Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f96e7aa051 | ||
|
|
68793119df | ||
|
|
f4025ed1fe | ||
|
|
39f7f91563 | ||
|
|
d366f4868a | ||
|
|
a67fe8c537 | ||
|
|
13f2fb8c99 | ||
|
|
3b96a8972a | ||
|
|
91cf995996 | ||
|
|
a773b7affa | ||
|
|
d3a8af4d9b | ||
|
|
18ea9de0fe | ||
|
|
e2c6c7e894 | ||
|
|
9dbf139133 | ||
|
|
77e3b0ac7a | ||
|
|
147509c0c5 | ||
|
|
7623501fc2 | ||
|
|
7027546ccf | ||
|
|
3d86a552b8 | ||
|
|
d465dc7ca1 | ||
|
|
ad94d52965 | ||
|
|
ebf200f952 | ||
|
|
075aa7e147 | ||
|
|
377ef091dc | ||
|
|
6e19e916c7 | ||
|
|
f6c59d8703 | ||
|
|
82ad9fa38b | ||
|
|
60d83f8b0e | ||
|
|
e2e82903fa | ||
|
|
939425152e | ||
|
|
02dc3ea4c3 | ||
|
|
fb30760996 | ||
|
|
add1f92bdd | ||
|
|
124e9af9d2 | ||
|
|
2d9622a4d4 | ||
|
|
30019c87c4 | ||
|
|
c44575ef14 | ||
|
|
81f9a394e9 | ||
|
|
30e2891808 | ||
|
|
b315b3ee97 | ||
|
|
c01eafa552 | ||
|
|
8f912415e8 | ||
|
|
5ad1681cf1 | ||
|
|
30546d1fd4 | ||
|
|
6bba484f1a | ||
|
|
e92b7855f6 | ||
|
|
4e13421378 | ||
|
|
b7871fc0d1 | ||
|
|
e0c95a5ffd | ||
|
|
b8a6272440 | ||
|
|
d1bd15ccce | ||
|
|
0bb0925920 | ||
|
|
a5165c53ac | ||
|
|
cd37044093 | ||
|
|
d67ad83426 | ||
|
|
e5a81899d0 | ||
|
|
78e818e3ce | ||
|
|
87abaa019e | ||
|
|
f427fa0753 | ||
|
|
0f25c31bd5 | ||
|
|
5512797527 | ||
|
|
39df91c982 | ||
|
|
7ce88bd686 | ||
|
|
01d04d50f8 | ||
|
|
9a56a0e845 | ||
|
|
88313499e0 | ||
|
|
2b4a99376c | ||
|
|
e922bc7125 | ||
|
|
950dd9ac67 | ||
|
|
b6b1b258c3 | ||
|
|
4138e853d5 | ||
|
|
641df5e5ad | ||
|
|
6a261db7d8 | ||
|
|
948f69330a | ||
|
|
96fb6d2fa9 | ||
|
|
400164d47c | ||
|
|
bb7bba885e | ||
|
|
4f066a6fb0 | ||
|
|
a836eb8c3c | ||
|
|
1a9e4235ac | ||
|
|
43f4eda808 | ||
|
|
acdf81510d | ||
|
|
1e85fe8e9a | ||
|
|
5fcf71b8b8 | ||
|
|
60bdc23467 | ||
|
|
694487c65b | ||
|
|
af5b627672 | ||
|
|
8713570d3c | ||
|
|
5a173edb63 | ||
|
|
b880b76c43 | ||
|
|
cf129bbb11 | ||
|
|
c1bf60d7b1 | ||
|
|
0fcfc99ea0 | ||
|
|
e7fbd528f7 | ||
|
|
2f84872ce0 | ||
|
|
f91cb0d6a7 | ||
|
|
8dd34b77d1 | ||
|
|
35f71382de | ||
|
|
144893850f | ||
|
|
26936bea45 | ||
|
|
43983edf18 | ||
|
|
c82ebbd5bf | ||
|
|
8edecb5c69 | ||
|
|
8d8c701d6a | ||
|
|
b63eed94b6 | ||
|
|
4f9d09ef52 | ||
|
|
ba8f2a324d | ||
|
|
721f05049d | ||
|
|
accd6d656a | ||
|
|
bd3f22e2f7 | ||
|
|
5db07cad71 | ||
|
|
acf96e7ab7 | ||
|
|
a199313eb3 | ||
|
|
b205993ed4 | ||
|
|
9ea503f505 | ||
|
|
3ff2dcb649 | ||
|
|
4713800b08 | ||
|
|
0e2e34aa86 | ||
|
|
76929b0a8a | ||
|
|
bf7be180e3 | ||
|
|
eec8e0b945 | ||
|
|
be7572e2cf | ||
|
|
64ee2f9847 | ||
|
|
132e0ca25d | ||
|
|
9eef4a7195 | ||
|
|
3f07e022ac | ||
|
|
551809688b | ||
|
|
08edcb8f2c | ||
|
|
d6f69da04d | ||
|
|
6cd40001a9 | ||
|
|
a84b315e7b | ||
|
|
83d4311ffe | ||
|
|
fce745fe5e | ||
|
|
1fd1ccf7ad | ||
|
|
efb7e3c55e | ||
|
|
b517b9bd01 | ||
|
|
479664e7aa | ||
|
|
a51df81573 | ||
|
|
a18c230189 | ||
|
|
e21d5327b0 | ||
|
|
4d1b53e6fb | ||
|
|
6100aca085 | ||
|
|
72116bafb3 | ||
|
|
e2c2edcc27 | ||
|
|
de1ce45913 | ||
|
|
51fc00122b | ||
|
|
445284b428 | ||
|
|
e8f7c93a0b | ||
|
|
0de38190d7 | ||
|
|
681dfaedcc | ||
|
|
9893d39218 | ||
|
|
5ba17e6fdf | ||
|
|
6f3b997dec | ||
|
|
cc62676656 |
No files matched your search
@@ -93,6 +93,7 @@ jobs:
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
@@ -124,6 +125,22 @@ jobs:
|
||||
- '"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'
|
||||
@@ -198,8 +215,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
@@ -434,6 +461,7 @@ jobs:
|
||||
|
||||
linux-depends:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
@@ -515,6 +543,7 @@ jobs:
|
||||
# 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
|
||||
@@ -665,6 +694,7 @@ jobs:
|
||||
# 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
|
||||
|
||||
@@ -321,6 +321,22 @@ jobs:
|
||||
- '"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'
|
||||
@@ -365,8 +381,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ jobs:
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'x/mlxrunner/xgrammar/native/**' \
|
||||
'x/mlxrunner/xgrammar/native/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
@@ -116,7 +118,7 @@ jobs:
|
||||
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
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so lib/ollama/mlx_cuda_v13/libollama_xgrammar.so
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
@@ -158,7 +160,9 @@ jobs:
|
||||
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 }}"
|
||||
for f in ${{ matrix.expected_payload }}; do
|
||||
test -e "${{ matrix.superbuild_dir }}/$f"
|
||||
done
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
@@ -214,7 +218,7 @@ jobs:
|
||||
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
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll lib\ollama\mlx_cuda_v13\ollama_xgrammar.dll
|
||||
install-go: true
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -334,8 +338,10 @@ jobs:
|
||||
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 }}"
|
||||
foreach ($f in "${{ matrix.expected_payload }}".Split(' ')) {
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\$f")) {
|
||||
throw "missing $f"
|
||||
}
|
||||
}
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
@@ -416,5 +422,3 @@ jobs:
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
@@ -45,6 +45,10 @@ 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)
|
||||
|
||||
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
|
||||
FROM scratch AS local-mlx-c
|
||||
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
@@ -42,8 +42,8 @@ ENV LDFLAGS=-s
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
@@ -74,7 +74,7 @@ RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, llama/compat/, or cmake/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
@@ -84,6 +84,7 @@ FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
@@ -91,8 +92,8 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
|
||||
/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
|
||||
|
||||
@@ -103,6 +104,7 @@ FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
@@ -115,6 +117,7 @@ FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
@@ -124,10 +127,11 @@ 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++
|
||||
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
@@ -141,6 +145,7 @@ FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
@@ -165,6 +170,7 @@ ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
@@ -185,6 +191,7 @@ ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
@@ -213,7 +220,9 @@ ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY mlx mlx
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY x/mlxrunner/xgrammar/native x/mlxrunner/xgrammar/native
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_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
|
||||
|
||||
@@ -1 +1 @@
|
||||
b9888
|
||||
b10630
|
||||
@@ -1 +1 @@
|
||||
de7b4ed986b6d6f55b8ace5e73c24d1ca0bea89b
|
||||
c793734eb715dbcfdb1ced58e348ec53c2d7ed85
|
||||
@@ -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), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
|
||||
@@ -474,7 +474,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
// Pre-compute the full-history token estimate once per batch instead of
|
||||
// re-marshaling the entire history for each tool call. Per-call deltas
|
||||
// (tool messages already appended this batch) are tracked in batchTokens
|
||||
@@ -530,7 +529,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
for _, plan := range plans {
|
||||
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
deniedContent := msg.Content
|
||||
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
|
||||
@@ -559,7 +557,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content = msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
@@ -580,7 +577,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
rawContent := fmt.Sprintf("Error: %v", err)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
@@ -609,7 +605,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
|
||||
@@ -637,7 +632,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
batchTokens := 0
|
||||
for _, call := range calls {
|
||||
@@ -645,7 +639,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
||||
args := call.Function.Arguments.ToMap()
|
||||
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -271,6 +273,354 @@ type skillRoot struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// SkillImportResult describes one import attempt. Failed skills do not prevent
|
||||
// other valid skills in the same source root from being imported.
|
||||
type SkillImportResult struct {
|
||||
Source string
|
||||
SourceDir string
|
||||
Destination string
|
||||
Imported []string
|
||||
Existing []string
|
||||
Failures []SkillImportFailure
|
||||
}
|
||||
|
||||
// SkillImportFailure identifies a source skill that was deliberately skipped.
|
||||
// The destination is never changed for a failed skill.
|
||||
type SkillImportFailure struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImportSkills imports skills from a conventional coding-agent source into the
|
||||
// canonical Ollama skills directory. Supported sources are codex, claude, and
|
||||
// pi. Existing skills are left untouched: an identical directory is reported
|
||||
// as existing, and a differing one is reported as a conflict.
|
||||
func ImportSkills(source string) (SkillImportResult, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
destination, err := SkillsDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
|
||||
}
|
||||
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
|
||||
}
|
||||
|
||||
func conventionalSkillImportRoots(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
}
|
||||
}
|
||||
|
||||
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
|
||||
source = strings.ToLower(strings.TrimSpace(source))
|
||||
sourceDir, ok := roots[source]
|
||||
if !ok {
|
||||
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
|
||||
}
|
||||
return importSkillsFromDir(source, sourceDir, destination)
|
||||
}
|
||||
|
||||
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
|
||||
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
|
||||
info, err := os.Lstat(sourceDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read %s skills directory: %w", source, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(sourceDir, name)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !skillName.MatchString(name) {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
|
||||
continue
|
||||
}
|
||||
if err := validateImportSkill(path, name); err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
state, err := importSkillDirectory(path, filepath.Join(destination, name))
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
if state == skillImportExisting {
|
||||
result.Existing = append(result.Existing, name)
|
||||
} else {
|
||||
result.Imported = append(result.Imported, name)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateImportSkill(dir, name string) error {
|
||||
manifest := filepath.Join(dir, skillFilename)
|
||||
info, err := os.Lstat(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", skillFilename, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
|
||||
}
|
||||
if _, err := parseSkill(manifest, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
if info.IsDir() || path == dir {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe skill path %q", path)
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks may not be imported: %s", path)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(path, entry, info)
|
||||
})
|
||||
}
|
||||
|
||||
type skillImportState int
|
||||
|
||||
const (
|
||||
skillImportCopied skillImportState = iota
|
||||
skillImportExisting
|
||||
)
|
||||
|
||||
func importSkillDirectory(source, destination string) (skillImportState, error) {
|
||||
if info, err := os.Lstat(destination); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return 0, errors.New("destination exists but is not a regular directory")
|
||||
}
|
||||
same, err := sameImportTree(source, destination)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("inspect existing destination: %w", err)
|
||||
}
|
||||
if same {
|
||||
return skillImportExisting, nil
|
||||
}
|
||||
return 0, errors.New("destination skill already exists with different contents")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create import staging directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
if err := copyImportTree(source, stage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return 0, errors.New("destination skill was created during import")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination before install: %w", err)
|
||||
}
|
||||
if err := os.Rename(stage, destination); err != nil {
|
||||
return 0, fmt.Errorf("install imported skill: %w", err)
|
||||
}
|
||||
return skillImportCopied, nil
|
||||
}
|
||||
|
||||
func ensureImportDestination(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create Ollama skills directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect Ollama skills directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyImportTree(source, destination string) error {
|
||||
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := destination
|
||||
if rel != "." {
|
||||
target = filepath.Join(destination, rel)
|
||||
}
|
||||
if info.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
return os.Mkdir(target, info.Mode().Perm())
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
return copyImportFile(path, target, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
|
||||
func copyImportFile(source, destination string, mode fs.FileMode) error {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", destination, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %s: %w", source, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("write %s: %w", destination, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameImportTree(source, destination string) (bool, error) {
|
||||
seen := make(map[string]struct{})
|
||||
same := true
|
||||
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[rel] = struct{}{}
|
||||
other := destination
|
||||
if rel != "." {
|
||||
other = filepath.Join(destination, rel)
|
||||
}
|
||||
otherInfo, err := os.Lstat(other)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
equal, err := sameImportFile(path, other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equal {
|
||||
same = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || !same {
|
||||
return same, err
|
||||
}
|
||||
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(destination, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[rel]; !ok {
|
||||
same = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return same, err
|
||||
}
|
||||
|
||||
func sameImportFile(first, second string) (bool, error) {
|
||||
a, err := os.Open(first)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := os.Open(second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
left := make([]byte, 32*1024)
|
||||
right := make([]byte, len(left))
|
||||
for {
|
||||
n, errA := a.Read(left)
|
||||
m, errB := b.Read(right)
|
||||
if n != m || !bytes.Equal(left[:n], right[:m]) {
|
||||
return false, nil
|
||||
}
|
||||
if errA == io.EOF && errB == io.EOF {
|
||||
return true, nil
|
||||
}
|
||||
if errA != nil && errA != io.EOF {
|
||||
return false, errA
|
||||
}
|
||||
if errB != nil && errB != io.EOF {
|
||||
return false, errB
|
||||
}
|
||||
if errA == io.EOF || errB == io.EOF {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSkillRoots returns skill directories ordered lowest- to
|
||||
// highest-precedence. Non-existent directories are scanned harmlessly
|
||||
// (DiscoverSkills skips them).
|
||||
@@ -325,6 +675,31 @@ func (c *SkillCatalog) Diagnostics() []error {
|
||||
return append([]error(nil), c.diagnostics...)
|
||||
}
|
||||
|
||||
// ExcludeNames removes skills whose names are reserved by a caller. It returns
|
||||
// the excluded names in sorted order.
|
||||
func (c *SkillCatalog) ExcludeNames(names []string) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
reserved := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
|
||||
if name != "" {
|
||||
reserved[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var excluded []string
|
||||
for name := range c.skills {
|
||||
if _, ok := reserved[name]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(c.skills, name)
|
||||
excluded = append(excluded, name)
|
||||
}
|
||||
sort.Strings(excluded)
|
||||
return excluded
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) Load(name string) (Skill, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !skillName.MatchString(name) {
|
||||
|
||||
@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportFixtureSkill(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "release-notes", skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAndLoadSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
|
||||
@@ -257,6 +272,30 @@ func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCatalogExcludeNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"release-notes", "system", "exit"} {
|
||||
writeCatalogSkill(t, dir, name, "instructions")
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want {
|
||||
t.Fatalf("excluded skills = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := catalog.Load("system"); err == nil {
|
||||
t.Fatal("excluded system skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("exit"); err == nil {
|
||||
t.Fatal("excluded exit skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("non-conflicting skill should remain available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillDir := filepath.Join(root, "pdf-processing")
|
||||
@@ -291,3 +330,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
t.Fatalf("content missing resource listing: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeImportFixtureSkill(t, source)
|
||||
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
|
||||
t.Fatalf("imported = %q, want %q", got, want)
|
||||
}
|
||||
catalog, err := DiscoverSkills(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load("release-notes")
|
||||
if err != nil || skill.Description != "Draft concise release notes." {
|
||||
t.Fatalf("imported skill = %#v, %v", skill, err)
|
||||
}
|
||||
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
|
||||
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
|
||||
}
|
||||
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
|
||||
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
|
||||
t.Fatalf("imported fixture file %q: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err = importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
|
||||
t.Fatalf("existing = %q, want %q", got, want)
|
||||
}
|
||||
if len(result.Imported) != 0 {
|
||||
t.Fatalf("repeated import copied skills: %#v", result.Imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeCatalogSkill(t, source, "release-notes", "source instructions")
|
||||
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
|
||||
writeCatalogSkill(t, source, "nested-link", "safe manifest")
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
t.Fatalf("unexpected successful import: %#v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
|
||||
t.Fatalf("conflicting destination changed: %q, %v", got, err)
|
||||
}
|
||||
failed := make(map[string]bool)
|
||||
for _, failure := range result.Failures {
|
||||
failed[failure.Name] = true
|
||||
}
|
||||
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
|
||||
if !failed[name] {
|
||||
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(t.TempDir(), "codex-skills")
|
||||
if err := os.Symlink(root, source); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
|
||||
t.Fatalf("symlinked root error = %v", err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("symlinked root result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
|
||||
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("missing root result = %#v", result)
|
||||
}
|
||||
|
||||
destination := t.TempDir()
|
||||
rootBase := t.TempDir()
|
||||
roots := map[string]string{
|
||||
"codex": filepath.Join(rootBase, "codex"),
|
||||
"claude": filepath.Join(rootBase, "claude"),
|
||||
"pi": filepath.Join(rootBase, "pi"),
|
||||
}
|
||||
for _, test := range []struct {
|
||||
source string
|
||||
root string
|
||||
name string
|
||||
}{
|
||||
{source: "codex", root: roots["codex"], name: "from-codex"},
|
||||
{source: "claude", root: roots["claude"], name: "from-claude"},
|
||||
{source: "pi", root: roots["pi"], name: "from-pi"},
|
||||
} {
|
||||
t.Run(test.source, func(t *testing.T) {
|
||||
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
|
||||
result, err = importSkillsFromRoots(test.source, roots, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.SourceDir != test.root {
|
||||
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
|
||||
t.Fatalf("conventional source was not imported: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
|
||||
t.Fatalf("unknown source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventionalSkillImportRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
roots := conventionalSkillImportRoots(home)
|
||||
for source, want := range map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
} {
|
||||
if got := roots[source]; got != want {
|
||||
t.Fatalf("%s root = %q, want %q", source, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
writeCatalogSkill(t, source, "private", "do not read")
|
||||
manifest := filepath.Join(source, "private", skillFilename)
|
||||
if err := os.Chmod(manifest, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
|
||||
if _, err := os.ReadFile(manifest); err == nil {
|
||||
t.Skip("test user can read a mode-000 file")
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
|
||||
t.Fatalf("failures = %#v", result.Failures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes.
|
||||
---
|
||||
|
||||
# Release notes
|
||||
|
||||
Use short bullets.
|
||||
@@ -2,11 +2,14 @@ package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -105,26 +108,37 @@ func (e *Edit) Name() string {
|
||||
}
|
||||
|
||||
func (e *Edit) Description() string {
|
||||
return "Edit a text file in the current working directory by replacing exact text."
|
||||
return "Edit a text file in the current working directory by replacing exact text. Pass multiple edits to change separate parts of the file in one call."
|
||||
}
|
||||
|
||||
func (e *Edit) Schema() api.ToolFunction {
|
||||
editProps := api.NewToolPropertiesMap()
|
||||
editProps.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text for one targeted replacement. Must match the original file exactly once and must not overlap with any other edit's old_text.",
|
||||
})
|
||||
editProps.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text for this targeted edit.",
|
||||
})
|
||||
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("path", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Path to the file to edit, relative to the working directory.",
|
||||
})
|
||||
props.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text to replace.",
|
||||
})
|
||||
props.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text.",
|
||||
props.Set("edits", api.ToolProperty{
|
||||
Type: api.PropertyType{"array"},
|
||||
Items: api.ToolProperty{
|
||||
Type: api.PropertyType{"object"},
|
||||
Properties: editProps,
|
||||
Required: []string{"old_text", "new_text"},
|
||||
},
|
||||
Description: "One or more exact-text replacements. Each is matched against the original file, not against the output of earlier edits. Keep old_text as small as possible while still unique in the file; merge changes to the same or adjacent lines into a single edit.",
|
||||
})
|
||||
props.Set("replace_all", api.ToolProperty{
|
||||
Type: api.PropertyType{"boolean"},
|
||||
Description: "Replace every occurrence. Defaults to false and requires old_text to match exactly once.",
|
||||
Description: "Replace every occurrence. Defaults to false; only applies when a single edit is provided.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: e.Name(),
|
||||
@@ -132,7 +146,7 @@ func (e *Edit) Schema() api.ToolFunction {
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path", "old_text", "new_text"},
|
||||
Required: []string{"path", "edits"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -148,18 +162,11 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
if !ok || oldText == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text parameter is required")
|
||||
edits, replaceAll, err := parseEditArgs(args)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return agent.ToolResult{}, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
if err := rejectFinalSymlink(toolCtx.WorkingDir, path); err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
@@ -188,19 +195,56 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
content := string(contentBytes)
|
||||
matches := strings.Count(content, oldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
if matches > 1 && !replaceAll {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", matches, path)
|
||||
}
|
||||
|
||||
var updated string
|
||||
replacements := 0
|
||||
if replaceAll {
|
||||
updated = strings.ReplaceAll(content, oldText, newText)
|
||||
matches := strings.Count(content, edits[0].OldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
updated = strings.ReplaceAll(content, edits[0].OldText, edits[0].NewText)
|
||||
replacements = matches
|
||||
} else {
|
||||
updated = strings.Replace(content, oldText, newText, 1)
|
||||
// Every edit is matched against the original file content rather
|
||||
// than the output of earlier edits, so each edit must match exactly
|
||||
// once and edits must target disjoint regions.
|
||||
matched := make([]editMatch, 0, len(edits))
|
||||
for i, edit := range edits {
|
||||
count := strings.Count(content, edit.OldText)
|
||||
if count == 0 {
|
||||
return agent.ToolResult{}, editNotFoundError(path, i, len(edits))
|
||||
}
|
||||
if count > 1 {
|
||||
return agent.ToolResult{}, editAmbiguousError(path, i, len(edits), count)
|
||||
}
|
||||
matched = append(matched, editMatch{
|
||||
editIndex: i,
|
||||
offset: strings.Index(content, edit.OldText),
|
||||
length: len(edit.OldText),
|
||||
newText: edit.NewText,
|
||||
})
|
||||
replacements++
|
||||
}
|
||||
|
||||
slices.SortFunc(matched, func(a, b editMatch) int { return cmp.Compare(a.offset, b.offset) })
|
||||
for i := 1; i < len(matched); i++ {
|
||||
prev, cur := matched[i-1], matched[i]
|
||||
if prev.offset+prev.length > cur.offset {
|
||||
return agent.ToolResult{}, fmt.Errorf("edits[%d] and edits[%d] overlap in %s; merge them into one edit or target disjoint text", prev.editIndex, cur.editIndex, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply from the end of the file backwards so earlier offsets stay valid.
|
||||
updated = content
|
||||
for i := len(matched) - 1; i >= 0; i-- {
|
||||
m := matched[i]
|
||||
updated = updated[:m.offset] + m.newText + updated[m.offset+m.length:]
|
||||
}
|
||||
}
|
||||
|
||||
if updated == content {
|
||||
return agent.ToolResult{}, fmt.Errorf("edit produced no changes in %s; replacement text is identical to the original", path)
|
||||
}
|
||||
if len(updated) > maxReadBytes {
|
||||
return agent.ToolResult{}, fmt.Errorf("edited content is too large (%d bytes)", len(updated))
|
||||
@@ -210,7 +254,116 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d replacement%s).", path, matches, plural(matches))}, nil
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d edit%s, %d replacement%s).", path, len(edits), plural(len(edits)), replacements, plural(replacements))}, nil
|
||||
}
|
||||
|
||||
// editReplacement is one targeted replacement within an edit call.
|
||||
type editReplacement struct {
|
||||
OldText string
|
||||
NewText string
|
||||
}
|
||||
|
||||
// editMatch locates one editReplacement within the original file content.
|
||||
type editMatch struct {
|
||||
editIndex int
|
||||
offset int
|
||||
length int
|
||||
newText string
|
||||
}
|
||||
|
||||
// parseEditArgs normalizes edit arguments from a tool call into a list of
|
||||
// replacements. It accepts the `edits` array form and tolerates legacy
|
||||
// top-level old_text/new_text args as well as stringified JSON, mirroring
|
||||
// the pi coding agent's argument handling.
|
||||
func parseEditArgs(args map[string]any) ([]editReplacement, bool, error) {
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
var edits []editReplacement
|
||||
if raw, ok := args["edits"]; ok {
|
||||
parsed, err := parseEditArray(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
edits = parsed
|
||||
}
|
||||
|
||||
// Fold a legacy top-level old_text/new_text pair into edits.
|
||||
if oldText, ok := args["old_text"].(string); ok {
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
|
||||
if len(edits) == 0 {
|
||||
return nil, false, fmt.Errorf("edits parameter is required")
|
||||
}
|
||||
for i, edit := range edits {
|
||||
if edit.OldText == "" {
|
||||
if len(edits) == 1 {
|
||||
return nil, false, fmt.Errorf("old_text parameter is required")
|
||||
}
|
||||
return nil, false, fmt.Errorf("edits[%d].old_text must not be empty", i)
|
||||
}
|
||||
}
|
||||
if replaceAll && len(edits) != 1 {
|
||||
return nil, false, fmt.Errorf("replace_all only applies to a single edit")
|
||||
}
|
||||
return edits, replaceAll, nil
|
||||
}
|
||||
|
||||
func parseEditArray(raw any) ([]editReplacement, error) {
|
||||
if s, ok := raw.(string); ok {
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
|
||||
edits := make([]editReplacement, 0, len(items))
|
||||
for i, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
oldText, oldOK := editTextArg(entry, "old_text", "oldText")
|
||||
newText, newOK := editTextArg(entry, "new_text", "newText")
|
||||
if !oldOK || !newOK {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
// editTextArg reads the first present string key, tolerating both snake_case
|
||||
// and camelCase spellings that models emit.
|
||||
func editTextArg(entry map[string]any, keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := entry[key].(string); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func editNotFoundError(path string, editIndex, totalEdits int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text was not found in %s", editIndex, path)
|
||||
}
|
||||
|
||||
func editAmbiguousError(path string, editIndex, totalEdits, occurrences int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", occurrences, path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text matched %d times in %s; each edit must match exactly once, so provide more surrounding context", editIndex, occurrences, path)
|
||||
}
|
||||
|
||||
func cleanRelativePath(path string) (string, error) {
|
||||
|
||||
@@ -59,6 +59,239 @@ func TestEditRequiresUniqueMatchByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAppliesMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha beta gamma delta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "beta", "new_text": "BETA"},
|
||||
map[string]any{"old_text": "delta", "new_text": "DELTA"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "2 edits, 2 replacements") {
|
||||
t.Fatalf("result = %q", result.Content)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "alpha BETA gamma DELTA\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMatchesEditsAgainstOriginalContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc def\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// edits[1] must target the original "def", not the one introduced by edits[0].
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "abc", "new_text": "def"},
|
||||
map[string]any{"old_text": "def", "new_text": "ghi"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "def ghi\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsOverlappingEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "ab", "new_text": "x"},
|
||||
map[string]any{"old_text": "bc", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overlap") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsNotFoundIndexed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "missing", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsAmbiguousIndexed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello same same\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "same", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") || !strings.Contains(err.Error(), "matched 2 times") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, args := range map[string]map[string]any{
|
||||
"missing edits": {"path": "note.txt"},
|
||||
"empty edits": {"path": "note.txt", "edits": []any{}},
|
||||
} {
|
||||
if _, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, args); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
} else if !strings.Contains(err.Error(), "edits parameter is required") {
|
||||
t.Fatalf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyOldTextInArray(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty old_text to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1].old_text must not be empty") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAcceptsJSONStringEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": `[{"oldText": "hello", "newText": "hi"}, {"oldText": "world", "newText": "earth"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hi earth\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsReplaceAllWithMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("a b c\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"replace_all": true,
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "a", "new_text": "x"},
|
||||
map[string]any{"old_text": "b", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected replace_all with multiple edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "replace_all") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsNoChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"old_text": "hello",
|
||||
"new_text": "hello",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-change edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no changes") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEscapingPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// Skill is the model-facing adapter for the core agent skill catalog.
|
||||
// It only supplies instructions; regular tools retain their own approval
|
||||
// requirements for filesystem or network access.
|
||||
// Model-initiated loads require approval because a skill's instructions can
|
||||
// influence the rest of the run. Explicit user activation is handled by the
|
||||
// session's synthetic skill call and bypasses this adapter.
|
||||
type Skill struct{ Catalog *agent.SkillCatalog }
|
||||
|
||||
func (t *Skill) Name() string { return "skill" }
|
||||
@@ -25,6 +26,8 @@ func (t *Skill) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
|
||||
}
|
||||
|
||||
func (t *Skill) RequiresApproval(map[string]any) bool { return true }
|
||||
|
||||
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
name, ok := args["name"].(string)
|
||||
if !ok {
|
||||
|
||||
@@ -8,9 +8,117 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
|
||||
func TestSkillLoadsCoreCatalogWithApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
tool := &Skill{Catalog: catalog}
|
||||
if !agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||
t.Fatal("model-initiated skill loading should require approval")
|
||||
}
|
||||
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSkillLoadRequiresApproval(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
approval agent.Approval
|
||||
prompt bool
|
||||
wantCalls int
|
||||
wantPrompts int
|
||||
wantResult string
|
||||
}{
|
||||
{name: "rejected", approval: agent.Approval{Reason: "Skill loading denied."}, prompt: true, wantCalls: 1, wantPrompts: 1, wantResult: "Skill loading denied."},
|
||||
{name: "approved", approval: agent.Approval{Allow: true}, prompt: true, wantCalls: 2, wantPrompts: 1, wantResult: "Use concise bullets."},
|
||||
{name: "headless denied", wantCalls: 1, wantResult: "Tool execution requires approval"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("name", "release-notes")
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call_skill_1",
|
||||
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
|
||||
}}}}},
|
||||
{{Message: api.Message{Role: "assistant", Content: "done"}}},
|
||||
}}
|
||||
var prompter *skillApprovalPrompter
|
||||
var approvalPrompter agent.ApprovalPrompter
|
||||
if tt.prompt {
|
||||
prompter = &skillApprovalPrompter{result: tt.approval}
|
||||
approvalPrompter = prompter
|
||||
}
|
||||
registry := &agent.Registry{}
|
||||
registry.Register(&Skill{Catalog: catalog})
|
||||
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Tools: registry,
|
||||
ApprovalPrompter: approvalPrompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "load the release-notes skill"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.prompt {
|
||||
if got := len(prompter.requests); got != tt.wantPrompts {
|
||||
t.Fatalf("approval prompts = %d, want %d", got, tt.wantPrompts)
|
||||
}
|
||||
request := prompter.requests[0]
|
||||
if len(request.Calls) != 1 || request.Calls[0].ToolName != "skill" || request.Calls[0].ApprovalScope != "skill" || request.Calls[0].Args["name"] != "release-notes" {
|
||||
t.Fatalf("approval request = %#v", request)
|
||||
}
|
||||
}
|
||||
if got := client.calls; got != tt.wantCalls {
|
||||
t.Fatalf("model calls = %d, want %d", got, tt.wantCalls)
|
||||
}
|
||||
var toolResult string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == "tool" && message.ToolCallID == "call_skill_1" {
|
||||
toolResult = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(toolResult, tt.wantResult) {
|
||||
t.Fatalf("skill tool result = %q, want it to contain %q", toolResult, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSkillActivationBypassesApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{{{Message: api.Message{Role: "assistant", Content: "done"}}}}}
|
||||
prompter := &skillApprovalPrompter{result: agent.Approval{}}
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Skills: catalog,
|
||||
ApprovalPrompter: prompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
|
||||
SkillName: "release-notes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prompter.requests) != 0 {
|
||||
t.Fatalf("explicit activation prompted for approval: %#v", prompter.requests)
|
||||
}
|
||||
if len(result.Messages) != 4 || result.Messages[2].ToolName != "skill" || !strings.Contains(result.Messages[2].Content, "Use concise bullets.") {
|
||||
t.Fatalf("synthetic skill activation = %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func testSkillCatalog(t *testing.T) *agent.SkillCatalog {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "release-notes")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
@@ -23,12 +131,33 @@ func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tool := &Skill{Catalog: catalog}
|
||||
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||
t.Fatal("loading a skill must not change ordinary tool approval semantics")
|
||||
}
|
||||
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v, %v", result, err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
type skillTestClient struct {
|
||||
responses [][]api.ChatResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *skillTestClient) Chat(_ context.Context, _ *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||
if c.calls >= len(c.responses) {
|
||||
return nil
|
||||
}
|
||||
for _, response := range c.responses[c.calls] {
|
||||
if err := fn(response); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type skillApprovalPrompter struct {
|
||||
requests []agent.ApprovalRequest
|
||||
result agent.Approval
|
||||
}
|
||||
|
||||
func (p *skillApprovalPrompter) PromptApproval(_ context.Context, request agent.ApprovalRequest) (agent.Approval, error) {
|
||||
p.requests = append(p.requests, request)
|
||||
return p.result, nil
|
||||
}
|
||||
@@ -1075,7 +1075,7 @@ type CountTokensRequest struct {
|
||||
|
||||
// EstimateInputTokens estimates input tokens from a MessagesRequest (reuses CountTokensRequest logic)
|
||||
func EstimateInputTokens(req MessagesRequest) int {
|
||||
return estimateTokens(CountTokensRequest{
|
||||
return EstimateCountTokens(CountTokensRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
System: req.System,
|
||||
@@ -1089,10 +1089,10 @@ type CountTokensResponse struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
|
||||
// estimateTokens returns a rough estimate of tokens (len/4).
|
||||
// EstimateCountTokens returns a rough estimate of tokens (len/4).
|
||||
// TODO: Replace with actual tokenization via Tokenize API for accuracy.
|
||||
// Current len/4 heuristic is a rough approximation (~4 chars/token average).
|
||||
func estimateTokens(req CountTokensRequest) int {
|
||||
func EstimateCountTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
|
||||
@@ -145,6 +145,118 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ClaudeAutoModeClassifierFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
model string
|
||||
maxTokens int
|
||||
wantStop []string
|
||||
wantSystem string
|
||||
wantUser string
|
||||
}{
|
||||
{
|
||||
name: "stage one local model",
|
||||
request: `{
|
||||
"model": "qwen3.5:latest",
|
||||
"max_tokens": 2112,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<transcript>\n"},
|
||||
{"type": "text", "text": "User: Run the safe test.\n"},
|
||||
{"type": "text", "text": "Bash go test ./safe\n"},
|
||||
{"type": "text", "text": "</transcript>\n"},
|
||||
{"type": "text", "text": "Return only the stage-one block verdict."}
|
||||
]
|
||||
}],
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Synthetic policy fixture. Evaluate whether the proposed action needs further review.",
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
},
|
||||
{"type": "text", "text": "Synthetic session context."}
|
||||
],
|
||||
"stop_sequences": ["</block>"]
|
||||
}`,
|
||||
model: "qwen3.5:latest",
|
||||
maxTokens: 2112,
|
||||
wantStop: []string{"</block>"},
|
||||
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action needs further review.Synthetic session context.",
|
||||
wantUser: "<transcript>\nUser: Run the safe test.\nBash go test ./safe\n</transcript>\nReturn only the stage-one block verdict.",
|
||||
},
|
||||
{
|
||||
name: "stage two cloud model",
|
||||
request: `{
|
||||
"model": "glm-5.2:cloud",
|
||||
"max_tokens": 10240,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<transcript>\n"},
|
||||
{"type": "text", "text": "User: Send the fixture to an external host.\n"},
|
||||
{"type": "text", "text": "Bash upload fixture.txt\n"},
|
||||
{"type": "text", "text": "</transcript>\n"},
|
||||
{"type": "text", "text": "Return the stage-two block verdict and reason."}
|
||||
]
|
||||
}],
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Synthetic policy fixture. Evaluate whether the proposed action must be denied.",
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
},
|
||||
{"type": "text", "text": "Synthetic session context."}
|
||||
]
|
||||
}`,
|
||||
model: "glm-5.2:cloud",
|
||||
maxTokens: 10240,
|
||||
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action must be denied.Synthetic session context.",
|
||||
wantUser: "<transcript>\nUser: Send the fixture to an external host.\nBash upload fixture.txt\n</transcript>\nReturn the stage-two block verdict and reason.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var request MessagesRequest
|
||||
if err := json.Unmarshal([]byte(tt.request), &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
converted, err := FromMessagesRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if converted.Model != tt.model {
|
||||
t.Fatalf("model = %q, want exact selected model %q", converted.Model, tt.model)
|
||||
}
|
||||
if converted.Stream == nil || *converted.Stream {
|
||||
t.Fatalf("stream = %v, want explicit non-streaming conversion", converted.Stream)
|
||||
}
|
||||
if len(converted.Tools) != 0 {
|
||||
t.Fatalf("tools = %v, want tool-free classifier request", converted.Tools)
|
||||
}
|
||||
if got := converted.Options["num_predict"]; got != tt.maxTokens {
|
||||
t.Fatalf("num_predict = %v, want %d", got, tt.maxTokens)
|
||||
}
|
||||
gotStop, _ := converted.Options["stop"].([]string)
|
||||
if diff := cmp.Diff(tt.wantStop, gotStop); diff != "" {
|
||||
t.Fatalf("stop sequences mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if len(converted.Messages) != 2 {
|
||||
t.Fatalf("messages = %+v, want system and user messages", converted.Messages)
|
||||
}
|
||||
if got := converted.Messages[0]; got.Role != "system" || got.Content != tt.wantSystem {
|
||||
t.Fatalf("system message = %+v", got)
|
||||
}
|
||||
if got := converted.Messages[1]; got.Role != "user" || got.Content != tt.wantUser {
|
||||
t.Fatalf("user message = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithImage(t *testing.T) {
|
||||
imgData, _ := base64.StdEncoding.DecodeString(testImage)
|
||||
|
||||
@@ -779,6 +891,40 @@ func TestToMessagesResponse_Basic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToMessagesResponse_PreservesClaudeAutoClassifierOutput(t *testing.T) {
|
||||
for _, output := range []string{
|
||||
"<block>no",
|
||||
"<block>yes</block><category>Synthetic risk</category><reason>Denied by the synthetic fixture.</reason>",
|
||||
"malformed classifier output",
|
||||
} {
|
||||
t.Run(output, func(t *testing.T) {
|
||||
result := ToMessagesResponse("msg_classifier", api.ChatResponse{
|
||||
Model: "qwen3.5:latest",
|
||||
Message: api.Message{
|
||||
Role: "assistant",
|
||||
Content: output,
|
||||
},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{
|
||||
PromptEvalCount: 24644,
|
||||
EvalCount: 300,
|
||||
},
|
||||
})
|
||||
|
||||
if result.Model != "qwen3.5:latest" || len(result.Content) != 1 || result.Content[0].Text == nil || *result.Content[0].Text != output {
|
||||
t.Fatalf("classifier response = %+v, want opaque output on the selected model", result)
|
||||
}
|
||||
if result.StopReason != "end_turn" {
|
||||
t.Fatalf("stop reason = %q, want end_turn", result.StopReason)
|
||||
}
|
||||
if result.Usage.InputTokens != 24644 || result.Usage.OutputTokens != 300 {
|
||||
t.Fatalf("usage = %+v", result.Usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
||||
resp := api.ChatResponse{
|
||||
Model: "test-model",
|
||||
@@ -1546,7 +1692,7 @@ func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// "user" (4) + "Hello, world!" (13) = 17 chars / 4 = 4 tokens
|
||||
if tokens < 1 {
|
||||
@@ -1567,7 +1713,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// System prompt adds to count
|
||||
if tokens < 5 {
|
||||
@@ -1590,7 +1736,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// Tools add significant content
|
||||
if tokens < 10 {
|
||||
@@ -1619,7 +1765,7 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// Thinking content should be counted
|
||||
if tokens < 10 {
|
||||
@@ -1633,7 +1779,7 @@ func TestEstimateTokens_EmptyContent(t *testing.T) {
|
||||
Messages: []MessageParam{},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
if tokens != 0 {
|
||||
t.Errorf("expected 0 tokens for empty content, got %d", tokens)
|
||||
|
||||
@@ -510,13 +510,3 @@ func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) {
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Usage returns the authenticated user's recent activity and included-usage
|
||||
// limits.
|
||||
func (c *Client) Usage(ctx context.Context) (*UsageResponse, error) {
|
||||
var resp UsageResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/usage", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -51,32 +52,6 @@ func TestClientFromEnvironment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUsage(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/usage" {
|
||||
t.Fatalf("request = %s %s, want GET /api/usage", r.Method, r.URL.Path)
|
||||
}
|
||||
fmt.Fprint(w, `{"activity":{"cost":"0.00709","period":{"type":"last_4_weeks","starting_at":"2026-06-29T00:00:00Z","ending_at":"2026-07-27T00:00:00Z"},"models":[{"name":"qwen3-coder:480b","request_count":1,"cost":"0.00709"}]},"limits":{"session":{"usage":0.006,"models":[]},"weekly":{"usage":0,"models":[]}}}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
base, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := NewClient(base, ts.Client()).Usage(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Activity.Cost != "0.00709" {
|
||||
t.Errorf("activity cost = %q, want 0.00709", got.Activity.Cost)
|
||||
}
|
||||
if len(got.Activity.Models) != 1 || got.Activity.Models[0].Name != "qwen3-coder:480b" {
|
||||
t.Errorf("activity models = %#v, want qwen3-coder:480b", got.Activity.Models)
|
||||
}
|
||||
}
|
||||
|
||||
// testError represents an internal error type with status code and message
|
||||
// this is used since the error response from the server is not a standard error struct
|
||||
type testError struct {
|
||||
@@ -414,6 +389,64 @@ func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -127,20 +127,6 @@ type GenerateRequest struct {
|
||||
// each with an associated log probability. Only applies when Logprobs is true.
|
||||
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Width is the width of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Width int32 `json:"width,omitempty"`
|
||||
|
||||
// Height is the height of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Height int32 `json:"height,omitempty"`
|
||||
|
||||
// Steps is the number of diffusion steps for image generation.
|
||||
// Only used for image generation models.
|
||||
Steps int32 `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest describes a request sent by [Client.Chat].
|
||||
@@ -706,8 +692,11 @@ type CreateRequest struct {
|
||||
// Messages is a list of messages added to the model before chat and generation requests.
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
|
||||
// Renderer is the name of the renderer used when constructing a request to the model.
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Parser is the name of the parser used to parse the output of the request.
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
@@ -811,9 +800,19 @@ type ListResponse struct {
|
||||
|
||||
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
|
||||
type ModelRecommendationsResponse struct {
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
Mappings *ModelRecommendationMappings `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMapping defines one app-specific route preference.
|
||||
type ModelRecommendationMapping struct {
|
||||
Model string `json:"model"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMappings defines the app-specific model routes.
|
||||
type ModelRecommendationMappings map[string]ModelRecommendationMapping
|
||||
|
||||
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
|
||||
type ModelRecommendation struct {
|
||||
Model string `json:"model"`
|
||||
@@ -938,20 +937,6 @@ type GenerateResponse struct {
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Image contains a base64-encoded generated image.
|
||||
// Only present for image generation models.
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// Completed is the number of completed steps in image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
|
||||
// Total is the total number of steps for image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ModelDetails provides details about a model.
|
||||
@@ -978,45 +963,6 @@ type UserResponse struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
}
|
||||
|
||||
// UsageResponse reports recent activity and included-usage limits.
|
||||
type UsageResponse struct {
|
||||
Activity UsageActivity `json:"activity"`
|
||||
Limits UsageLimits `json:"limits"`
|
||||
}
|
||||
|
||||
// UsageActivity reports usage activity over a period.
|
||||
type UsageActivity struct {
|
||||
Cost string `json:"cost"`
|
||||
Period UsagePeriod `json:"period"`
|
||||
Models []UsageModel `json:"models"`
|
||||
}
|
||||
|
||||
// UsagePeriod describes the time window the usage covers.
|
||||
type UsagePeriod struct {
|
||||
Type string `json:"type"`
|
||||
StartingAt time.Time `json:"starting_at"`
|
||||
EndingAt time.Time `json:"ending_at"`
|
||||
}
|
||||
|
||||
// UsageLimits reports included usage for the current session and week.
|
||||
type UsageLimits struct {
|
||||
Session UsageLimit `json:"session"`
|
||||
Weekly UsageLimit `json:"weekly"`
|
||||
}
|
||||
|
||||
// UsageLimit reports the consumed fraction of an included-usage limit.
|
||||
type UsageLimit struct {
|
||||
Usage float64 `json:"usage"`
|
||||
Models []UsageModel `json:"models"`
|
||||
}
|
||||
|
||||
// UsageModel reports a model's activity.
|
||||
type UsageModel struct {
|
||||
Name string `json:"name"`
|
||||
RequestCount int `json:"request_count"`
|
||||
Cost string `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
// Tensor describes the metadata for a given tensor.
|
||||
type Tensor struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1169,7 +1115,7 @@ func DefaultOptions() Options {
|
||||
TopP: 0.9,
|
||||
TypicalP: 1.0,
|
||||
RepeatLastN: 64,
|
||||
RepeatPenalty: 1.1,
|
||||
RepeatPenalty: 1.0,
|
||||
PresencePenalty: 0.0,
|
||||
FrequencyPenalty: 0.0,
|
||||
Seed: -1,
|
||||
|
||||
@@ -146,15 +146,10 @@ func main() {
|
||||
// Do this after logging is set up so we can debug issues
|
||||
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
|
||||
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
|
||||
if checkAndHandleExistingInstance(urlSchemeRequest) {
|
||||
// The function will exit if it successfully sends to another instance
|
||||
// If we reach here, we're the first/only instance
|
||||
} else {
|
||||
// No existing instance found, handle the URL scheme in this instance
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
}
|
||||
// This exits after forwarding the request when another instance is
|
||||
// running. First-instance requests are handled later by osRun, after the
|
||||
// Windows UI dependencies are initialized and from the primary thread.
|
||||
checkAndHandleExistingInstance(urlSchemeRequest)
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
@@ -180,7 +175,9 @@ func main() {
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance(startHidden)
|
||||
if !handleExistingInstance(startHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
@@ -205,6 +202,12 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
if devMode {
|
||||
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
|
||||
st.DBPath = dbPath
|
||||
slog.Debug("using development app database", "path", dbPath)
|
||||
}
|
||||
}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
@@ -324,11 +327,11 @@ func main() {
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" {
|
||||
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else {
|
||||
} else if urlSchemeRequest == "" {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
@@ -343,7 +346,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
settings, settingsErr := st.Settings()
|
||||
showOnboarding := shouldShowOnboarding(settings, settingsErr)
|
||||
if settingsErr != nil {
|
||||
slog.Error("failed to load onboarding state", "error", settingsErr)
|
||||
}
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
@@ -355,6 +364,33 @@ func main() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func shouldShowOnboarding(settings store.Settings, err error) bool {
|
||||
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
|
||||
}
|
||||
|
||||
func runInitialWindowsUI(
|
||||
startHidden bool,
|
||||
showOnboarding bool,
|
||||
urlSchemeRequest string,
|
||||
startHiddenFn func(),
|
||||
handleURLFn func(string),
|
||||
showUIFn func(string),
|
||||
) {
|
||||
if urlSchemeRequest != "" {
|
||||
handleURLFn(urlSchemeRequest)
|
||||
return
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenFn()
|
||||
return
|
||||
}
|
||||
if showOnboarding {
|
||||
showUIFn("/")
|
||||
return
|
||||
}
|
||||
showUIFn("/connect")
|
||||
}
|
||||
|
||||
func startHiddenTasks() {
|
||||
// If an upgrade is ready and we're in hidden mode, perform it at startup.
|
||||
// If we're not in hidden mode, we want to start as fast as possible and not
|
||||
@@ -375,7 +411,7 @@ func startHiddenTasks() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil {
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil { //nolint:staticcheck,nolintlint // DoUpgradeAtStartup may always return non-nil on Windows
|
||||
slog.Info("unable to perform upgrade at startup", "error", err)
|
||||
// Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization
|
||||
UpdateAvailable("")
|
||||
@@ -432,7 +468,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
openUI("/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,17 +527,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
|
||||
openUI("/")
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isConnect {
|
||||
connect()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Security/Security.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
@@ -16,8 +18,12 @@ enum AppMove
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void killOtherInstances();
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
typedef struct {
|
||||
int pid;
|
||||
int64_t started_at;
|
||||
} AppProcessIdentity;
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
@@ -25,6 +31,7 @@ extern void Restart();
|
||||
// extern void Quit();
|
||||
void StartUI(const char *path);
|
||||
void ShowUI();
|
||||
bool IsOnboardingActive(void);
|
||||
void StopUI();
|
||||
void StartUpdate();
|
||||
void darwinStartHiddenTasks();
|
||||
@@ -38,6 +45,30 @@ 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);
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestShouldShowOnboarding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh install",
|
||||
settings: store.Settings{OnboardingVersion: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed onboarding",
|
||||
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "settings failure",
|
||||
err: errors.New("settings unavailable"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchURLSchemeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
wantConnect bool
|
||||
wantOpen bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
|
||||
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
|
||||
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected := false
|
||||
opened := false
|
||||
err := dispatchURLSchemeRequest(
|
||||
tt.request,
|
||||
func() { connected = true },
|
||||
func() { opened = true },
|
||||
)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if connected != tt.wantConnect {
|
||||
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
|
||||
}
|
||||
if opened != tt.wantOpen {
|
||||
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
urlCalls := 0
|
||||
onboardingCalls := 0
|
||||
openCalls := 0
|
||||
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
true,
|
||||
"ollama://",
|
||||
func() { hiddenCalls++ },
|
||||
func(request string) {
|
||||
urlCalls++
|
||||
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
|
||||
}
|
||||
},
|
||||
func(path string) {
|
||||
onboardingCalls++
|
||||
},
|
||||
)
|
||||
|
||||
if urlCalls != 1 {
|
||||
t.Fatalf("URL handled %d times, want 1", urlCalls)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Errorf("app opened %d times, want 1", openCalls)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
|
||||
}
|
||||
if onboardingCalls != 0 {
|
||||
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIRoutesInteractiveLaunch(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
showOnboarding bool
|
||||
wantPath string
|
||||
}{
|
||||
{name: "fresh install preserves onboarding", showOnboarding: true, wantPath: "/"},
|
||||
{name: "returning launch opens apps", wantPath: "/connect"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
tt.showOnboarding,
|
||||
"",
|
||||
func() { t.Fatal("unexpected hidden startup") },
|
||||
func(string) { t.Fatal("unexpected URL handling") },
|
||||
func(path string) { gotPath = path },
|
||||
)
|
||||
if gotPath != tt.wantPath {
|
||||
t.Fatalf("initial UI path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
func handleExistingInstance(startHidden bool) bool {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func installSymlink() {}
|
||||
@@ -95,11 +96,15 @@ func (ac *appCallbacks) UIRun(path string) {
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
wv.Run("/")
|
||||
return
|
||||
}
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
@@ -110,6 +115,10 @@ func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIOnboarding() bool {
|
||||
return wv.OnboardingActive()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
@@ -126,7 +135,7 @@ func (app *appCallbacks) DoUpdate() {
|
||||
|
||||
app.shutdown()
|
||||
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
if err := updater.DoUpgrade(true); err != nil { //nolint:staticcheck,nolintlint // DoUpgrade may always return non-nil on Windows
|
||||
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
|
||||
}
|
||||
}
|
||||
@@ -138,19 +147,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
handleURLSchemeInCurrentInstance(urlScheme)
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
@@ -161,7 +158,7 @@ func UpdateAvailable(ver string) error {
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
@@ -205,10 +202,8 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
ptr := wv.Run("/")
|
||||
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func(path string) {
|
||||
ptr := wv.Run(path)
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
@@ -225,7 +220,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
})
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
@@ -408,6 +403,8 @@ func hideWindow(ptr unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -432,17 +429,13 @@ func drag(ptr unsafe.Pointer) {}
|
||||
func doubleClick(ptr unsafe.Pointer) {}
|
||||
|
||||
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) {
|
||||
if urlSchemeRequest == "" {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Try to send URL to existing instance using wintray messaging
|
||||
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// No existing instance, we'll handle it ourselves
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
func bindClaudeDesktop(wv webview.WebView) {
|
||||
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionStatus()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionSummary()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopRequestCount", func() uint64 {
|
||||
return claudeDesktopRequestCount()
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopConnected", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopConnection(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult {
|
||||
err := prepareClaudeDesktopConnection()
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("openClaudeDesktop", func() string {
|
||||
if err := openClaudeDesktopApplication(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
wv.Bind("installClaudeDesktop", func() claudeDesktopInstallResult {
|
||||
return requestClaudeDesktopInstall()
|
||||
})
|
||||
|
||||
wv.Bind("applyClaudeDesktopMappings", func(mappings map[string]string, restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := applyClaudeDesktopMappings(mappings, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("resetClaudeDesktopMappings", func(restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := resetClaudeDesktopMappings(restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopAutoMode", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopAutoMode(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("getShowAppsInMenu", func() bool {
|
||||
return getShowAppsInMenu()
|
||||
})
|
||||
|
||||
wv.Bind("setShowAppsInMenu", func(visible bool) {
|
||||
setShowAppsInMenu(visible)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindClaudeDesktop(_ webview.WebView) {}
|
||||
@@ -0,0 +1,252 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaudeDesktopArchiveBytes = 1 << 30
|
||||
maxClaudeDesktopExtractBytes = 2 << 30
|
||||
maxClaudeDesktopArchiveFiles = 100_000
|
||||
claudeDesktopBundleID = "com.anthropic.claudefordesktop"
|
||||
claudeDesktopTeamID = "Q6L2SF6YDW"
|
||||
)
|
||||
|
||||
var errClaudeDesktopDestinationExists = errors.New("Claude Desktop installation destination already exists")
|
||||
|
||||
func claudeDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/Claude.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "Claude.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installClaudeDesktopZip(archivePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("Claude Desktop bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat Claude Desktop archive: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("Claude Desktop archive is not a regular file")
|
||||
}
|
||||
if info.Size() > maxClaudeDesktopArchiveBytes {
|
||||
return "", fmt.Errorf("Claude Desktop archive exceeds %d bytes", maxClaudeDesktopArchiveBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-claude-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create Claude Desktop installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
if err := extractClaudeDesktopZip(archivePath, workDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bundlePath := filepath.Join(workDir, "Claude.app")
|
||||
if err := validateClaudeDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify Claude Desktop signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errClaudeDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check Claude Desktop destination %s: %w", destination, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create Claude Desktop destination: %w", err)
|
||||
}
|
||||
if err := os.Rename(bundlePath, destination); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("move Claude Desktop to %s: %w", destination, err)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install Claude Desktop in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZip(archivePath, destination string) error {
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return errors.New("Claude Desktop archive is empty")
|
||||
}
|
||||
if len(reader.File) > maxClaudeDesktopArchiveFiles {
|
||||
return fmt.Errorf("Claude Desktop archive contains more than %d files", maxClaudeDesktopArchiveFiles)
|
||||
}
|
||||
|
||||
var expanded uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := safeClaudeDesktopArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expanded += file.UncompressedSize64
|
||||
if expanded > maxClaudeDesktopExtractBytes {
|
||||
return fmt.Errorf("Claude Desktop archive expands beyond %d bytes", maxClaudeDesktopExtractBytes)
|
||||
}
|
||||
path := filepath.Join(destination, filepath.FromSlash(clean))
|
||||
switch {
|
||||
case file.FileInfo().IsDir():
|
||||
if err := os.MkdirAll(path, file.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
case file.Mode()&os.ModeSymlink != 0:
|
||||
target, err := readClaudeDesktopZipFile(file, 16<<10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
if err := validateClaudeDesktopSymlink(clean, string(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
if err := os.Symlink(string(target), path); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
case file.Mode().IsRegular():
|
||||
if err := extractClaudeDesktopZipFile(file, path); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Claude Desktop archive contains unsupported file %q", file.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeClaudeDesktopArchivePath(name string) (string, error) {
|
||||
if strings.ContainsRune(name, '\x00') || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unsafe path %q", name)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(name))
|
||||
if clean != "Claude.app" && !strings.HasPrefix(clean, "Claude.app/") {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unexpected path %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopSymlink(name, target string) error {
|
||||
if target == "" || filepath.IsAbs(target) {
|
||||
return fmt.Errorf("Claude Desktop archive contains unsafe symlink %q", name)
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(name), target))
|
||||
resolved = filepath.ToSlash(resolved)
|
||||
if resolved != "Claude.app" && !strings.HasPrefix(resolved, "Claude.app/") {
|
||||
return fmt.Errorf("Claude Desktop archive symlink %q escapes Claude.app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZipFile(file *zip.File, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive file: %w", err)
|
||||
}
|
||||
output, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode().Perm())
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return fmt.Errorf("create Claude Desktop archive file: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
inputErr := input.Close()
|
||||
outputErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("extract Claude Desktop archive file: %w", copyErr)
|
||||
}
|
||||
if inputErr != nil {
|
||||
return fmt.Errorf("close Claude Desktop archive file: %w", inputErr)
|
||||
}
|
||||
if outputErr != nil {
|
||||
return fmt.Errorf("close extracted Claude Desktop file: %w", outputErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readClaudeDesktopZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("archive entry exceeds %d bytes", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("Claude Desktop archive does not contain Claude.app")
|
||||
}
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", "Claude")
|
||||
info, err = os.Stat(executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Claude Desktop executable is missing: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
|
||||
return errors.New("Claude Desktop executable is not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyClaudeDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+claudeDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+claudeDesktopTeamID) {
|
||||
return fmt.Errorf("unexpected Claude Desktop signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallClaudeDesktopZip(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/": {directory: true},
|
||||
"Claude.app/Contents/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/Contents/Resources/": {directory: true},
|
||||
"Claude.app/Contents/Resources/link": {body: "../MacOS/Claude", mode: os.ModeSymlink | 0o777},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
var verified string
|
||||
installed, err := installClaudeDesktopZip(archive, []string{destination}, func(bundle string) error {
|
||||
verified = bundle
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination || verified == "" {
|
||||
t.Fatalf("installed = %q, verified = %q", installed, verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "Claude"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed Claude executable is not executable")
|
||||
}
|
||||
if target, err := os.Readlink(filepath.Join(installed, "Contents", "Resources", "link")); err != nil || target != "../MacOS/Claude" {
|
||||
t.Fatalf("symlink target = %q, err = %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRejectsUnsafeArchives(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
entries map[string]claudeDesktopTestZipEntry
|
||||
}{
|
||||
{name: "path traversal", entries: map[string]claudeDesktopTestZipEntry{"../Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755}}},
|
||||
{name: "unexpected root", entries: map[string]claudeDesktopTestZipEntry{"README": {body: "nope", mode: 0o644}}},
|
||||
{name: "escaping symlink", entries: map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/escape": {body: "../../outside", mode: os.ModeSymlink | 0o777},
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, test.entries)
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("installClaudeDesktopZip succeeded")
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unsafe archive created destination: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipVerifiesBeforeMove(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipDoesNotOverwrite(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); !errors.Is(err, errClaudeDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRealArchive(t *testing.T) {
|
||||
archive := os.Getenv("OLLAMA_TEST_CLAUDE_DESKTOP_ZIP")
|
||||
if archive == "" {
|
||||
t.Skip("set OLLAMA_TEST_CLAUDE_DESKTOP_ZIP to a downloaded Claude Desktop ZIP")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
installed, err := installClaudeDesktopZip(
|
||||
archive,
|
||||
[]string{destination},
|
||||
verifyClaudeDesktopBundle,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
type claudeDesktopTestZipEntry struct {
|
||||
body string
|
||||
mode os.FileMode
|
||||
directory bool
|
||||
}
|
||||
|
||||
func writeClaudeDesktopTestZip(t *testing.T, entries map[string]claudeDesktopTestZipEntry) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "Claude.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, entry := range entries {
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
if entry.directory {
|
||||
header.SetMode(os.ModeDir | 0o755)
|
||||
} else {
|
||||
header.SetMode(entry.mode)
|
||||
}
|
||||
item, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := item.Write([]byte(entry.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSafeClaudeDesktopArchivePath(t *testing.T) {
|
||||
for _, name := range []string{"Claude.app", "Claude.app/Contents/MacOS/Claude"} {
|
||||
if got, err := safeClaudeDesktopArchivePath(name); err != nil || got != strings.TrimSuffix(name, "/") {
|
||||
t.Fatalf("safeClaudeDesktopArchivePath(%q) = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/internal/proxy"
|
||||
|
||||
type claudeDesktopInstallResult string
|
||||
|
||||
const (
|
||||
claudeDesktopInstallCancelled claudeDesktopInstallResult = "cancelled"
|
||||
claudeDesktopInstallerOpened claudeDesktopInstallResult = "opened"
|
||||
claudeDesktopInstallFailed claudeDesktopInstallResult = "failed"
|
||||
)
|
||||
|
||||
type claudeDesktopStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Used bool `json:"used"`
|
||||
Installed bool `json:"installed"`
|
||||
Configured bool `json:"configured"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
StartFailed bool `json:"startFailed"`
|
||||
PortConflict bool `json:"portConflict"`
|
||||
GatewayPort int `json:"gatewayPort,omitempty"`
|
||||
RoutedRequests uint64 `json:"routedRequests"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
ModelSource string `json:"modelSource,omitempty"`
|
||||
Models []claudeDesktopModelStatus `json:"models,omitempty"`
|
||||
Mappings []claudeDesktopMappingStatus `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopMappingStatus struct {
|
||||
RouteID string `json:"routeId"`
|
||||
RouteName string `json:"routeName"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopModelStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cloud bool `json:"cloud"`
|
||||
Selected bool `json:"selected"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
Availability proxy.ClaudeDesktopAvailability `json:"availability"`
|
||||
Reason proxy.ClaudeDesktopAccessReason `json:"reason,omitempty"`
|
||||
RequiredPlan string `json:"requiredPlan,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopActionResult struct {
|
||||
Status claudeDesktopStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
MappingsApplied bool `json:"mappingsApplied,omitempty"`
|
||||
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -24,11 +25,21 @@ import (
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1360
|
||||
defaultWindowHeight = 960
|
||||
onboardingWindowWidth = 900
|
||||
onboardingWindowHeight = 660
|
||||
minimumWindowWidth = onboardingWindowWidth
|
||||
minimumWindowHeight = onboardingWindowHeight
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
onboarding atomic.Bool
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
@@ -88,85 +99,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
// Keep Edge WebView2 scrollbars aligned with the system theme.
|
||||
function updateScrollbarStyles() {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'scrollbar-style';
|
||||
|
||||
if (isDark) {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
} else {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('load', updateScrollbarStyles);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
||||
`
|
||||
}
|
||||
// on windows make ctrl+n open new chat
|
||||
@@ -187,15 +151,32 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
`
|
||||
}
|
||||
|
||||
init += `
|
||||
init += fmt.Sprintf(`
|
||||
window.OLLAMA_PLATFORM = %q;
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`
|
||||
`, runtime.GOOS)
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (
|
||||
e.key === '+' || e.key === '=' || e.key === '-' ||
|
||||
e.key === '_' || e.key === '0' ||
|
||||
e.code === 'NumpadAdd' || e.code === 'NumpadSubtract'
|
||||
);
|
||||
|
||||
// Keep fixed-scale onboarding and apps pages at their intended size.
|
||||
const isFixedScalePage =
|
||||
window.location.pathname === '/onboarding' ||
|
||||
window.location.pathname === '/connect';
|
||||
if (isFixedScalePage && isZoomShortcut) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
@@ -237,10 +218,41 @@ 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() {
|
||||
@@ -450,18 +462,18 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to get window size", "error", err)
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
} else {
|
||||
wv.SetSize(800, 600, webview.HintNone)
|
||||
if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
@@ -476,6 +488,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.onboarding.Store(false)
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
@@ -489,6 +502,10 @@ func (w *Webview) Terminate() {
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) OnboardingActive() bool {
|
||||
return w.onboarding.Load()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -143,13 +143,13 @@ func utf16ptr(utf16 []uint16) *uint16 {
|
||||
|
||||
func utf16slice(ptr *uint16) []uint16 { //nolint:unused
|
||||
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
|
||||
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice := *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
i := 0
|
||||
for slice[len(slice)-1] != 0 {
|
||||
i++
|
||||
}
|
||||
hdr.Len = i
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice = *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
return slice
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 16
|
||||
const currentSchemaVersion = 18
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,12 +82,14 @@ 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 'launch',
|
||||
last_home_view TEXT NOT NULL DEFAULT 'chat',
|
||||
onboarding_version INTEGER NOT NULL DEFAULT 0,
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
remote TEXT NOT NULL DEFAULT '', -- deprecated
|
||||
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -271,6 +273,18 @@ 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
|
||||
@@ -527,7 +541,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 'launch'`)
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add last_home_view column: %w", err)
|
||||
}
|
||||
@@ -540,6 +554,38 @@ 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(`
|
||||
@@ -1188,9 +1234,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, think_enabled, think_level, auto_update_enabled
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
|
||||
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.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1200,28 +1246,14 @@ 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": {},
|
||||
"codex-app": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
lastHomeView = "launch"
|
||||
}
|
||||
lastHomeView = "chat"
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, 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)
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "launch" {
|
||||
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||
if lastHomeView != "chat" {
|
||||
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
@@ -174,6 +174,88 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(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,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
|
||||
t.Error("expected has completed first run to be true after migration")
|
||||
}
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != CurrentOnboardingVersion {
|
||||
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
// Verify migration is marked as complete
|
||||
migrated, err := s.db.isConfigMigrated()
|
||||
if err != nil {
|
||||
|
||||
@@ -167,13 +167,22 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||
// LastHomeView is retained for settings compatibility and resolves to chat.
|
||||
LastHomeView string
|
||||
|
||||
// OnboardingVersion stores the latest onboarding flow the user has completed.
|
||||
OnboardingVersion int
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
|
||||
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
|
||||
ClaudeDesktopUsed bool
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -334,6 +343,16 @@ func (s *Store) migrateFromConfig(database *database) error {
|
||||
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
|
||||
return fmt.Errorf("migrate first time run: %w", err)
|
||||
}
|
||||
if hasCompleted {
|
||||
settings, err := database.getSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read settings for onboarding migration: %w", err)
|
||||
}
|
||||
settings.OnboardingVersion = CurrentOnboardingVersion
|
||||
if err := database.setSettings(settings); err != nil {
|
||||
return fmt.Errorf("migrate onboarding completion: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
|
||||
|
||||
// Mark as migrated
|
||||
@@ -393,7 +412,7 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "launch"
|
||||
settings.LastHomeView = "chat"
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
|
||||
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||
t.Run("settings default home view is chat", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,12 +117,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings codex app home view is accepted", func(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -132,8 +132,8 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "codex-app" {
|
||||
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,6 +227,58 @@ 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()
|
||||
|
||||
@@ -415,7 +415,9 @@ 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);
|
||||
@@ -434,7 +436,9 @@ 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 {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<html lang="en" style="overflow: hidden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Ollama</title>
|
||||
</head>
|
||||
<body class="dark:bg-neutral-900 select-text">
|
||||
<body class="bg-white dark:bg-neutral-900 select-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -56,6 +57,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
@@ -4593,6 +4595,16 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@@ -11152,6 +11164,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^19.1.0",
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer/node_modules/react-is": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -65,6 +66,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="92" height="96" viewBox="0 0 92 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#24292F">
|
||||
<path fill-rule="evenodd" d="M65.45 16.8c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4 4 0 0 1-.01 3.6l-5.73 11.34v6.6c0 10.94-8.82 19.8-19.71 19.8H26.02C15.13 96 6.31 87.14 6.31 76.2v-6.6L.45 58.3a4 4 0 0 1-.01-3.67l5.87-11.43v-6.6c0-10.94 8.82-19.8 19.71-19.8h39.43Zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88V45L7.44 54.46a4 4 0 0 0 .01 3.68L12.32 67.5v5.63c0 9.32 7.55 16.87 16.87 16.87h33.74c9.32 0 16.87-7.55 16.87-16.87V67.5l4.77-9.39a4 4 0 0 0 .01-3.61L79.8 45v-5.62c0-9.32-7.55-16.88-16.87-16.88Z"/>
|
||||
<circle cx="45.73" cy="11.5" r="11"/>
|
||||
<rect x="27" y="41" width="13" height="30" rx="6.5"/>
|
||||
<rect x="51" y="41" width="13" height="30" rx="6.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#4D6BFE" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="omp-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#ed4abf"/>
|
||||
<stop offset=".5" stop-color="#9b4dff"/>
|
||||
<stop offset="1" stop-color="#5ad8e6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0f0a14"/>
|
||||
<path fill="url(#omp-gradient)" d="M14 16h36v8H40v32h-8V24h-6v22h-8V24h-4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="poolside-gradient" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6c5cff"/>
|
||||
<stop offset="1" stop-color="#3c2cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="13" fill="url(#poolside-gradient)"/>
|
||||
<path d="M13 32c0-10.5 8.5-19 19-19 10.49 0 19 8.5 19 19s-8.51 19-19 19c-10.5 0-19-8.5-19-19Z" fill="none" stroke="#fff" stroke-width="4"/>
|
||||
<path d="M16 24c8-4.1 17.1-.9 22.6 7.1 4.3-1.2 8.6.5 11 4.1M23.5 47.5 38 17.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 141.38 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.32 3.32 0 0 0 0-3.34l-9.62-16.67a3.34 3.34 0 0 0-2.89-1.67H82.23l-8.66-15A3.33 3.33 0 0 0 70.68-.02H51.43a3.33 3.33 0 0 0-2.88 1.67L32.19 29.98l-1.92 3.33H12.96a3.34 3.34 0 0 0-2.88 1.67L.45 51.66a3.32 3.32 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67a3.34 3.34 0 0 0 2.89 1.67h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32a3.34 3.34 0 0 0 2.89-1.67l9.62-16.67a3.32 3.32 0 0 0-.01-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { listModels } = vi.hoisted(() => ({ listModels: vi.fn() }));
|
||||
vi.mock("./lib/ollama-client", () => ({
|
||||
ollamaClient: { list: listModels },
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchConnectUrl,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("requests a desktop handoff after account creation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
signin_url:
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchConnectUrl()).resolves.toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationStatuses", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns desktop and launcher integration metadata", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getIntegrationStatuses()).resolves.toEqual([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClaudeDesktopAvailableModels", () => {
|
||||
afterEach(() => {
|
||||
listModels.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns installed local models while pruning remote entries", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "llama3.2:latest", digest: "local" },
|
||||
{
|
||||
name: "remote-placeholder",
|
||||
digest: "remote",
|
||||
remote_host: "https://ollama.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["llama3.2"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not request cloud models when they are unavailable to the user", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "qwen3:8b", digest: "local" },
|
||||
{ name: "deepseek-v4-flash:cloud", digest: "cached-cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cached-cloud" },
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the account cloud list in parallel when Cloud is available", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{ name: "glm-5.2", digest: "cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cloud" },
|
||||
{ name: "qwen3:8b", digest: "cloud-duplicate" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual([
|
||||
"qwen3:8b",
|
||||
"glm-5.2:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/models/cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps local models when the account cloud list fails", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,24 @@ export interface CloudStatusResponse {
|
||||
disabled: boolean;
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed?: boolean;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export type IntegrationStatuses = IntegrationStatus[];
|
||||
|
||||
export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/integrations`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch integration statuses: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
@@ -81,7 +99,9 @@ export async function fetchConnectUrl(): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
return data.signin_url;
|
||||
const connectUrl = new URL(data.signin_url);
|
||||
connectUrl.searchParams.set("launch", "true");
|
||||
return connectUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +196,84 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaudeDesktopAvailableModels(
|
||||
includeCloudModels = false,
|
||||
): Promise<Model[]> {
|
||||
try {
|
||||
const [localResult, cloudResult] = await Promise.all([
|
||||
ollama.list(),
|
||||
includeCloudModels
|
||||
? fetch(`${API_BASE}/api/v1/models/cloud`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`cloud model list returned ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { models?: ModelResponse[] };
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to fetch cloud models:", error);
|
||||
return { models: [] };
|
||||
})
|
||||
: Promise.resolve({ models: [] as ModelResponse[] }),
|
||||
]);
|
||||
|
||||
const localModels = localResult.models.filter((model: ModelResponse) => {
|
||||
const response = model as ModelResponse & {
|
||||
remote_model?: string;
|
||||
remote_host?: string;
|
||||
};
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
return (
|
||||
!response.remote_model &&
|
||||
!response.remote_host &&
|
||||
!name.endsWith("cloud")
|
||||
);
|
||||
});
|
||||
const cloudModels = (cloudResult.models ?? []).map((model) => {
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
const tag = name.slice(name.lastIndexOf(":") + 1).toLowerCase();
|
||||
const explicitCloud =
|
||||
name.endsWith(":cloud") ||
|
||||
(name.includes(":") && tag.endsWith("-cloud"));
|
||||
return {
|
||||
...model,
|
||||
name: explicitCloud ? name : `${name}:cloud`,
|
||||
};
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...localModels, ...cloudModels]
|
||||
.filter((model: ModelResponse) => {
|
||||
const base = model.name
|
||||
.replace(/:latest$/, "")
|
||||
.replace(/:cloud$/, "");
|
||||
if (!base || seen.has(base)) return false;
|
||||
|
||||
const families = model.details?.families;
|
||||
const supported =
|
||||
!families ||
|
||||
families.length === 0 ||
|
||||
!families.every((family: string) =>
|
||||
family.toLowerCase().includes("bert"),
|
||||
);
|
||||
if (supported) seen.add(base);
|
||||
return supported;
|
||||
})
|
||||
.map(
|
||||
(model: ModelResponse) =>
|
||||
new Model({
|
||||
model: model.name.replace(/:latest$/, ""),
|
||||
digest: model.digest,
|
||||
modified_at: model.modified_at
|
||||
? new Date(model.modified_at)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(
|
||||
modelName: string,
|
||||
): Promise<ModelCapabilitiesResponse> {
|
||||
@@ -418,7 +516,9 @@ 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`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { Cog6ToothIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type AppSection = "apps" | "chat" | "settings";
|
||||
|
||||
export function AppNavigation({ current }: { current: AppSection }) {
|
||||
const itemClass = (section: AppSection) =>
|
||||
`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
current === section ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link to="/connect" className={itemClass("apps")} draggable={false}>
|
||||
<RectangleGroupIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Apps</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
mask={{ to: "/" }}
|
||||
className={itemClass("chat")}
|
||||
draggable={false}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span className="truncate">Chat</span>
|
||||
</Link>
|
||||
<Link to="/settings" className={itemClass("settings")} draggable={false}>
|
||||
<Cog6ToothIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar({ current }: { current: AppSection }) {
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col px-4 pb-4 select-none">
|
||||
<AppNavigation current={current} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ChatIcon({ className = "h-5 w-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${className} fill-current`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,12 @@ import { getChat } from "@/api";
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { ChatsResponse } from "@/gotypes";
|
||||
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||
import { AppNavigation } from "@/components/AppSidebar";
|
||||
|
||||
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
}
|
||||
@@ -240,156 +238,100 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
[startEditing, handleDeleteChat],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4">Loading...</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4 text-red-500">Error loading chats</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col min-h-0 select-none">
|
||||
<nav
|
||||
aria-busy={isLoading || undefined}
|
||||
className="flex flex-1 flex-col min-h-0 select-none"
|
||||
>
|
||||
<header className="flex flex-col gap-0.5 px-4 pb-2">
|
||||
<Link
|
||||
href="/c/new"
|
||||
mask={{ to: "/" }}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
<span className="truncate">New Chat</span>
|
||||
</Link>
|
||||
<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>
|
||||
)}
|
||||
<AppNavigation current="chat" />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
{error ? (
|
||||
<div className="px-2 pt-4 text-sm text-red-500">
|
||||
Error loading chats
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
import {
|
||||
act,
|
||||
create,
|
||||
type ReactTestInstance,
|
||||
type ReactTestRenderer,
|
||||
} from "react-test-renderer";
|
||||
import {
|
||||
createRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
ClaudeDesktopModelsSettings,
|
||||
type ClaudeDesktopModelsSettingsHandle,
|
||||
} from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
vi.mock("@headlessui/react", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const original = await importOriginal<typeof import("@headlessui/react")>();
|
||||
type PopoverContextValue = {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
const PopoverContext = React.createContext<PopoverContextValue | null>(null);
|
||||
const usePopover = () => {
|
||||
const context = React.useContext(PopoverContext);
|
||||
if (!context) throw new Error("Popover components must be nested");
|
||||
return context;
|
||||
};
|
||||
|
||||
function TestPopover({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const context = {
|
||||
open,
|
||||
close: () => setOpen(false),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
};
|
||||
return (
|
||||
<PopoverContext.Provider value={context}>
|
||||
<div className={className}>{children}</div>
|
||||
</PopoverContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPopoverButton({
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { open, toggle } = usePopover();
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-expanded={open}
|
||||
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
toggle();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPopoverPanel({
|
||||
anchor,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
anchor?: unknown;
|
||||
children: ReactNode | ((props: { close: () => void }) => ReactNode);
|
||||
}) {
|
||||
const { open, close } = usePopover();
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div {...props} data-anchor={JSON.stringify(anchor)}>
|
||||
{typeof children === "function" ? children({ close }) : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return Object.assign({}, original, {
|
||||
Popover: TestPopover,
|
||||
PopoverButton: TestPopoverButton,
|
||||
PopoverPanel: TestPopoverPanel,
|
||||
});
|
||||
});
|
||||
|
||||
const fableRoute = {
|
||||
routeId: "claude-fable-5",
|
||||
routeName: "Fable 5",
|
||||
};
|
||||
|
||||
function testStatus(model = "glm-5.2:cloud", running = false) {
|
||||
return {
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
autoMode: false,
|
||||
modelSource: "user" as const,
|
||||
mappings: [{ ...fableRoute, model }],
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: model === "glm-5.2:cloud",
|
||||
availability: "available" as const,
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: model === "kimi-k3:cloud",
|
||||
availability: "available" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function selectKimi(renderer: ReactTestRenderer) {
|
||||
await act(async () => {
|
||||
pickerButton(renderer).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findAllByProps({ role: "option" })[1].props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function pickerButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.props["aria-label"] === "Ollama model for Fable 5",
|
||||
);
|
||||
if (!button) throw new Error("Claude model picker button not found");
|
||||
return button;
|
||||
}
|
||||
|
||||
function actionButton(renderer: ReactTestRenderer) {
|
||||
const button = renderer.root
|
||||
.findAllByType("button")
|
||||
.find(
|
||||
(candidate) =>
|
||||
!candidate.props["aria-label"] &&
|
||||
candidate.props.className?.includes("flex-shrink-0"),
|
||||
);
|
||||
if (!button) throw new Error("Claude action button not found");
|
||||
return button;
|
||||
}
|
||||
|
||||
function textContent(node: ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === "string" ? child : textContent(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopModelsSettings interactions", () => {
|
||||
it("opens below without scrolling and disables auto mode for draft changes", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const focus = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={{
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
autoMode: true,
|
||||
modelSource: "user",
|
||||
mappings: [
|
||||
{
|
||||
routeId: "claude-fable-5",
|
||||
routeName: "Fable 5",
|
||||
model: "glm-5.2:cloud",
|
||||
},
|
||||
],
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
autoMode: true,
|
||||
},
|
||||
{
|
||||
name: "kimi-k3:cloud",
|
||||
displayName: "kimi-k3:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
autoMode: true,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
createNodeMock: (element) =>
|
||||
element.type === "input" ? { focus } : null,
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const autoModeSwitch = () =>
|
||||
renderer!.root.findByProps({ role: "switch" });
|
||||
expect(autoModeSwitch().props.disabled).not.toBe(true);
|
||||
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
pickerButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
renderer!.root.findByProps({
|
||||
"data-anchor": JSON.stringify({
|
||||
to: "bottom end",
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
}),
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
await act(async () => {
|
||||
const options = renderer!.root.findAllByProps({ role: "option" });
|
||||
options[1].props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(autoModeSwitch().props.disabled).toBe(true);
|
||||
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(
|
||||
renderer!.root
|
||||
.findAllByType("p")
|
||||
.some((node) =>
|
||||
node.children
|
||||
.join("")
|
||||
.includes(
|
||||
"Start or restart Claude to apply model changes before changing auto mode.",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for confirmation from live native state before restarting", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const apply = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: testStatus("glm-5.2:cloud", true),
|
||||
error:
|
||||
"Claude Desktop restart confirmation is required before changing its profile",
|
||||
restartConfirmationRequired: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: testStatus("kimi-k3:cloud", true),
|
||||
mappingsApplied: true,
|
||||
});
|
||||
const confirm = vi.fn(() => true);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
applyClaudeDesktopMappings: apply,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Restart Claude Desktop? Any running task will stop.",
|
||||
);
|
||||
expect(apply).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ "claude-fable-5": "kimi-k3:cloud" },
|
||||
false,
|
||||
);
|
||||
expect(apply).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ "claude-fable-5": "kimi-k3:cloud" },
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("restores Auto mode when restart confirmation is canceled", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const runningStatus = {
|
||||
...testStatus("glm-5.2:cloud", true),
|
||||
autoMode: true,
|
||||
models: testStatus().models.map((model) => ({
|
||||
...model,
|
||||
autoMode: true,
|
||||
})),
|
||||
};
|
||||
const setAutoMode = vi.fn().mockResolvedValue({
|
||||
status: runningStatus,
|
||||
error:
|
||||
"Claude Desktop restart confirmation is required before changing its profile",
|
||||
restartConfirmationRequired: true,
|
||||
});
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
setClaudeDesktopAutoMode: setAutoMode,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={runningStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.root.findByType(Switch).props.onChange(false);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(setAutoMode).toHaveBeenCalledTimes(1);
|
||||
expect(setAutoMode).toHaveBeenCalledWith(false, false);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
"Restart Claude to change auto mode? Any running task will stop.",
|
||||
);
|
||||
expect(
|
||||
renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a stale focus refresh that finishes after apply", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
let focusHandler: (() => void) | undefined;
|
||||
let resolveRefresh:
|
||||
| ((status: ReturnType<typeof testStatus>) => void)
|
||||
| undefined;
|
||||
const staleRefresh = new Promise<ReturnType<typeof testStatus>>(
|
||||
(resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === "focus") focusHandler = handler;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
getClaudeDesktopStatus: vi.fn(() => staleRefresh),
|
||||
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
|
||||
status: testStatus("kimi-k3:cloud"),
|
||||
mappingsApplied: true,
|
||||
}),
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
focusHandler?.();
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
resolveRefresh?.(testStatus("glm-5.2:cloud"));
|
||||
await staleRefresh;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts committed mappings when launching Claude fails", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const onDraftChange = vi.fn();
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
|
||||
status: testStatus("kimi-k3:cloud"),
|
||||
error:
|
||||
"Claude model mappings were saved, but Claude Desktop could not open",
|
||||
mappingsApplied: true,
|
||||
}),
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialLocalModels={[]}
|
||||
initialStatus={testStatus()}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await selectKimi(renderer!);
|
||||
await act(async () => {
|
||||
actionButton(renderer!).props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onDraftChange).toHaveBeenLastCalledWith(false);
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the previous mappings when reset restart is canceled", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const currentStatus = testStatus("kimi-k3:cloud", true);
|
||||
const resetMappings = vi.fn().mockResolvedValue({
|
||||
status: currentStatus,
|
||||
restartConfirmationRequired: true,
|
||||
});
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
resetClaudeDesktopMappings: resetMappings,
|
||||
confirm,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={settingsRef}
|
||||
initialLocalModels={[]}
|
||||
initialStatus={currentStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let resetSucceeded = true;
|
||||
await act(async () => {
|
||||
resetSucceeded =
|
||||
(await settingsRef.current?.resetToDefaults()) ?? false;
|
||||
});
|
||||
|
||||
expect(resetSucceeded).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledOnce();
|
||||
expect(resetMappings).toHaveBeenCalledWith(false);
|
||||
const picker = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
expect(picker.findAllByType("span")[0].children.join("")).toBe(
|
||||
"kimi-k3:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the native reset result and shows progress", async () => {
|
||||
class TestHTMLElement {
|
||||
focus() {}
|
||||
}
|
||||
const initialStatus = {
|
||||
...testStatus("glm-5.2:cloud"),
|
||||
mappings: [
|
||||
{ ...fableRoute, model: "glm-5.2:cloud" },
|
||||
{
|
||||
routeId: "claude-sonnet-5",
|
||||
routeName: "Sonnet 5",
|
||||
model: "kimi-k3:cloud",
|
||||
},
|
||||
],
|
||||
};
|
||||
const resetStatus = {
|
||||
...initialStatus,
|
||||
mappings: [
|
||||
{ ...fableRoute },
|
||||
{
|
||||
routeId: "claude-sonnet-5",
|
||||
routeName: "Sonnet 5",
|
||||
model: "glm-5.2:cloud",
|
||||
},
|
||||
],
|
||||
};
|
||||
const resetResult = {
|
||||
status: resetStatus,
|
||||
mappingsApplied: true,
|
||||
};
|
||||
let resolveReset!: (result: typeof resetResult) => void;
|
||||
const resetRequestResult = new Promise<typeof resetResult>((resolve) => {
|
||||
resolveReset = resolve;
|
||||
});
|
||||
const resetMappings = vi.fn(() => resetRequestResult);
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLElement: TestHTMLElement,
|
||||
resetClaudeDesktopMappings: resetMappings,
|
||||
});
|
||||
vi.stubGlobal("document", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={settingsRef}
|
||||
initialLocalModels={[]}
|
||||
initialStatus={initialStatus}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let resetRequest: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
resetRequest = settingsRef.current?.resetToDefaults();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(actionButton(renderer!).props.disabled).toBe(true);
|
||||
expect(textContent(actionButton(renderer!))).toContain("Resetting…");
|
||||
|
||||
resolveReset(resetResult);
|
||||
let resetSucceeded = false;
|
||||
await act(async () => {
|
||||
resetSucceeded = (await resetRequest) ?? false;
|
||||
});
|
||||
|
||||
expect(resetSucceeded).toBe(true);
|
||||
expect(resetMappings).toHaveBeenCalledWith(false);
|
||||
|
||||
const fable = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Fable 5",
|
||||
});
|
||||
const sonnet = renderer!.root.findByProps({
|
||||
"aria-label": "Ollama model for Sonnet 5",
|
||||
});
|
||||
expect(fable.findAllByType("span")[0].children.join("")).toBe(
|
||||
"Select a model",
|
||||
);
|
||||
expect(sonnet.findAllByType("span")[0].children.join("")).toBe(
|
||||
"glm-5.2:cloud",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ClaudeDesktopStatus } from "@/types/webview";
|
||||
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
|
||||
|
||||
const routes = [
|
||||
{ routeId: "claude-fable-5", routeName: "Fable 5" },
|
||||
{ routeId: "claude-opus-5", routeName: "Opus 5" },
|
||||
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
|
||||
{
|
||||
routeId: "claude-haiku-4-5-20251001",
|
||||
routeName: "Haiku 4.5",
|
||||
},
|
||||
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
|
||||
];
|
||||
|
||||
function status(
|
||||
overrides: Partial<ClaudeDesktopStatus> = {},
|
||||
): ClaudeDesktopStatus {
|
||||
return {
|
||||
supported: true,
|
||||
used: true,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
modelSource: "endpoint",
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: true,
|
||||
availability: "available",
|
||||
},
|
||||
],
|
||||
mappings: routes.map((route, index) => ({
|
||||
...route,
|
||||
model: index === 0 ? "glm-5.2:cloud" : undefined,
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopModelsSettings", () => {
|
||||
it("labels model plan and account requirements in the picker", () => {
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
requiredPlan: "free",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
}),
|
||||
).toBe("Pro plan required");
|
||||
expect(
|
||||
claudeDesktopModelStatusLabel({
|
||||
name: "gemma4:31b-cloud",
|
||||
displayName: "gemma4:31b-cloud",
|
||||
cloud: true,
|
||||
selected: false,
|
||||
availability: "unavailable",
|
||||
reason: "sign_in_required",
|
||||
requiredPlan: "free",
|
||||
}),
|
||||
).toBe("Sign in required");
|
||||
});
|
||||
|
||||
it("renders the five explicit Claude routes and an Ollama model picker", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Claude</h2>");
|
||||
for (const route of routes) {
|
||||
expect(html).toContain(route.routeName);
|
||||
expect(html).not.toContain(`>${route.routeId}<`);
|
||||
}
|
||||
expect((html.match(/aria-haspopup="listbox"/g) ?? []).length).toBe(5);
|
||||
expect(html).not.toContain('for="claude-route-');
|
||||
expect(html).toContain(
|
||||
"Choose which Ollama model Claude uses for each model option.",
|
||||
);
|
||||
expect(html).not.toContain("routing");
|
||||
expect(html).not.toContain("Built-in defaults");
|
||||
expect(html).not.toContain("Unassigned");
|
||||
expect(html).toContain("Select a model");
|
||||
expect(html).toContain("Start Claude");
|
||||
});
|
||||
|
||||
it("allows the same Ollama model to be assigned to multiple routes", () => {
|
||||
const shared = routes.map((route) => ({
|
||||
...route,
|
||||
model: "qwen3:8b",
|
||||
}));
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({ mappings: shared })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((html.match(/>qwen3:8b<\/span>/g) ?? []).length).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps an unavailable default visible with its access status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({
|
||||
models: [
|
||||
{
|
||||
name: "glm-5.2:cloud",
|
||||
displayName: "glm-5.2:cloud",
|
||||
cloud: true,
|
||||
selected: true,
|
||||
availability: "unavailable",
|
||||
reason: "upgrade_required",
|
||||
requiredPlan: "pro",
|
||||
},
|
||||
{
|
||||
name: "qwen3:8b",
|
||||
displayName: "qwen3:8b",
|
||||
selected: false,
|
||||
availability: "available",
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain(">glm-5.2:cloud</span>");
|
||||
});
|
||||
|
||||
it("presents Start or Restart based on whether Claude is running", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings
|
||||
initialStatus={status({ configured: false, connected: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Start Claude");
|
||||
expect(html).not.toContain("Apply changes");
|
||||
|
||||
const runningHTML = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status({ running: true })} />,
|
||||
);
|
||||
expect(runningHTML).toContain("Restart Claude");
|
||||
expect(runningHTML).toContain("disabled");
|
||||
});
|
||||
|
||||
it("stays hidden until Claude has been enabled once", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeDesktopModelsSettings initialStatus={status({ used: false })} />,
|
||||
);
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { getClaudeDesktopAvailableModels } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Description, Field, Label } from "@/components/ui/fieldset";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { claudeDesktopRecoveryMessage } from "@/lib/claudeDesktop";
|
||||
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
|
||||
import type {
|
||||
ClaudeDesktopActionResult,
|
||||
ClaudeDesktopMappingStatus,
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ChevronUpDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface ClaudeDesktopModelsSettingsHandle {
|
||||
resetToDefaults: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopModelsSettingsProps {
|
||||
initialStatus?: ClaudeDesktopStatus;
|
||||
initialLocalModels?: string[];
|
||||
initialCloudModels?: string[];
|
||||
includeCloudModels?: boolean;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
}
|
||||
|
||||
const fallbackRoutes: ClaudeDesktopMappingStatus[] = [
|
||||
{ routeId: "claude-fable-5", routeName: "Fable 5" },
|
||||
{ routeId: "claude-opus-5", routeName: "Opus 5" },
|
||||
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
|
||||
{
|
||||
routeId: "claude-haiku-4-5-20251001",
|
||||
routeName: "Haiku 4.5",
|
||||
},
|
||||
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
|
||||
];
|
||||
|
||||
function isInvalidModelName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase().replace(/[-:]+/g, " ");
|
||||
return normalized === "ollama cloud";
|
||||
}
|
||||
|
||||
function visibleModels(
|
||||
status: ClaudeDesktopStatus,
|
||||
): ClaudeDesktopModelStatus[] {
|
||||
return (status.models ?? []).filter(
|
||||
(model) => !isInvalidModelName(model.name) && model.reason !== "cloud_off",
|
||||
);
|
||||
}
|
||||
|
||||
function modelIsAvailable(model: ClaudeDesktopModelStatus): boolean {
|
||||
return !model.availability || model.availability === "available";
|
||||
}
|
||||
|
||||
function initialMappings(
|
||||
status: ClaudeDesktopStatus,
|
||||
): ClaudeDesktopMappingStatus[] {
|
||||
const models = visibleModels(status);
|
||||
const known = new Set(models.map((model) => model.name));
|
||||
const available = new Set(
|
||||
models.filter(modelIsAvailable).map((model) => model.name),
|
||||
);
|
||||
const routes = (
|
||||
status.mappings?.length ? status.mappings : fallbackRoutes
|
||||
).map((route) => ({ ...route }));
|
||||
|
||||
if (!status.mappings?.length) {
|
||||
const selected = models.filter(
|
||||
(model) => model.selected && available.has(model.name),
|
||||
);
|
||||
selected.slice(0, routes.length).forEach((model, index) => {
|
||||
routes[index].model = model.name;
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.model && !known.has(route.model)) route.model = undefined;
|
||||
}
|
||||
if (!routes.some((route) => route.model)) {
|
||||
const first = models.find(modelIsAvailable);
|
||||
if (first && routes.length > 0) routes[0].model = first.name;
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function mappingsEqual(
|
||||
left: ClaudeDesktopMappingStatus[],
|
||||
right: ClaudeDesktopMappingStatus[],
|
||||
): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every(
|
||||
(route, index) =>
|
||||
route.routeId === right[index]?.routeId &&
|
||||
(route.model ?? "") === (right[index]?.model ?? ""),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function mappingRecord(
|
||||
mappings: ClaudeDesktopMappingStatus[],
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
mappings
|
||||
.filter((route) => route.model)
|
||||
.map((route) => [route.routeId, route.model ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatModelList(names: string[]): string {
|
||||
if (names.length < 2) return names[0] ?? "";
|
||||
if (names.length === 2) return `${names[0]} or ${names[1]}`;
|
||||
return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
|
||||
}
|
||||
|
||||
interface ClaudeModelPickerProps {
|
||||
id: string;
|
||||
routeName: string;
|
||||
value?: string;
|
||||
models: ClaudeDesktopModelStatus[];
|
||||
disabled: boolean;
|
||||
onChange: (model: string) => void;
|
||||
}
|
||||
|
||||
function ClaudeModelPicker({
|
||||
id,
|
||||
routeName,
|
||||
value,
|
||||
models,
|
||||
disabled,
|
||||
onChange,
|
||||
}: ClaudeModelPickerProps) {
|
||||
return (
|
||||
<Popover className="relative min-w-0">
|
||||
<PopoverButton
|
||||
id={id}
|
||||
aria-label={`Ollama model for ${routeName}`}
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
className="flex min-h-9 w-full items-center gap-2 rounded-lg bg-neutral-50 px-3 py-1.5 text-left text-sm text-neutral-800 outline-none ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-700 dark:text-neutral-100 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${value ? "" : "text-neutral-400"}`}
|
||||
>
|
||||
{value || "Select a model"}
|
||||
</span>
|
||||
<ChevronUpDownIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel
|
||||
anchor={{ to: "bottom end", gap: 8, padding: 8 }}
|
||||
className="z-50 flex w-[var(--button-width)] min-w-64 flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
{({ close }) => (
|
||||
<ClaudeModelPickerOptions
|
||||
routeName={routeName}
|
||||
value={value}
|
||||
models={models}
|
||||
onChange={(model) => {
|
||||
onChange(model);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaudeModelPickerOptions({
|
||||
routeName,
|
||||
value,
|
||||
models,
|
||||
onChange,
|
||||
}: Pick<
|
||||
ClaudeModelPickerProps,
|
||||
"routeName" | "value" | "models" | "onChange"
|
||||
>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.displayName.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Find model..."
|
||||
aria-label={`Find model for ${routeName}`}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div role="listbox" className="min-h-0 overflow-y-auto py-1">
|
||||
{filteredModels.map((model) => {
|
||||
const available = modelIsAvailable(model);
|
||||
const statusLabel = claudeDesktopModelStatusLabel(model);
|
||||
const selected = value === model.name;
|
||||
return (
|
||||
<button
|
||||
key={model.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
disabled={!available}
|
||||
onClick={() => onChange(model.name)}
|
||||
className="flex w-full cursor-pointer items-start gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60"
|
||||
>
|
||||
<span className="mt-0.5 h-4 w-4 flex-shrink-0">
|
||||
{selected && <CheckIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{model.displayName}</span>
|
||||
{statusLabel && (
|
||||
<span className="mt-0.5 block truncate text-xs text-neutral-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && (
|
||||
<p className="px-3 py-2 text-neutral-400">No models found</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClaudeDesktopModelsSettings = forwardRef<
|
||||
ClaudeDesktopModelsSettingsHandle,
|
||||
ClaudeDesktopModelsSettingsProps
|
||||
>(function ClaudeDesktopModelsSettings(
|
||||
{
|
||||
initialStatus,
|
||||
initialLocalModels,
|
||||
initialCloudModels,
|
||||
includeCloudModels = false,
|
||||
onDraftChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const [status, setStatus] = useState<ClaudeDesktopStatus | null>(
|
||||
initialStatus ?? null,
|
||||
);
|
||||
const [models, setModels] = useState<ClaudeDesktopModelStatus[]>(() =>
|
||||
initialStatus ? visibleModels(initialStatus) : [],
|
||||
);
|
||||
const [mappings, setMappings] = useState<ClaudeDesktopMappingStatus[]>(() =>
|
||||
initialStatus ? initialMappings(initialStatus) : [],
|
||||
);
|
||||
const [savedMappings, setSavedMappings] = useState<
|
||||
ClaudeDesktopMappingStatus[]
|
||||
>(() => (initialStatus ? initialMappings(initialStatus) : []));
|
||||
const [localModels, setLocalModels] = useState<string[]>(
|
||||
initialLocalModels ?? [],
|
||||
);
|
||||
const [accountCloudModels, setAccountCloudModels] = useState<string[]>(
|
||||
initialCloudModels ?? [],
|
||||
);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resettingMappings, setResettingMappings] = useState(false);
|
||||
const [autoModeApplying, setAutoModeApplying] = useState(false);
|
||||
const [autoModeOverride, setAutoModeOverride] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const draftRef = useRef({ mappings, savedMappings });
|
||||
const statusRequestRef = useRef(0);
|
||||
const operationInFlightRef = useRef(false);
|
||||
draftRef.current = { mappings, savedMappings };
|
||||
|
||||
const applyStatus = useCallback(
|
||||
(next: ClaudeDesktopStatus, preserveDraft = false) => {
|
||||
const nextMappings = initialMappings(next);
|
||||
const draft = draftRef.current;
|
||||
const keepDraft =
|
||||
preserveDraft && !mappingsEqual(draft.mappings, draft.savedMappings);
|
||||
setStatus(next);
|
||||
setModels(visibleModels(next));
|
||||
if (!keepDraft) {
|
||||
setMappings(nextMappings);
|
||||
setSavedMappings(nextMappings);
|
||||
}
|
||||
setError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!window.getClaudeDesktopStatus) return;
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const next = await window.getClaudeDesktopStatus();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyStatus(next, true);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not read the Claude connection status.");
|
||||
}
|
||||
}
|
||||
}, [applyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStatus) void refreshStatus();
|
||||
const handleFocus = () => void refreshStatus();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, [initialStatus, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocalModels || !status?.used) return;
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
void getClaudeDesktopAvailableModels(includeCloudModels)
|
||||
.then((installed) => {
|
||||
if (!cancelled) {
|
||||
setLocalModels(installed.map((model) => model.model));
|
||||
setAccountCloudModels(
|
||||
installed
|
||||
.filter((model) => model.isCloud())
|
||||
.map((model) => model.model),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Ollama could not load your models.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [includeCloudModels, initialLocalModels, status?.used]);
|
||||
|
||||
const catalogModels = useMemo(() => {
|
||||
const current = new Set(models.map((model) => model.name));
|
||||
const installed: ClaudeDesktopModelStatus[] = localModels
|
||||
.filter((name) => !current.has(name) && !isInvalidModelName(name))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: false,
|
||||
availability: "available",
|
||||
}));
|
||||
return [...models, ...installed];
|
||||
}, [localModels, models]);
|
||||
|
||||
const hasDraftChanges = !mappingsEqual(mappings, savedMappings);
|
||||
const assignedModels = mappings
|
||||
.map((route) => route.model)
|
||||
.filter((model): model is string => Boolean(model));
|
||||
const hasInvalidMapping = assignedModels.some((name) => {
|
||||
const model = catalogModels.find((candidate) => candidate.name === name);
|
||||
return !model || !modelIsAvailable(model);
|
||||
});
|
||||
const busy = applying || resettingMappings || autoModeApplying;
|
||||
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasDraftChanges);
|
||||
}, [hasDraftChanges, onDraftChange]);
|
||||
|
||||
const updateMapping = (routeId: string, model: string) => {
|
||||
setError(null);
|
||||
setMappings((current) =>
|
||||
current.map((route) =>
|
||||
route.routeId === routeId
|
||||
? { ...route, model: model || undefined }
|
||||
: route,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const runMappingAction = useCallback(
|
||||
async (
|
||||
action: (restartConfirmed: boolean) => Promise<ClaudeDesktopActionResult>,
|
||||
failureMessage: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
let result = await action(false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyStatus(result.status, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restart Claude Desktop? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
result = await action(true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
applyStatus(result.status, !result.mappingsApplied);
|
||||
setError(result.error);
|
||||
return Boolean(result.mappingsApplied);
|
||||
}
|
||||
applyStatus(result.status);
|
||||
return true;
|
||||
} catch {
|
||||
setError(failureMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applyStatus],
|
||||
);
|
||||
|
||||
const applyChanges = async () => {
|
||||
const applyMappings = window.applyClaudeDesktopMappings;
|
||||
if (!applyMappings) {
|
||||
setError(
|
||||
"Claude routing settings are available in the Ollama macOS app.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (assignedModels.length === 0) {
|
||||
setError("Choose at least one Ollama model for Claude.");
|
||||
return;
|
||||
}
|
||||
if (hasInvalidMapping) {
|
||||
setError("Choose models available to your account and device.");
|
||||
return;
|
||||
}
|
||||
if (operationInFlightRef.current) return;
|
||||
|
||||
const mappingsToApply = mappingRecord(mappings);
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
await runMappingAction(
|
||||
(restartConfirmed) => applyMappings(mappingsToApply, restartConfirmed),
|
||||
"Ollama could not apply the Claude model mappings.",
|
||||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAutoMode = async (checked: boolean) => {
|
||||
if (!window.setClaudeDesktopAutoMode) {
|
||||
setError("Auto mode is available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setAutoModeOverride(checked);
|
||||
setAutoModeApplying(true);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
let result = await window.setClaudeDesktopAutoMode(checked, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyStatus(result.status, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
"Restart Claude to change auto mode? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
result = await window.setClaudeDesktopAutoMode(checked, true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
applyStatus(result.status);
|
||||
if (result.error) setError(result.error);
|
||||
} catch {
|
||||
setError("Ollama could not update Claude auto mode.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setAutoModeOverride(null);
|
||||
setAutoModeApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaults = useCallback(async (): Promise<boolean> => {
|
||||
if (operationInFlightRef.current) return false;
|
||||
|
||||
const resetMappings = window.resetClaudeDesktopMappings;
|
||||
if (!resetMappings) {
|
||||
setError("Ollama could not reset the Claude model mappings.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setResettingMappings(true);
|
||||
setError(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
return await runMappingAction(
|
||||
resetMappings,
|
||||
"Ollama could not reset the Claude model mappings.",
|
||||
);
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setResettingMappings(false);
|
||||
}
|
||||
}, [runMappingAction]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
|
||||
|
||||
if (!status?.supported || !status.used) return null;
|
||||
|
||||
const autoModeModelNames = Array.from(
|
||||
new Set([
|
||||
...models.filter((model) => model.autoMode).map((model) => model.name),
|
||||
...accountCloudModels,
|
||||
]),
|
||||
);
|
||||
const autoModeModelSet = new Set(autoModeModelNames);
|
||||
const autoModeAvailable =
|
||||
!hasDraftChanges &&
|
||||
assignedModels.length > 0 &&
|
||||
assignedModels.some((name) => autoModeModelSet.has(name));
|
||||
const autoMode = autoModeAvailable
|
||||
? (autoModeOverride ?? status.autoMode ?? false)
|
||||
: (status.autoMode ?? false);
|
||||
const autoModeDescription = hasDraftChanges
|
||||
? "Start or restart Claude to apply model changes before changing auto mode."
|
||||
: autoModeAvailable
|
||||
? "Let Claude decide when to ask before making changes."
|
||||
: accountCloudModels.length > 0
|
||||
? "Select a cloud model from Ollama.com to use auto mode."
|
||||
: autoModeModelNames.length > 0
|
||||
? `Select one of ${formatModelList(autoModeModelNames)} to use auto mode.`
|
||||
: "Auto mode needs a cloud model available to your Ollama.com account.";
|
||||
|
||||
const guidance =
|
||||
claudeDesktopRecoveryMessage(status.error, error) ??
|
||||
(hasDraftChanges && status.running
|
||||
? "Restarting Claude will stop any running task."
|
||||
: null);
|
||||
|
||||
return (
|
||||
<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-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<img
|
||||
src="/launch-icons/claude.svg"
|
||||
alt=""
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2
|
||||
id="claude-settings-heading"
|
||||
className="text-sm font-medium text-neutral-900 dark:text-white"
|
||||
>
|
||||
Claude
|
||||
</h2>
|
||||
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
|
||||
Choose which Ollama model Claude uses for each model option.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={applyChanges}
|
||||
disabled={
|
||||
busy ||
|
||||
assignedModels.length === 0 ||
|
||||
hasInvalidMapping ||
|
||||
(status.running && !hasDraftChanges)
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{(applying || resettingMappings) && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{resettingMappings
|
||||
? "Resetting…"
|
||||
: applying
|
||||
? status.running
|
||||
? "Restarting…"
|
||||
: "Starting…"
|
||||
: status.running
|
||||
? "Restart Claude"
|
||||
: "Start Claude"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full max-w-xl space-y-1">
|
||||
{mappings.map((mapping) => (
|
||||
<div
|
||||
key={mapping.routeId}
|
||||
className="relative grid min-h-12 grid-cols-[5.5rem_3.75rem_minmax(0,1fr)] items-center gap-2 py-1 max-sm:grid-cols-1 max-sm:gap-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{mapping.routeName}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
aria-hidden="true"
|
||||
className="absolute left-[6.6625rem] h-4 w-4 -translate-x-1/2 text-neutral-300 dark:text-neutral-500 max-sm:hidden"
|
||||
/>
|
||||
<div className="col-start-3 w-2/3 min-w-0 max-sm:col-start-auto max-sm:w-full">
|
||||
<ClaudeModelPicker
|
||||
id={`claude-route-${mapping.routeId}`}
|
||||
routeName={mapping.routeName}
|
||||
value={mapping.model ?? ""}
|
||||
disabled={busy || modelsLoading}
|
||||
models={catalogModels}
|
||||
onChange={(model) =>
|
||||
updateMapping(mapping.routeId, model)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Field className="mt-3 w-full max-w-xl border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<Label>Enable auto mode</Label>
|
||||
<Description>{autoModeDescription}</Description>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoMode}
|
||||
disabled={busy || !autoModeAvailable}
|
||||
onChange={(checked) => void toggleAutoMode(checked)}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{guidance && (
|
||||
<p
|
||||
role={error || status.error ? "alert" : "status"}
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{guidance}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 rounded-lg hover:cursor-pointer ${className}`
|
||||
? `text-xs px-4 py-2 z-10 cursor-pointer rounded-lg ${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 ? (
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
command: "ollama launch chatgpt",
|
||||
description: "Complete work with ChatGPT",
|
||||
icon: "/launch-icons/codex-app.png",
|
||||
iconClassName: "h-full w-full",
|
||||
},
|
||||
{
|
||||
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: "openclaw",
|
||||
name: "OpenClaw",
|
||||
command: "ollama launch openclaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ClaudeConnectedIntro,
|
||||
FIRST_MODEL_COMMAND,
|
||||
ConnectAppsScreen,
|
||||
IntroScreen,
|
||||
default as Onboarding,
|
||||
RunOllamaScreen,
|
||||
shouldShowClaudeConnectedIntro,
|
||||
WelcomeScreen,
|
||||
} from "./Onboarding";
|
||||
import {
|
||||
CLAUDE_CONNECTION_TIMEOUT_MS,
|
||||
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).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
expect(html).not.toMatch(/alt="Ollama waving" class="[^"]*dark:/);
|
||||
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("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="desktop-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("detects when the menu bar already reached the requested Claude state", () => {
|
||||
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("keeps the Claude switch on and busy through installer detection", async () => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
const disconnectedStatus = {
|
||||
supported: true,
|
||||
used: false,
|
||||
installed: false,
|
||||
configured: false,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
};
|
||||
let finishInstall!: (result: "opened") => void;
|
||||
const install = new Promise<"opened">((resolve) => {
|
||||
finishInstall = resolve;
|
||||
});
|
||||
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
vi.stubGlobal("window", {
|
||||
OLLAMA_PLATFORM: "darwin",
|
||||
innerHeight: 660,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
getClaudeDesktopConnectionSummary: vi
|
||||
.fn()
|
||||
.mockResolvedValue(disconnectedStatus),
|
||||
setClaudeDesktopConnected: vi.fn(),
|
||||
installClaudeDesktop: vi.fn().mockReturnValue(install),
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ConnectAppsScreen
|
||||
initialClaudeStatus={disconnectedStatus}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: false,
|
||||
action: "connect",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" });
|
||||
let clickResult!: Promise<void>;
|
||||
await act(async () => {
|
||||
clickResult = claudeSwitch().props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBe(true);
|
||||
expect(claudeSwitch().props.disabled).toBe(true);
|
||||
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
|
||||
expect(renderer.root.findByProps({ role: "status" }).children).toContain(
|
||||
"Downloading…",
|
||||
);
|
||||
expect(
|
||||
renderer.root.findAll(
|
||||
(node) =>
|
||||
typeof node.props.className === "string" &&
|
||||
node.props.className.includes("animate-spin"),
|
||||
),
|
||||
).not.toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
finishInstall("opened");
|
||||
await clickResult;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBe(true);
|
||||
expect(claudeSwitch().props.disabled).toBe(true);
|
||||
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
|
||||
expect(renderer.root.findByProps({ role: "status" }).children).toContain(
|
||||
"Finish installing…",
|
||||
);
|
||||
expect(
|
||||
renderer.root.findAll(
|
||||
(node) =>
|
||||
typeof node.props.className === "string" &&
|
||||
node.props.className.includes("animate-spin"),
|
||||
),
|
||||
).not.toHaveLength(0);
|
||||
} finally {
|
||||
if (renderer) {
|
||||
act(() => renderer?.unmount());
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a late native error after the Connect Apps action times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
const disconnectedStatus = {
|
||||
supported: true,
|
||||
used: false,
|
||||
installed: true,
|
||||
configured: false,
|
||||
connected: false,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
};
|
||||
const connectedStatus = {
|
||||
...disconnectedStatus,
|
||||
configured: true,
|
||||
connected: true,
|
||||
};
|
||||
let finishNativeAction!: (result: {
|
||||
status: typeof connectedStatus;
|
||||
error?: string;
|
||||
}) => void;
|
||||
const nativeAction = new Promise<{
|
||||
status: typeof connectedStatus;
|
||||
error?: string;
|
||||
}>((resolve) => {
|
||||
finishNativeAction = resolve;
|
||||
});
|
||||
const getClaudeStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(disconnectedStatus)
|
||||
.mockResolvedValue(connectedStatus);
|
||||
const setClaudeConnected = vi.fn().mockReturnValue(nativeAction);
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
vi.stubGlobal("window", {
|
||||
OLLAMA_PLATFORM: "darwin",
|
||||
innerHeight: 660,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
getClaudeDesktopConnectionSummary: getClaudeStatus,
|
||||
setClaudeDesktopConnected: setClaudeConnected,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<ConnectAppsScreen
|
||||
initialClaudeStatus={disconnectedStatus}
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" });
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(false);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
|
||||
expect(claudeSwitch().props.disabled).toBe(false);
|
||||
|
||||
let clickResult!: Promise<void>;
|
||||
await act(async () => {
|
||||
clickResult = claudeSwitch().props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(setClaudeConnected).toHaveBeenCalledWith(true, false);
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBe(true);
|
||||
expect(claudeSwitch().props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(CLAUDE_CONNECTION_TIMEOUT_MS);
|
||||
await clickResult;
|
||||
});
|
||||
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(false);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
|
||||
expect(claudeSwitch().props.disabled).toBe(false);
|
||||
expect(
|
||||
renderer.root.findByProps({ role: "alert" }).children.join(""),
|
||||
).toContain("Claude is taking too long to connect");
|
||||
|
||||
await act(async () => {
|
||||
finishNativeAction({
|
||||
status: connectedStatus,
|
||||
error: "Claude failed to restart.",
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getClaudeStatus).toHaveBeenCalledOnce();
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(true);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
|
||||
expect(claudeSwitch().props.disabled).toBe(false);
|
||||
expect(
|
||||
renderer.root.findByProps({ role: "alert" }).children.join(""),
|
||||
).toContain("Claude failed to restart.");
|
||||
expect(
|
||||
renderer.root.findAllByProps({ id: "claude-connected-title" }),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
if (renderer) {
|
||||
act(() => renderer?.unmount());
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the Claude intro only before the integration has been used", () => {
|
||||
const firstConnection = {
|
||||
supported: true,
|
||||
used: false,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
};
|
||||
|
||||
expect(shouldShowClaudeConnectedIntro(firstConnection)).toBe(true);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({ ...firstConnection, used: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({
|
||||
...firstConnection,
|
||||
connected: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({
|
||||
...firstConnection,
|
||||
startFailed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Continue as the only Claude intro action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeConnectedIntro onDone={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Continue</button>");
|
||||
expect(html).not.toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
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 a scrollable terminal list", () => {
|
||||
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: "dsh",
|
||||
name: "DeepSeek Harness",
|
||||
description: "DeepSeek's open-source agent harness",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch dsh",
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
description: "Autonomous coding agent",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch cline",
|
||||
},
|
||||
{
|
||||
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("Desktop");
|
||||
expect(html).toContain('id="desktop-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("Desktop")).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("overflow-y-auto");
|
||||
expect(html).not.toContain('aria-label="Show more apps"');
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
expect(html).not.toContain("grid-rows-[0fr]");
|
||||
expect(html).not.toContain("inert");
|
||||
expect(html).toContain("/launch-icons/claude.svg");
|
||||
expect(html).toContain("/launch-icons/claude-code.svg");
|
||||
expect(html).toMatch(
|
||||
/src="\/launch-icons\/cline\.svg"[^>]*class="[^"]*dark:invert/,
|
||||
);
|
||||
expect(html).toContain("/launch-icons/deepseek-harness.svg");
|
||||
expect(html).not.toMatch(
|
||||
/src="\/launch-icons\/deepseek-harness\.svg"[^>]*class="[^"]*dark:invert/,
|
||||
);
|
||||
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 Desktop 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,
|
||||
routedRequests: 12,
|
||||
}}
|
||||
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="desktop-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"');
|
||||
expect(html).toContain("Connected to Ollama · 12 requests this session");
|
||||
});
|
||||
|
||||
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).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
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).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { act, create, type ReactTestInstance } from "react-test-renderer";
|
||||
import { forwardRef, useImperativeHandle } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { Badge } from "./ui/badge";
|
||||
import Settings from "./Settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resetClaudeMappings: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
updateCloudSetting: vi.fn(),
|
||||
setShowAppsInMenu: vi.fn(),
|
||||
refetchUser: vi.fn(),
|
||||
isWindows: false,
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryData: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
},
|
||||
settings: null as SettingsType | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ClaudeDesktopModelsSettings", () => ({
|
||||
ClaudeDesktopModelsSettings: forwardRef(
|
||||
function MockClaudeDesktopSettings(_props, ref) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetToDefaults: mocks.resetClaudeMappings,
|
||||
}));
|
||||
return <section aria-label="Claude settings" />;
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUser", () => ({
|
||||
useUser: () => ({
|
||||
user: { name: "Paid user", email: "paid@example.com", plan: "pro" },
|
||||
isAuthenticated: true,
|
||||
refreshUser: vi.fn(),
|
||||
isRefreshing: false,
|
||||
refetchUser: mocks.refetchUser,
|
||||
fetchConnectUrl: vi.fn(),
|
||||
isLoading: false,
|
||||
disconnectUser: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCloudStatus", () => ({
|
||||
useCloudStatus: () => ({
|
||||
cloudDisabled: false,
|
||||
cloudStatus: { disabled: false, source: "none" },
|
||||
isKnown: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/platform", () => ({
|
||||
isWindowsPlatform: () => mocks.isWindows,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useBlocker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => mocks.queryClient,
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
if (queryKey[0] === "settings") {
|
||||
return {
|
||||
data: { settings: mocks.settings },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { data: { defaultContextLength: 65_536 } };
|
||||
},
|
||||
useMutation: ({
|
||||
mutationFn,
|
||||
onMutate,
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
}: {
|
||||
mutationFn: (value: unknown) => Promise<unknown>;
|
||||
onMutate?: (value: unknown) => Promise<unknown>;
|
||||
onSuccess?: (result: unknown, value: unknown, context: unknown) => void;
|
||||
onError?: (error: unknown, value: unknown, context: unknown) => void;
|
||||
onSettled?: (
|
||||
result: unknown,
|
||||
error: unknown,
|
||||
value: unknown,
|
||||
context: unknown,
|
||||
) => void;
|
||||
}) => {
|
||||
const run = async (
|
||||
value: unknown,
|
||||
callbacks?: { onSuccess?: () => void },
|
||||
) => {
|
||||
const context = await onMutate?.(value);
|
||||
try {
|
||||
const result = await mutationFn(value);
|
||||
onSuccess?.(result, value, context);
|
||||
callbacks?.onSuccess?.();
|
||||
onSettled?.(result, null, value, context);
|
||||
return result;
|
||||
} catch (error) {
|
||||
onError?.(error, value, context);
|
||||
onSettled?.(undefined, error, value, context);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mutate: (value: unknown, callbacks?: { onSuccess?: () => void }) => {
|
||||
void run(value, callbacks);
|
||||
},
|
||||
mutateAsync: (value: unknown) => run(value),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
getSettings: vi.fn(),
|
||||
getInferenceCompute: vi.fn(),
|
||||
updateSettings: mocks.updateSettings,
|
||||
updateCloudSetting: mocks.updateCloudSetting,
|
||||
}));
|
||||
|
||||
function textContent(node: ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === "string" ? child : textContent(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Settings reset interactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isWindows = false;
|
||||
mocks.settings = new SettingsType({ ContextLength: 65_536 });
|
||||
mocks.updateSettings.mockResolvedValue({ settings: mocks.settings });
|
||||
mocks.updateCloudSetting.mockResolvedValue({
|
||||
disabled: false,
|
||||
source: "none",
|
||||
});
|
||||
mocks.setShowAppsInMenu.mockResolvedValue(undefined);
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
getShowAppsInMenu: vi.fn().mockResolvedValue(true),
|
||||
setShowAppsInMenu: mocks.setShowAppsInMenu,
|
||||
open: vi.fn(),
|
||||
confirm: vi.fn(() => true),
|
||||
OLLAMA_TOOLS: false,
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
});
|
||||
|
||||
it("locks every control and shows Saved after reset succeeds", async () => {
|
||||
const pendingClaudeReset = deferred<boolean>();
|
||||
mocks.resetClaudeMappings.mockImplementation(
|
||||
() => pendingClaudeReset.promise,
|
||||
);
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<Settings />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const resetButton = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => textContent(button).includes("Reset to defaults"));
|
||||
if (!resetButton) throw new Error("Reset button not found");
|
||||
|
||||
await act(async () => {
|
||||
resetButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const settingsFieldset = renderer!.root.findByType("fieldset");
|
||||
expect(settingsFieldset.props.disabled).toBe(true);
|
||||
expect(settingsFieldset.props["aria-busy"]).toBe(true);
|
||||
expect(textContent(resetButton)).toContain("Resetting…");
|
||||
expect(renderer!.root.findAllByType(Badge)).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
pendingClaudeReset.resolve(true);
|
||||
await pendingClaudeReset.promise;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false);
|
||||
expect(renderer!.root.findAllByType(Badge)).toHaveLength(1);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("hides Claude Desktop settings and skips its reset on Windows", async () => {
|
||||
mocks.isWindows = true;
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<Settings />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
renderer!.root.findAllByProps({ "aria-label": "Claude settings" }),
|
||||
).toHaveLength(0);
|
||||
|
||||
const resetButton = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => textContent(button).includes("Reset to defaults"));
|
||||
if (!resetButton) throw new Error("Reset button not found");
|
||||
|
||||
await act(async () => {
|
||||
resetButton.props.onClick();
|
||||
await vi.waitFor(() => expect(mocks.updateSettings).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
expect(mocks.resetClaudeMappings).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { applySettingsDefaults } from "./Settings";
|
||||
|
||||
function currentSettings(overrides: Partial<SettingsType> = {}) {
|
||||
return new SettingsType({
|
||||
ContextLength: 65_536,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Settings defaults", () => {
|
||||
it("serializes a full reset before showing Saved", async () => {
|
||||
const settingsUpdate = deferred();
|
||||
const updateSettings = vi.fn(() => settingsUpdate.promise);
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
let resolveClaudeReset!: (succeeded: boolean) => void;
|
||||
const claudeReset = new Promise<boolean>((resolve) => {
|
||||
resolveClaudeReset = resolve;
|
||||
});
|
||||
const resetClaudeMappings = vi.fn(() => claudeReset);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
const reset = applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
Models: "/custom/models",
|
||||
}),
|
||||
currentShowAppsInMenu: false,
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce());
|
||||
expect(updateCloud).toHaveBeenCalledWith(true);
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
expect(updateSettings.mock.calls[0][0]).toMatchObject({
|
||||
Expose: false,
|
||||
Models: "",
|
||||
ContextLength: 65_536,
|
||||
AutoUpdateEnabled: true,
|
||||
});
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
settingsUpdate.resolve();
|
||||
await vi.waitFor(() => expect(resetClaudeMappings).toHaveBeenCalledOnce());
|
||||
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
resolveClaudeReset(true);
|
||||
await reset;
|
||||
|
||||
expect(onSaved).toHaveBeenCalledOnce();
|
||||
expect(updateCloud.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateSettings.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateShowAppsInMenu.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(updateShowAppsInMenu.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
resetClaudeMappings.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(resetClaudeMappings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onSaved.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an environment-only Cloud override", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
cloudSource: "env",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("clears the persisted Cloud override when the source is both", async () => {
|
||||
let environmentDisabled = true;
|
||||
let configDisabled = true;
|
||||
const updateCloud = vi.fn(async (enabled: boolean) => {
|
||||
configDisabled = !enabled;
|
||||
});
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
cloudSource: "both",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(environmentDisabled || configDisabled).toBe(true);
|
||||
expect(configDisabled).toBe(false);
|
||||
|
||||
environmentDisabled = false;
|
||||
expect(environmentDisabled || configDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not issue a redundant Cloud update when Cloud is already on", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
cloudSource: "none",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores Cloud and leaves Claude untouched when settings fail", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const resetClaudeMappings = vi.fn().mockResolvedValue(true);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockRejectedValue(new Error("restart failed")),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
Models: "/custom/models",
|
||||
}),
|
||||
currentShowAppsInMenu: false,
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("restart failed");
|
||||
|
||||
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
|
||||
expect(resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not continue when the Cloud reset fails", async () => {
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud: vi.fn().mockRejectedValue(new Error("cloud failed")),
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("cloud failed");
|
||||
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls earlier changes back when Claude mappings cannot be reset", async () => {
|
||||
const onSaved = vi.fn();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const previousSettings = currentSettings({
|
||||
Expose: true,
|
||||
Models: "/custom/models",
|
||||
});
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(false),
|
||||
currentSettings: previousSettings,
|
||||
currentShowAppsInMenu: false,
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("Claude model mappings could not be reset");
|
||||
|
||||
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
|
||||
expect(updateSettings).toHaveBeenCalledTimes(2);
|
||||
expect(updateSettings.mock.calls[1][0]).toBe(previousSettings);
|
||||
expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -6,24 +6,31 @@ 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,
|
||||
type ClaudeDesktopModelsSettingsHandle,
|
||||
} from "@/components/ClaudeDesktopModelsSettings";
|
||||
import {
|
||||
WifiIcon,
|
||||
FolderIcon,
|
||||
BoltIcon,
|
||||
WrenchIcon,
|
||||
CloudIcon,
|
||||
XMarkIcon,
|
||||
CogIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ArrowPathIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { settingsMutationScope } from "@/lib/settingsMutationScope";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useBlocker } from "@tanstack/react-router";
|
||||
import {
|
||||
getSettings,
|
||||
type CloudStatusSource,
|
||||
type CloudStatusResponse,
|
||||
updateCloudSetting,
|
||||
updateSettings,
|
||||
@@ -44,10 +51,101 @@ function AnimatedDots() {
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsDefaultsActions {
|
||||
updateSettings: (settings: SettingsType) => Promise<unknown>;
|
||||
updateCloud: (enabled: boolean) => Promise<unknown>;
|
||||
updateShowAppsInMenu: (visible: boolean) => Promise<unknown>;
|
||||
resetClaudeMappings: () => Promise<boolean>;
|
||||
currentSettings: SettingsType;
|
||||
currentShowAppsInMenu: boolean;
|
||||
cloudSource: CloudStatusSource;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface CloudUpdateRequest {
|
||||
enabled: boolean;
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
let latestCloudRequestId = 0;
|
||||
const savedConfirmationDuration = 3000;
|
||||
|
||||
export async function applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetClaudeMappings,
|
||||
currentSettings,
|
||||
currentShowAppsInMenu,
|
||||
cloudSource,
|
||||
onSaved,
|
||||
}: SettingsDefaultsActions): Promise<void> {
|
||||
const cloudNeedsReset = cloudSource === "config" || cloudSource === "both";
|
||||
const rollbacks: Array<() => Promise<unknown>> = [];
|
||||
|
||||
try {
|
||||
if (cloudNeedsReset) {
|
||||
await updateCloud(true);
|
||||
rollbacks.push(() => updateCloud(false));
|
||||
}
|
||||
|
||||
await updateSettings(
|
||||
new SettingsType({
|
||||
Expose: false,
|
||||
Browser: false,
|
||||
Models: "",
|
||||
Agent: false,
|
||||
Tools: false,
|
||||
ContextLength: currentSettings.ContextLength,
|
||||
AutoUpdateEnabled: true,
|
||||
}),
|
||||
);
|
||||
rollbacks.push(() => updateSettings(currentSettings));
|
||||
|
||||
await updateShowAppsInMenu(true);
|
||||
rollbacks.push(() => updateShowAppsInMenu(currentShowAppsInMenu));
|
||||
|
||||
// Apply Claude last so no later settings failure can leave its mappings
|
||||
// reset while the rest of the page rolls back.
|
||||
if (!(await resetClaudeMappings())) {
|
||||
throw new Error("Claude model mappings could not be reset");
|
||||
}
|
||||
} catch (error) {
|
||||
const rollbackErrors: unknown[] = [];
|
||||
for (const rollback of rollbacks.reverse()) {
|
||||
try {
|
||||
await rollback();
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError);
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
console.error("Failed to roll back settings reset:", rollbackErrors);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
onSaved();
|
||||
}
|
||||
|
||||
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 [resettingToDefaults, setResettingToDefaults] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false);
|
||||
const claudeModelsSettingsRef =
|
||||
useRef<ClaudeDesktopModelsSettingsHandle>(null);
|
||||
const savedConfirmationTimeoutRef = useRef<number | null>(null);
|
||||
useBlocker({
|
||||
shouldBlockFn: () =>
|
||||
!window.confirm("Discard unapplied Claude routing changes?"),
|
||||
enableBeforeUnload: hasClaudeDraftChanges,
|
||||
disabled: !hasClaudeDraftChanges,
|
||||
});
|
||||
const {
|
||||
user,
|
||||
isAuthenticated,
|
||||
@@ -61,13 +159,32 @@ 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,
|
||||
isLoading: cloudStatusLoading,
|
||||
isKnown: cloudStatusKnown,
|
||||
} = useCloudStatus();
|
||||
|
||||
const showSavedConfirmation = useCallback(() => {
|
||||
if (savedConfirmationTimeoutRef.current !== null) {
|
||||
window.clearTimeout(savedConfirmationTimeoutRef.current);
|
||||
}
|
||||
setShowSaved(true);
|
||||
savedConfirmationTimeoutRef.current = window.setTimeout(() => {
|
||||
setShowSaved(false);
|
||||
savedConfirmationTimeoutRef.current = null;
|
||||
}, savedConfirmationDuration);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (savedConfirmationTimeoutRef.current !== null) {
|
||||
window.clearTimeout(savedConfirmationTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
data: settingsData,
|
||||
isLoading: loading,
|
||||
@@ -87,22 +204,24 @@ export default function Settings() {
|
||||
const defaultContextLength = inferenceComputeResponse?.defaultContextLength;
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
scope: settingsMutationScope,
|
||||
mutationFn: updateSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCloudMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => updateCloudSetting(enabled),
|
||||
onMutate: async (enabled: boolean) => {
|
||||
scope: settingsMutationScope,
|
||||
mutationFn: ({ enabled }: CloudUpdateRequest) =>
|
||||
updateCloudSetting(enabled),
|
||||
onMutate: async ({ enabled, requestId }: CloudUpdateRequest) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
const previous = queryClient.getQueryData<CloudStatusResponse | null>([
|
||||
"cloudStatus",
|
||||
]);
|
||||
if (requestId !== latestCloudRequestId) return { previous };
|
||||
const envForcesDisabled =
|
||||
previous?.source === "env" || previous?.source === "both";
|
||||
|
||||
@@ -121,28 +240,44 @@ export default function Settings() {
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _enabled, context) => {
|
||||
onError: (_error, request, context) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
if (context?.previous !== undefined) {
|
||||
queryClient.setQueryData(["cloudStatus"], context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: (status) => {
|
||||
onSuccess: (status, request) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
queryClient.setQueryData<CloudStatusResponse | null>(
|
||||
["cloudStatus"],
|
||||
status,
|
||||
);
|
||||
},
|
||||
onSettled: (_status, _error, request) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const requestCloudUpdate = (enabled: boolean) => {
|
||||
const requestId = ++latestCloudRequestId;
|
||||
return updateCloudMutation.mutateAsync({ enabled, requestId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
@@ -196,34 +331,88 @@ export default function Settings() {
|
||||
if (field === "ContextLength" && value !== settings.ContextLength) {
|
||||
setRestartMessage(true);
|
||||
// Hide restart message after 3 seconds
|
||||
setTimeout(() => setRestartMessage(false), 3000);
|
||||
window.setTimeout(
|
||||
() => setRestartMessage(false),
|
||||
savedConfirmationDuration,
|
||||
);
|
||||
}
|
||||
|
||||
updateSettingsMutation.mutate(updatedSettings);
|
||||
updateSettingsMutation.mutate(updatedSettings, {
|
||||
onSuccess: showSavedConfirmation,
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, updateSettingsMutation],
|
||||
[settings, showSavedConfirmation, updateSettingsMutation],
|
||||
);
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
if (settings) {
|
||||
const defaultSettings = new SettingsType({
|
||||
Expose: false,
|
||||
Browser: false,
|
||||
Models: "",
|
||||
Agent: false,
|
||||
Tools: false,
|
||||
ContextLength: 0,
|
||||
AutoUpdateEnabled: true,
|
||||
});
|
||||
updateSettingsMutation.mutate(defaultSettings);
|
||||
const updateShowAppsInMenuVisibility = async (checked: boolean) => {
|
||||
const previous = showAppsInMenu;
|
||||
setShowAppsInMenuState(checked);
|
||||
setShowAppsInMenuPending(true);
|
||||
try {
|
||||
await window.setShowAppsInMenu?.(checked);
|
||||
} catch (error) {
|
||||
setShowAppsInMenuState(previous);
|
||||
throw error;
|
||||
} finally {
|
||||
setShowAppsInMenuPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAppsInMenu = (checked: boolean) => {
|
||||
void updateShowAppsInMenuVisibility(checked)
|
||||
.then(showSavedConfirmation)
|
||||
.catch((error) =>
|
||||
console.error("Failed to update menu app visibility:", error),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloudUpdate = (enabled: boolean) => {
|
||||
void requestCloudUpdate(enabled)
|
||||
.then(showSavedConfirmation)
|
||||
.catch((error) =>
|
||||
console.error("Failed to update cloud setting:", error),
|
||||
);
|
||||
};
|
||||
|
||||
const cloudOverriddenByEnv =
|
||||
cloudStatus?.source === "env" || cloudStatus?.source === "both";
|
||||
const cloudToggleDisabled =
|
||||
cloudStatusLoading || updateCloudMutation.isPending || cloudOverriddenByEnv;
|
||||
const cloudToggleDisabled = cloudOverriddenByEnv;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
const cloudSource = cloudStatus?.source;
|
||||
if (!settings || resettingToDefaults || !cloudSource) return;
|
||||
|
||||
setResettingToDefaults(true);
|
||||
if (savedConfirmationTimeoutRef.current !== null) {
|
||||
window.clearTimeout(savedConfirmationTimeoutRef.current);
|
||||
savedConfirmationTimeoutRef.current = null;
|
||||
}
|
||||
setShowSaved(false);
|
||||
setRestartMessage(false);
|
||||
setResetError(null);
|
||||
try {
|
||||
await applySettingsDefaults({
|
||||
updateSettings: (defaultSettings) =>
|
||||
updateSettingsMutation.mutateAsync(defaultSettings),
|
||||
updateCloud: requestCloudUpdate,
|
||||
updateShowAppsInMenu: updateShowAppsInMenuVisibility,
|
||||
resetClaudeMappings: async () =>
|
||||
(await claudeModelsSettingsRef.current?.resetToDefaults()) ?? true,
|
||||
currentSettings: settings,
|
||||
currentShowAppsInMenu: showAppsInMenu,
|
||||
cloudSource,
|
||||
onSaved: showSavedConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reset settings:", error);
|
||||
setResetError(
|
||||
"Ollama could not reset every setting. Check the settings above and try again.",
|
||||
);
|
||||
} finally {
|
||||
setResettingToDefaults(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectOllamaAccount = async () => {
|
||||
setConnectionError(null);
|
||||
@@ -266,49 +455,22 @@ export default function Settings() {
|
||||
|
||||
if (error || !settings) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-red-500">Failed to load settings</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const handleCloseSettings = () => {
|
||||
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
|
||||
navigate({ to: "/c/$chatId", params: { chatId } });
|
||||
};
|
||||
const isWindows = isWindowsPlatform();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col select-none dark:bg-neutral-900">
|
||||
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
|
||||
<div className="space-y-4 max-w-2xl mx-auto">
|
||||
<fieldset
|
||||
disabled={resettingToDefaults}
|
||||
aria-busy={resettingToDefaults}
|
||||
className="mx-auto max-w-4xl space-y-4 border-0 p-0"
|
||||
>
|
||||
{/* Connect Ollama Account */}
|
||||
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
|
||||
<div className="p-4">
|
||||
@@ -439,13 +601,36 @@ export default function Settings() {
|
||||
if (cloudOverriddenByEnv) {
|
||||
return;
|
||||
}
|
||||
updateCloudMutation.mutate(checked);
|
||||
handleCloudUpdate(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
@@ -463,7 +648,9 @@ 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>
|
||||
@@ -544,7 +731,9 @@ 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);
|
||||
}}
|
||||
@@ -566,6 +755,16 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isWindows && (
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={claudeModelsSettingsRef}
|
||||
includeCloudModels={
|
||||
isAuthenticated && cloudStatusKnown && !cloudDisabled
|
||||
}
|
||||
onDraftChange={setHasClaudeDraftChanges}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent Mode */}
|
||||
{window.OLLAMA_TOOLS && (
|
||||
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
|
||||
@@ -611,17 +810,31 @@ export default function Settings() {
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="mt-6 flex justify-end px-4">
|
||||
<div className="flex items-center justify-between gap-4 px-4">
|
||||
{resetError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-xs text-red-600 dark:text-red-400"
|
||||
>
|
||||
{resetError}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="px-3"
|
||||
onClick={handleResetToDefaults}
|
||||
disabled={resettingToDefaults || !cloudStatusKnown}
|
||||
onClick={() => void handleResetToDefaults()}
|
||||
>
|
||||
Reset to defaults
|
||||
{resettingToDefaults && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{resettingToDefaults ? "Resetting…" : "Reset to defaults"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Saved indicator */}
|
||||
{(showSaved || restartMessage) && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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,30 +1,39 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { useState } from "react";
|
||||
|
||||
let sessionSidebarOpen = false;
|
||||
|
||||
export function SidebarLayout({
|
||||
sidebar,
|
||||
title,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
sidebar: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
chatId?: string;
|
||||
title?: string;
|
||||
}>) {
|
||||
const { settings, setSettings } = useSettings();
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(sessionSidebarOpen);
|
||||
const isWindows = isWindowsPlatform();
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sessionSidebarOpen = !sidebarOpen;
|
||||
setSidebarOpen(sessionSidebarOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex transition-[width] duration-300 dark:bg-neutral-900`}>
|
||||
<div className="flex h-screen w-full overflow-hidden 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 ${settings.sidebarOpen ? (isWindows ? "left-2" : "left-[204px]") : 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 ${sidebarOpen ? (isWindows ? "left-2" : "left-[140px]") : isWindows ? "left-2" : "left-20"}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSettings({ SidebarOpen: !settings.sidebarOpen })}
|
||||
onClick={toggleSidebar}
|
||||
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={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
title={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
aria-label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 fill-current"
|
||||
@@ -35,45 +44,47 @@ 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>
|
||||
<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"
|
||||
{!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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<ChatIcon />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col transition-[width] duration-300 max-h-screen ${settings.sidebarOpen ? "w-64" : "w-0"}`}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={() => window.doubleClick && window.doubleClick()}
|
||||
onMouseDown={() => window.drag && window.drag()}
|
||||
className="flex-none h-13 w-full"
|
||||
></div>
|
||||
{settings.sidebarOpen && sidebar}
|
||||
{sidebarOpen && sidebar}
|
||||
</div>
|
||||
<main
|
||||
className={`flex flex-1 flex-col min-w-0 transition-all duration-300`}
|
||||
>
|
||||
<main className="flex min-w-0 flex-1 flex-col transition-all duration-300">
|
||||
<div
|
||||
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"}`}
|
||||
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"}`}
|
||||
onDoubleClick={() => window.doubleClick && window.doubleClick()}
|
||||
onMouseDown={() => window.drag && window.drag()}
|
||||
></div>
|
||||
>
|
||||
{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>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SettingsState {
|
||||
selectedModel: string;
|
||||
sidebarOpen: boolean;
|
||||
lastHomeView: string;
|
||||
onboardingVersion: number;
|
||||
thinkEnabled: boolean;
|
||||
thinkLevel: string;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ type SettingsUpdate = Partial<{
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
}>;
|
||||
|
||||
export function useSettings() {
|
||||
@@ -52,7 +54,8 @@ export function useSettings() {
|
||||
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
||||
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
||||
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "chat",
|
||||
onboardingVersion: settingsData?.settings?.OnboardingVersion ?? 0,
|
||||
}),
|
||||
[settingsData?.settings],
|
||||
);
|
||||
|
||||
@@ -2,18 +2,38 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
@custom-variant dark {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&:not(.light-only, .light-only *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Dark mode styles go here */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Example dark mode variables */
|
||||
--bg-color: #1a1a1a;
|
||||
--text-color: #ffffff;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.light-only {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
a[href],
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role="button"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +48,32 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { preloadChatData } from "./chatPreload";
|
||||
|
||||
describe("preloadChatData", () => {
|
||||
it("warms Chat data once without installing polling options", async () => {
|
||||
const prefetchQuery = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await preloadChatData({ prefetchQuery } as unknown as Pick<
|
||||
QueryClient,
|
||||
"prefetchQuery"
|
||||
>);
|
||||
|
||||
expect(prefetchQuery).toHaveBeenCalledTimes(4);
|
||||
expect(
|
||||
prefetchQuery.mock.calls.map(([options]) => options.queryKey),
|
||||
).toEqual([
|
||||
["chats"],
|
||||
["health"],
|
||||
["models", ""],
|
||||
["modelRecommendations"],
|
||||
]);
|
||||
for (const [options] of prefetchQuery.mock.calls) {
|
||||
expect(options).not.toHaveProperty("refetchInterval");
|
||||
expect(options).not.toHaveProperty("refetchIntervalInBackground");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchHealth,
|
||||
getChats,
|
||||
getModelRecommendations,
|
||||
getModels,
|
||||
} from "@/api";
|
||||
|
||||
type PrefetchClient = Pick<QueryClient, "prefetchQuery">;
|
||||
|
||||
export function preloadChatData(queryClient: PrefetchClient) {
|
||||
return Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["chats"],
|
||||
queryFn: getChats,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: fetchHealth,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["models", ""],
|
||||
queryFn: () => getModels(""),
|
||||
gcTime: 10 * 60 * 1000,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["modelRecommendations"],
|
||||
queryFn: getModelRecommendations,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addClaudeModelSelection,
|
||||
ClaudeConnectionTimeoutError,
|
||||
claudeDesktopRecoveryMessage,
|
||||
claudeDesktopMaxModels,
|
||||
claudeDesktopMaxModelsMessage,
|
||||
claudeDesktopRequestCountLabel,
|
||||
claudeDesktopUsableSelection,
|
||||
defaultClaudeDesktopMaxModels,
|
||||
isClaudeConfigured,
|
||||
optimisticClaudeConnectionState,
|
||||
withClaudeConnectionTimeout,
|
||||
} 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("optimisticClaudeConnectionState", () => {
|
||||
it("shows the requested state while a connection change is pending", () => {
|
||||
expect(optimisticClaudeConnectionState(false, true)).toBe(true);
|
||||
expect(optimisticClaudeConnectionState(true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the confirmed state when no change is pending", () => {
|
||||
expect(optimisticClaudeConnectionState(true, null)).toBe(true);
|
||||
expect(optimisticClaudeConnectionState(false, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeDesktopRequestCountLabel", () => {
|
||||
it("formats zero, singular, and plural session counts", () => {
|
||||
expect(claudeDesktopRequestCountLabel(0)).toBe("0 requests this session");
|
||||
expect(claudeDesktopRequestCountLabel(1)).toBe("1 request this session");
|
||||
expect(claudeDesktopRequestCountLabel(2)).toBe("2 requests this session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withClaudeConnectionTimeout", () => {
|
||||
it("returns a native result that finishes before the watchdog", async () => {
|
||||
await expect(
|
||||
withClaudeConnectionTimeout(Promise.resolve("connected"), 100),
|
||||
).resolves.toBe("connected");
|
||||
});
|
||||
|
||||
it("reports when native work settles after the watchdog", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let resolveNative!: (value: string) => void;
|
||||
const nativeAction = new Promise<string>((resolve) => {
|
||||
resolveNative = resolve;
|
||||
});
|
||||
const onLateSettled = vi.fn();
|
||||
const result = withClaudeConnectionTimeout(
|
||||
nativeAction,
|
||||
100,
|
||||
onLateSettled,
|
||||
);
|
||||
const timedOut = expect(result).rejects.toBeInstanceOf(
|
||||
ClaudeConnectionTimeoutError,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await timedOut;
|
||||
expect(onLateSettled).not.toHaveBeenCalled();
|
||||
resolveNative("late connection");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onLateSettled).toHaveBeenCalledOnce();
|
||||
expect(onLateSettled).toHaveBeenCalledWith({
|
||||
status: "fulfilled",
|
||||
value: "late connection",
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(ClaudeConnectionTimeoutError);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
|
||||
export const CLAUDE_INSTALL_TIMEOUT_MS = 120_000;
|
||||
export const CLAUDE_CONNECTION_TIMEOUT_MS = 45_000;
|
||||
|
||||
export class ClaudeConnectionTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Claude connection timed out");
|
||||
this.name = "ClaudeConnectionTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export function withClaudeConnectionTimeout<T>(
|
||||
action: Promise<T>,
|
||||
timeoutMs = CLAUDE_CONNECTION_TIMEOUT_MS,
|
||||
onLateSettled?: (result: PromiseSettledResult<T>) => void,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
reject(new ClaudeConnectionTimeoutError());
|
||||
}, timeoutMs);
|
||||
action.then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
if (timedOut) {
|
||||
onLateSettled?.({ status: "fulfilled", value });
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
if (timedOut) {
|
||||
onLateSettled?.({ status: "rejected", reason: error });
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 optimisticClaudeConnectionState(
|
||||
configured: boolean,
|
||||
pending: boolean | null,
|
||||
): boolean {
|
||||
return pending ?? configured;
|
||||
}
|
||||
|
||||
export function claudeDesktopRequestCountLabel(count: number): string {
|
||||
return `${count} ${count === 1 ? "request" : "requests"} this session`;
|
||||
}
|
||||
|
||||
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] };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ClaudeDesktopModelStatus } from "@/types/webview";
|
||||
|
||||
export function claudeDesktopModelStatusLabel(
|
||||
model: ClaudeDesktopModelStatus,
|
||||
): string | null {
|
||||
switch (model.reason) {
|
||||
case "sign_in_required":
|
||||
return "Sign in required";
|
||||
case "upgrade_required":
|
||||
return model.requiredPlan
|
||||
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
|
||||
: "Upgrade required";
|
||||
case "verification_unavailable":
|
||||
return "Access unavailable";
|
||||
case "model_not_installed":
|
||||
return "Not installed";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { preventPageSelectAll } from "./keyboard";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return {
|
||||
key: "a",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
target: { tagName: "BODY" } as EventTarget,
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("preventPageSelectAll", () => {
|
||||
it("prevents Command+A from selecting the page", () => {
|
||||
const event = keyboardEvent();
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("prevents Ctrl+A from selecting the page", () => {
|
||||
const event = keyboardEvent({ metaKey: false, ctrlKey: true });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ tagName: "INPUT" },
|
||||
{ tagName: "TEXTAREA" },
|
||||
{ tagName: "DIV", isContentEditable: true },
|
||||
])("keeps Select All working in editable targets", (target) => {
|
||||
const event = keyboardEvent({ target: target as EventTarget });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves unmodified A keypresses alone", () => {
|
||||
const event = keyboardEvent({ metaKey: false });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
type KeyboardTarget = EventTarget & {
|
||||
tagName?: string;
|
||||
isContentEditable?: boolean;
|
||||
};
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!target || typeof target !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tagName, isContentEditable } = target as KeyboardTarget;
|
||||
return (
|
||||
isContentEditable === true || tagName === "INPUT" || tagName === "TEXTAREA"
|
||||
);
|
||||
}
|
||||
|
||||
export function preventPageSelectAll(event: KeyboardEvent) {
|
||||
const isSelectAll =
|
||||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a";
|
||||
|
||||
if (isSelectAll && !isEditableTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface IntegrationIcon {
|
||||
src: string;
|
||||
darkSrc?: 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",
|
||||
darkSrc: "/launch-icons/codex-dark.svg",
|
||||
},
|
||||
copilot: {
|
||||
src: "/launch-icons/copilot.svg",
|
||||
darkSrc: "/launch-icons/copilot-dark.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",
|
||||
className: "h-7 w-7 dark:invert",
|
||||
},
|
||||
omp: { src: "/launch-icons/oh-my-pi.svg" },
|
||||
pool: { src: "/launch-icons/poolside.svg" },
|
||||
qwen: { src: "/launch-icons/qwen-code.svg" },
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { settingsMutationScope } from "./settingsMutationScope";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("settingsMutationScope", () => {
|
||||
it("serializes settings and Cloud updates in request order", async () => {
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const updateSettings = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockReturnValue(first.promise);
|
||||
const updateCloud = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockReturnValue(second.promise);
|
||||
const queryClient = new QueryClient();
|
||||
const mutationCache = queryClient.getMutationCache();
|
||||
const settingsMutation = mutationCache.build(queryClient, {
|
||||
mutationFn: updateSettings,
|
||||
scope: settingsMutationScope,
|
||||
});
|
||||
const cloudMutation = mutationCache.build(queryClient, {
|
||||
mutationFn: updateCloud,
|
||||
scope: settingsMutationScope,
|
||||
});
|
||||
|
||||
const firstResult = settingsMutation.execute(undefined);
|
||||
const secondResult = cloudMutation.execute(undefined);
|
||||
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce());
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
|
||||
first.resolve("settings updated");
|
||||
await firstResult;
|
||||
await vi.waitFor(() => expect(updateCloud).toHaveBeenCalledOnce());
|
||||
|
||||
second.resolve("cloud updated");
|
||||
await expect(secondResult).resolves.toBe("cloud updated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const settingsMutationScope = { id: "settings-update" } as const;
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
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'
|
||||
|
||||
@@ -23,6 +25,18 @@ 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: '/',
|
||||
@@ -46,6 +60,20 @@ 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'
|
||||
@@ -67,12 +95,16 @@ 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
|
||||
}
|
||||
@@ -80,27 +112,33 @@ 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: '/' | '/settings' | '/c/$chatId'
|
||||
fullPaths: '/' | '/connect' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/settings' | '/c/$chatId'
|
||||
to: '/' | '/connect' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/connect' | '/onboarding' | '/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,
|
||||
}
|
||||
@@ -116,6 +154,8 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/connect",
|
||||
"/onboarding",
|
||||
"/settings",
|
||||
"/c/$chatId"
|
||||
]
|
||||
@@ -123,6 +163,12 @@ export const routeTree = rootRoute
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/connect": {
|
||||
"filePath": "connect.tsx"
|
||||
},
|
||||
"/onboarding": {
|
||||
"filePath": "onboarding.tsx"
|
||||
},
|
||||
"/settings": {
|
||||
"filePath": "settings.tsx"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
import { getSettings } from "@/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { preloadChatData } from "@/lib/chatPreload";
|
||||
import { preventPageSelectAll } from "@/lib/keyboard";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function RootComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", preventPageSelectAll);
|
||||
return () => document.removeEventListener("keydown", preventPageSelectAll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void preloadChatData(queryClient);
|
||||
}, [queryClient]);
|
||||
|
||||
// This hook ensures settings are fetched on app startup
|
||||
useQuery({
|
||||
queryKey: ["settings"],
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } 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 LaunchCommands from "@/components/LaunchCommands";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } 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 }) => {
|
||||
// Skip loading for special non-chat views
|
||||
if (params.chatId !== "new" && params.chatId !== "launch") {
|
||||
if (params.chatId !== "new") {
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["chat", params.chatId],
|
||||
queryFn: () => getChat(params.chatId),
|
||||
@@ -47,61 +32,19 @@ 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 === "launch" ? "" : chatId);
|
||||
} = useChat(chatId === "new" ? "" : 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;
|
||||
}
|
||||
@@ -120,14 +63,6 @@ function RouteComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (chatId === "launch") {
|
||||
return (
|
||||
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
|
||||
<LaunchCommands />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle existing chat case
|
||||
if (chatLoading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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="Apps" sidebar={<AppSidebar current="apps" />}>
|
||||
<ConnectAppsScreen />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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 }) => {
|
||||
@@ -7,8 +8,11 @@ export const Route = createFileRoute("/")({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
const chatId =
|
||||
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||
if (settingsData.settings.OnboardingVersion < CURRENT_ONBOARDING_VERSION) {
|
||||
throw redirect({ to: "/onboarding" });
|
||||
}
|
||||
|
||||
const chatId = homeChatId();
|
||||
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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,6 +1,16 @@
|
||||
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: Settings,
|
||||
component: SettingsRoute,
|
||||
});
|
||||
|
||||
function SettingsRoute() {
|
||||
return (
|
||||
<SidebarLayout title="Settings" sidebar={<AppSidebar current="settings" />}>
|
||||
<Settings />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,57 @@ interface MenuItem {
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopStatus {
|
||||
supported: boolean;
|
||||
used: boolean;
|
||||
installed: boolean;
|
||||
configured?: boolean;
|
||||
connected: boolean;
|
||||
running: boolean;
|
||||
startFailed: boolean;
|
||||
portConflict: boolean;
|
||||
gatewayPort?: number;
|
||||
routedRequests?: number;
|
||||
error?: string;
|
||||
autoMode?: boolean;
|
||||
modelSource?: "user" | "endpoint" | "fallback";
|
||||
maxModels?: number;
|
||||
models?: ClaudeDesktopModelStatus[];
|
||||
mappings?: ClaudeDesktopMappingStatus[];
|
||||
}
|
||||
|
||||
interface ClaudeDesktopMappingStatus {
|
||||
routeId: string;
|
||||
routeName: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface ClaudeDesktopModelStatus {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
cloud?: boolean;
|
||||
selected: boolean;
|
||||
autoMode?: 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;
|
||||
mappingsApplied?: boolean;
|
||||
restartConfirmationRequired?: boolean;
|
||||
}
|
||||
|
||||
type ClaudeDesktopInstallResult = "opened" | "cancelled" | "failed";
|
||||
|
||||
interface WebviewAPI {
|
||||
selectFile: () => Promise<ImageData | null>;
|
||||
selectMultipleFiles: () => Promise<ImageData[] | null>;
|
||||
@@ -24,9 +75,35 @@ declare global {
|
||||
webview?: WebviewAPI;
|
||||
drag?: () => void;
|
||||
doubleClick?: () => void;
|
||||
activateOllama?: () => void;
|
||||
getClaudeDesktopStatus?: () => Promise<ClaudeDesktopStatus>;
|
||||
getClaudeDesktopConnectionSummary?: () => Promise<ClaudeDesktopStatus>;
|
||||
getClaudeDesktopRequestCount?: () => Promise<number>;
|
||||
setClaudeDesktopConnected?: (
|
||||
enabled: boolean,
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
|
||||
openClaudeDesktop?: () => Promise<string>;
|
||||
installClaudeDesktop?: () => Promise<ClaudeDesktopInstallResult>;
|
||||
getShowAppsInMenu?: () => Promise<boolean>;
|
||||
setShowAppsInMenu?: (visible: boolean) => Promise<void>;
|
||||
applyClaudeDesktopMappings?: (
|
||||
mappings: Record<string, string>,
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
resetClaudeDesktopMappings?: (
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
setClaudeDesktopAutoMode?: (
|
||||
enabled: boolean,
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
setOnboardingWindow?: (enabled: boolean) => void;
|
||||
menu: (items: MenuItem[]) => Promise<string | null>;
|
||||
OLLAMA_TOOLS?: boolean;
|
||||
OLLAMA_WEBSEARCH?: boolean;
|
||||
OLLAMA_PLATFORM?: "darwin" | "windows";
|
||||
}
|
||||
|
||||
namespace JSX {
|
||||
@@ -46,4 +123,14 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type { ImageData, WebviewAPI, ContextMenuItem, ContextMenuResult };
|
||||
export type {
|
||||
ClaudeDesktopActionResult,
|
||||
ClaudeDesktopInstallResult,
|
||||
ClaudeDesktopMappingStatus,
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
ContextMenuItem,
|
||||
ContextMenuResult,
|
||||
ImageData,
|
||||
WebviewAPI,
|
||||
};
|
||||
@@ -31,6 +31,7 @@ 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"
|
||||
@@ -110,8 +111,10 @@ type Server struct {
|
||||
Dev bool
|
||||
|
||||
// Updater for checking and downloading updates
|
||||
Updater *updater.Updater
|
||||
UpdateAvailableFunc func()
|
||||
Updater *updater.Updater
|
||||
UpdateAvailableFunc func()
|
||||
IntegrationInstalled func(string) bool
|
||||
ListCloudModels func(context.Context) (*api.ListResponse, error)
|
||||
}
|
||||
|
||||
func (s *Server) log() *slog.Logger {
|
||||
@@ -292,6 +295,8 @@ 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()
|
||||
@@ -314,6 +319,74 @@ 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", "hermes", "hermes-desktop", "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
|
||||
@@ -1464,11 +1537,27 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
var settings store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
var request struct {
|
||||
store.Settings
|
||||
OnboardingVersion *int
|
||||
ClaudeDesktopUsed *bool
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); 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)
|
||||
}
|
||||
@@ -1537,6 +1626,58 @@ 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,6 +18,7 @@ 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) {
|
||||
@@ -119,6 +120,80 @@ 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", "hermes", "hermes-desktop", "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)
|
||||
@@ -527,6 +602,63 @@ 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) {
|
||||
@@ -718,6 +850,100 @@ 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"),
|
||||
|
||||
@@ -153,10 +153,12 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return err
|
||||
}
|
||||
|
||||
// In case of slow downloads, continue the update check in the background
|
||||
// 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.
|
||||
bgctx, bgcancel := context.WithCancel(downloadCtx)
|
||||
defer bgcancel()
|
||||
go func() {
|
||||
var bgwg sync.WaitGroup
|
||||
bgwg.Go(func() {
|
||||
for {
|
||||
select {
|
||||
case <-bgctx.Done():
|
||||
@@ -165,6 +167,10 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
u.checkForUpdate(bgctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
bgcancel()
|
||||
bgwg.Wait()
|
||||
}()
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
@@ -434,7 +434,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 {
|
||||
|
||||
@@ -190,6 +190,23 @@ func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -276,6 +293,7 @@ func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
callbacks <- ver
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
@@ -364,6 +382,7 @@ func TestBackgoundChecker(t *testing.T) {
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
select {
|
||||
case <-stallTimer.C:
|
||||
t.Fatal("stalled")
|
||||
@@ -426,6 +445,7 @@ 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)
|
||||
@@ -488,6 +508,7 @@ 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)
|
||||
@@ -556,7 +577,9 @@ 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)
|
||||
}()
|
||||
|
||||
@@ -577,6 +600,10 @@ 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) {
|
||||
@@ -615,6 +642,7 @@ 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 {
|
||||
|
||||
@@ -78,7 +78,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)
|
||||
@@ -394,13 +394,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
|
||||
}
|
||||
|
||||
@@ -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 openUIMenuID:
|
||||
case openAppsMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIShow()
|
||||
t.app.UIRun("/connect")
|
||||
case settingsUIMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIRun("/settings")
|
||||
@@ -174,14 +174,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
}
|
||||
}
|
||||
case uint32(FOCUS_WINDOW_MSG_ID):
|
||||
// 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("/")
|
||||
}
|
||||
focusUI(t.app)
|
||||
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.
|
||||
@@ -197,6 +190,14 @@ 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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||