app: add code for macOS and Windows apps under 'app' (#12933)

* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
This commit is contained in:
Daniel Hiltgen
2025-11-04 11:40:17 -08:00
committed by GitHub
parent a4770107a6
commit d3b4b9970a
212 changed files with 102976 additions and 1482 deletions

View File

@@ -15,44 +15,56 @@ jobs:
environment: release
outputs:
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
VERSION: ${{ steps.goflags.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
- name: Set environment
id: goflags
run: |
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" >>$GITHUB_OUTPUT
echo VERSION="${GITHUB_REF_NAME#v}" >>$GITHUB_OUTPUT
darwin-build:
runs-on: macos-13-xlarge
runs-on: macos-14-xlarge
environment: release
needs: setup-environment
strategy:
matrix:
os: [darwin]
arch: [amd64, arm64]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_ID: ${{ vars.APPLE_ID }}
MACOS_SIGNING_KEY: ${{ secrets.MACOS_SIGNING_KEY }}
MACOS_SIGNING_KEY_PASSWORD: ${{ secrets.MACOS_SIGNING_KEY_PASSWORD }}
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
steps:
- uses: actions/checkout@v4
- run: |
echo $MACOS_SIGNING_KEY | base64 --decode > certificate.p12
security create-keychain -p password build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p password build.keychain
security import certificate.p12 -k build.keychain -P $MACOS_SIGNING_KEY_PASSWORD -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password build.keychain
security set-keychain-settings -lut 3600 build.keychain
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: |
go build -o dist/ .
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 1
CGO_CPPFLAGS: '-mmacosx-version-min=11.3'
- if: matrix.arch == 'amd64'
./scripts/build_darwin.sh
- name: Log build results
run: |
cmake --preset CPU -DCMAKE_OSX_DEPLOYMENT_TARGET=11.3 -DCMAKE_SYSTEM_PROCESSOR=x86_64 -DCMAKE_OSX_ARCHITECTURES=x86_64
cmake --build --parallel --preset CPU
cmake --install build --component CPU --strip --parallel 8
ls -l dist/
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}-${{ matrix.arch }}
path: dist/*
name: bundles-darwin
path: |
dist/*.tgz
dist/*.zip
dist/*.dmg
windows-depends:
strategy:
@@ -72,7 +84,6 @@ jobs:
- '"cublas_dev"'
cuda-version: '12.8'
flags: ''
runner_dir: 'cuda_v12'
- os: windows
arch: amd64
preset: 'CUDA 13'
@@ -87,14 +98,12 @@ jobs:
- '"nvptxcompiler"'
cuda-version: '13.0'
flags: ''
runner_dir: 'cuda_v13'
- os: windows
arch: amd64
preset: 'ROCm 6'
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
rocm-version: '6.2'
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
runner_dir: 'rocm'
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
env:
@@ -160,12 +169,15 @@ jobs:
run: |
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} -DOLLAMA_RUNNER_DIR="${{ matrix.runner_dir }}"
cmake --build --parallel --preset "${{ matrix.preset }}"
cmake --install build --component "${{ startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || 'CPU' }}" --strip --parallel 8
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
cmake --install build --component "${{ startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || 'CPU' }}" --strip
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
env:
CMAKE_GENERATOR: Ninja
- name: Log build results
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- uses: actions/upload-artifact@v4
with:
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
@@ -188,6 +200,7 @@ jobs:
needs: [setup-environment]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- name: Install ARM64 system dependencies
if: matrix.arch == 'arm64'
@@ -198,6 +211,9 @@ jobs:
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
choco install -y --no-progress git gzip
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install clang and gcc-compat
@@ -223,13 +239,72 @@ jobs:
exit 1
}
$ErrorActionPreference='Stop'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- run: |
go build -o dist/${{ matrix.os }}-${{ matrix.arch }}/ .
./scripts/build_windows ollama app
- name: Log build results
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}-${{ matrix.arch }}
path: |
dist\${{ matrix.os }}-${{ matrix.arch }}\*.exe
dist\*
windows-app:
runs-on: windows
environment: release
needs: [windows-build, windows-depends]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
KEY_CONTAINER: ${{ vars.KEY_CONTAINER }}
steps:
- uses: actions/checkout@v4
# - uses: google-github-actions/auth@v2
# with:
# project_id: ollama
# credentials_json: ${{ secrets.GOOGLE_SIGNING_CREDENTIALS }}
# - run: |
# $ErrorActionPreference = "Stop"
# Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=323507" -OutFile "${{ runner.temp }}\sdksetup.exe"
# Start-Process "${{ runner.temp }}\sdksetup.exe" -ArgumentList @("/q") -NoNewWindow -Wait
# Invoke-WebRequest -Uri "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/cng-v1.0/kmscng-1.0-windows-amd64.zip" -OutFile "${{ runner.temp }}\plugin.zip"
# Expand-Archive -Path "${{ runner.temp }}\plugin.zip" -DestinationPath "${{ runner.temp }}\plugin\"
# & "${{ runner.temp }}\plugin\*\kmscng.msi" /quiet
# echo "${{ vars.OLLAMA_CERT }}" >ollama_inc.crt
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: actions/download-artifact@v4
with:
pattern: depends-windows*
path: dist
merge-multiple: true
- uses: actions/download-artifact@v4
with:
pattern: build-windows*
path: dist
merge-multiple: true
- name: Log dist contents after download
run: |
gci -path .\dist -recurse
- run: |
./scripts/build_windows.ps1 deps sign installer zip
- name: Log contents after build
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- uses: actions/upload-artifact@v4
with:
name: bundles-windows
path: |
dist/*.zip
dist/OllamaSetup.exe
linux-build:
strategy:
@@ -288,7 +363,7 @@ jobs:
done
- uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
path: |
*.tgz
@@ -344,7 +419,7 @@ jobs:
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
target: ${{ matrix.target }}
target: ${{ matrix.preset }}
build-args: ${{ matrix.build-args }}
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
@@ -393,17 +468,28 @@ jobs:
docker buildx imagetools inspect ${{ vars.DOCKER_REPO }}:${{ steps.metadata.outputs.version }}
working-directory: ${{ runner.temp }}
# Trigger downstream release process
trigger:
# Final release process
release:
runs-on: ubuntu-latest
environment: release
needs: [darwin-build, windows-build, windows-depends, linux-build]
needs: [darwin-build, windows-app, linux-build]
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: bundles-*
path: dist
merge-multiple: true
- name: Log dist contents
run: |
ls -l dist/
- name: Generate checksum file
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
working-directory: dist
- name: Create or update Release for tag
run: |
RELEASE_VERSION="$(echo ${GITHUB_REF_NAME} | cut -f1 -d-)"
@@ -420,12 +506,17 @@ jobs:
--generate-notes \
--prerelease
fi
- name: Trigger downstream release process
- name: Upload release artifacts
run: |
curl -L \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.RELEASE_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/ollama/${{ vars.RELEASE_REPO }}/dispatches \
-d "{\"event_type\": \"trigger-workflow\", \"client_payload\": {\"run_id\": \"${GITHUB_RUN_ID}\", \"version\": \"${GITHUB_REF_NAME#v}\", \"origin\": \"${GITHUB_REPOSITORY}\", \"publish\": \"1\"}}"
pids=()
for payload in dist/*.txt dist/*.zip dist/*.tgz dist/*.exe dist/*.dmg ; do
echo "Uploading $payload"
gh release upload ${GITHUB_REF_NAME} $payload --clobber &
pids[$!]=$!
sleep 1
done
echo "Waiting for uploads to complete"
for pid in "${pids[*]}"; do
wait $pid
done
echo "done"

View File

@@ -200,51 +200,26 @@ jobs:
runs-on: ${{ matrix.os }}
env:
CGO_ENABLED: '1'
GOEXPERIMENT: 'synctest'
steps:
- name: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
- name: cache restore
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# NOTE: The -3- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
restore-keys: |
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}
${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-
- name: Setup Go
uses: actions/setup-go@v5
go-version-file: 'go.mod'
- uses: actions/setup-node@v4
with:
# The caching strategy of setup-go is less than ideal, and wastes
# time by not saving artifacts due to small failures like the linter
# complaining, etc. This means subsequent have to rebuild their world
# again until all checks pass. For instance, if you mispell a word,
# you're punished until you fix it. This is more hostile than
# helpful.
cache: false
go-version-file: go.mod
# It is tempting to run this in a platform independent way, but the past
# shows this codebase will see introductions of platform specific code
# generation, and so we need to check this per platform to ensure we
# don't abuse go generate on specific platforms.
- name: check that 'go generate' is clean
if: always()
node-version: '20'
- name: Install UI dependencies
working-directory: ./app/ui/app
run: npm ci
- name: Install tscriptify
run: |
go generate ./...
git diff --name-only --exit-code || (echo "Please run 'go generate ./...'." && exit 1)
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
- name: Run UI tests
if: ${{ startsWith(matrix.os, 'ubuntu') }}
working-directory: ./app/ui/app
run: npm test
- name: Run go generate
run: go generate ./...
- name: go test
if: always()
@@ -257,26 +232,6 @@ jobs:
with:
args: --timeout 10m0s -v
- name: cache save
# Always save the cache, even if the job fails. The artifacts produced
# during the building of test binaries are not all for naught. They can
# be used to speed up subsequent runs.
if: always()
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
# Note: unlike the other setups, this is only grabbing the mod download
# cache, rather than the whole mod directory, as the download cache
# contains zips that can be unpacked in parallel faster than they can be
# fetched and extracted by tar
path: |
~/.cache/go-build
~/go/pkg/mod/cache
~\AppData\Local\go-build
# NOTE: The -3- here should be incremented when the scheme of data to be
# cached changes (e.g. path above changes).
key: ${{ github.job }}-${{ runner.os }}-${{ matrix.goarch }}-${{ matrix.buildflags }}-go-3-${{ hashFiles('**/go.sum') }}-${{ github.run_id }}
patches:
runs-on: ubuntu-latest
steps:

View File

@@ -23,7 +23,8 @@
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2"
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
"OLLAMA_RUNNER_DIR": "cuda_v11"
}
},
{
@@ -31,7 +32,8 @@
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2"
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
"OLLAMA_RUNNER_DIR": "cuda_v12"
}
},
{
@@ -39,21 +41,24 @@
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
"CMAKE_CUDA_FLAGS": "-t 2"
"CMAKE_CUDA_FLAGS": "-t 2",
"OLLAMA_RUNNER_DIR": "cuda_v13"
}
},
{
"name": "JetPack 5",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "72;87"
"CMAKE_CUDA_ARCHITECTURES": "72;87",
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
}
},
{
"name": "JetPack 6",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "87"
"CMAKE_CUDA_ARCHITECTURES": "87",
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
}
},
{
@@ -68,12 +73,16 @@
"inherits": [ "ROCm" ],
"cacheVariables": {
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-"
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
"OLLAMA_RUNNER_DIR": "rocm"
}
},
{
"name": "Vulkan",
"inherits": [ "Default" ]
"inherits": [ "Default" ],
"cacheVariables": {
"OLLAMA_RUNNER_DIR": "vulkan"
}
}
],
"buildPresets": [

View File

@@ -58,7 +58,7 @@ RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
ENV PATH=/usr/local/cuda-11/bin:$PATH
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 11' -DOLLAMA_RUNNER_DIR="cuda_v11" \
cmake --preset 'CUDA 11' \
&& cmake --build --parallel ${PARALLEL} --preset 'CUDA 11' \
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
@@ -68,7 +68,7 @@ RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
ENV PATH=/usr/local/cuda-12/bin:$PATH
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 12' -DOLLAMA_RUNNER_DIR="cuda_v12"\
cmake --preset 'CUDA 12' \
&& cmake --build --parallel ${PARALLEL} --preset 'CUDA 12' \
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
@@ -79,7 +79,7 @@ RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
ENV PATH=/usr/local/cuda-13/bin:$PATH
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 13' -DOLLAMA_RUNNER_DIR="cuda_v13" \
cmake --preset 'CUDA 13' \
&& cmake --build --parallel ${PARALLEL} --preset 'CUDA 13' \
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
@@ -88,7 +88,7 @@ FROM base AS rocm-6
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'ROCm 6' -DOLLAMA_RUNNER_DIR="rocm" \
cmake --preset 'ROCm 6' \
&& cmake --build --parallel ${PARALLEL} --preset 'ROCm 6' \
&& cmake --install build --component HIP --strip --parallel ${PARALLEL}
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
@@ -101,7 +101,7 @@ COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'JetPack 5' -DOLLAMA_RUNNER_DIR="cuda_jetpack5" \
cmake --preset 'JetPack 5' \
&& cmake --build --parallel ${PARALLEL} --preset 'JetPack 5' \
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
@@ -113,13 +113,13 @@ COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
ARG PARALLEL
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'JetPack 6' -DOLLAMA_RUNNER_DIR="cuda_jetpack6" \
cmake --preset 'JetPack 6' \
&& cmake --build --parallel ${PARALLEL} --preset 'JetPack 6' \
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
FROM base AS vulkan
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'Vulkan' -DOLLAMA_RUNNER_DIR="vulkan" \
cmake --preset 'Vulkan' \
&& cmake --build --parallel --preset 'Vulkan' \
&& cmake --install build --component Vulkan --strip --parallel 8

10
app/.gitignore vendored
View File

@@ -1 +1,11 @@
ollama.syso
*.crt
*.exe
/app/app
/app/squirrel
ollama
*cover*
.vscode
.env
.DS_Store
.claude

View File

@@ -1,22 +1,107 @@
# Ollama App
# Ollama for macOS and Windows
## Linux
## Download
TODO
- [macOS](https://github.com/ollama/app/releases/download/latest/Ollama.dmg)
- [Windows](https://github.com/ollama/app/releases/download/latest/OllamaSetup.exe)
## MacOS
## Development
TODO
### Desktop App
## Windows
```bash
go generate ./... &&
go run ./cmd/app
```
### UI Development
#### Setup
Install required tools:
```bash
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
```
#### Develop UI (Development Mode)
1. Start the React development server (with hot-reload):
```bash
cd ui/app
npm install
npm run dev
```
2. In a separate terminal, run the Ollama app with the `-dev` flag:
```bash
go generate ./... &&
OLLAMA_DEBUG=1 go run ./cmd/app -dev
```
The `-dev` flag enables:
- Loading the UI from the Vite dev server at http://localhost:5173
- Fixed UI server port at http://127.0.0.1:3001 for API requests
- CORS headers for cross-origin requests
- Hot-reload support for UI development
#### Run Storybook
Inside the `ui/app` directory, run:
```bash
npm run storybook
```
For now we're writing stories as siblings of the component they're testing. So for example, `src/components/Message.stories.tsx` is the story for `src/components/Message.tsx`.
## Build
### Windows
If you want to build the installer, youll need to install
- https://jrsoftware.org/isinfo.php
In the top directory of this repo, run the following powershell script
to build the ollama CLI, ollama app, and ollama installer.
**Dependencies** - either build a local copy of ollama, or use a github release
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\build_windows.ps1
# Local dependencies
.\scripts\deps_local.ps1
# Release dependencies
.\scripts\deps_release.ps1 0.6.8
```
**Build**
```powershell
.\scripts\build_windows.ps1
```
### macOS
CI builds with Xcode 14.1 for OS compatibility prior to v13. If you want to manually build v11+ support, you can download the older Xcode [here](https://developer.apple.com/services-account/download?path=/Developer_Tools/Xcode_14.1/Xcode_14.1.xip), extract, then `mv ./Xcode.app /Applications/Xcode_14.1.0.app` then activate with:
```
export CGO_CFLAGS=-mmacosx-version-min=12.0
export CGO_CXXFLAGS=-mmacosx-version-min=12.0
export CGO_LDFLAGS=-mmacosx-version-min=12.0
export SDKROOT=/Applications/Xcode_14.1.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
export DEVELOPER_DIR=/Applications/Xcode_14.1.0.app/Contents/Developer
```
**Dependencies** - either build a local copy of Ollama, or use a GitHub release:
```sh
# Local dependencies
./scripts/deps_local.sh
# Release dependencies
./scripts/deps_release.sh 0.6.8
```
**Build**
```sh
./scripts/build_darwin.sh
```

View File

@@ -1,3 +1,5 @@
//go:build windows || darwin
package assets
import (

BIN
app/assets/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 116 KiB

26
app/auth/connect.go Normal file
View File

@@ -0,0 +1,26 @@
//go:build windows || darwin
package auth
import (
"encoding/base64"
"fmt"
"net/url"
"os"
"github.com/ollama/ollama/auth"
)
// BuildConnectURL generates the connect URL with the public key and device name
func BuildConnectURL(baseURL string) (string, error) {
pubKey, err := auth.GetPublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(pubKey))
hostname, _ := os.Hostname()
encodedDevice := url.QueryEscape(hostname)
return fmt.Sprintf("%s/connect?name=%s&key=%s&launch=true", baseURL, encodedDevice, encodedKey), nil
}

View File

@@ -0,0 +1,7 @@
#import <Cocoa/Cocoa.h>
@interface AppDelegate : NSObject <NSApplicationDelegate>
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
@end

478
app/cmd/app/app.go Normal file
View File

@@ -0,0 +1,478 @@
//go:build windows || darwin
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"github.com/ollama/ollama/app/auth"
"github.com/ollama/ollama/app/logrotate"
"github.com/ollama/ollama/app/server"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/tools"
"github.com/ollama/ollama/app/ui"
"github.com/ollama/ollama/app/updater"
"github.com/ollama/ollama/app/version"
)
var (
wv = &Webview{}
uiServerPort int
)
var debug = strings.EqualFold(os.Getenv("OLLAMA_DEBUG"), "true") || os.Getenv("OLLAMA_DEBUG") == "1"
var (
fastStartup = false
devMode = false
)
type appMove int
const (
CannotMove appMove = iota
UserDeclinedMove
MoveCompleted
AlreadyMoved
LoginSession
PermissionDenied
MoveError
)
func main() {
startHidden := false
var urlSchemeRequest string
if len(os.Args) > 1 {
for _, arg := range os.Args {
// Handle URL scheme requests (Windows)
if strings.HasPrefix(arg, "ollama://") {
urlSchemeRequest = arg
slog.Info("received URL scheme request", "url", arg)
continue
}
switch arg {
case "serve":
fmt.Fprintln(os.Stderr, "serve command not supported, use ollama")
os.Exit(1)
case "version", "-v", "--version":
fmt.Println(version.Version)
os.Exit(0)
case "background":
// When running the process in this "background" mode, we spawn a
// child process for the main app. This is necessary so the
// "Allow in the Background" setting in MacOS can be unchecked
// without breaking the main app. Two copies of the app are
// present in the bundle, one for the main app and one for the
// background initiator.
fmt.Fprintln(os.Stdout, "starting in background")
runInBackground()
os.Exit(0)
case "hidden", "-j", "--hide":
// startHidden suppresses the UI on startup, and can be triggered multiple ways
// On windows, path based via login startup detection
// On MacOS via [NSApp isHidden] from `open -j -a /Applications/Ollama.app` or equivalent
// On both via the "hidden" command line argument
startHidden = true
case "--fast-startup":
// Skip optional steps like pending updates to start quickly for immediate use
fastStartup = true
case "-dev", "--dev":
// Development mode: use local dev server and enable CORS
devMode = true
}
}
}
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
logrotate.Rotate(appLogPath)
if _, err := os.Stat(filepath.Dir(appLogPath)); errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(appLogPath), 0o755); err != nil {
slog.Error(fmt.Sprintf("failed to create server log dir %v", err))
return
}
}
var logFile io.Writer
var err error
logFile, err = os.OpenFile(appLogPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o755)
if err != nil {
slog.Error(fmt.Sprintf("failed to create server log %v", err))
return
}
// Detect if we're a GUI app on windows, and if not, send logs to console as well
if os.Stderr.Fd() != 0 {
// Console app detected
logFile = io.MultiWriter(os.Stderr, logFile)
}
handler := slog.NewTextHandler(logFile, &slog.HandlerOptions{
Level: level,
AddSource: true,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.SourceKey {
source := attr.Value.Any().(*slog.Source)
source.File = filepath.Base(source.File)
}
return attr
},
})
slog.SetDefault(slog.New(handler))
logStartup()
// On Windows, check if another instance is running and send URL to it
// Do this after logging is set up so we can debug issues
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
if checkAndHandleExistingInstance(urlSchemeRequest) {
// The function will exit if it successfully sends to another instance
// If we reach here, we're the first/only instance
} else {
// No existing instance found, handle the URL scheme in this instance
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
}
}
if u := os.Getenv("OLLAMA_UPDATE_URL"); u != "" {
updater.UpdateCheckURLBase = u
}
// Detect if this is a first start after an upgrade, in
// which case we need to do some cleanup
var skipMove bool
if _, err := os.Stat(updater.UpgradeMarkerFile); err == nil {
slog.Debug("first start after upgrade")
err = updater.DoPostUpgradeCleanup()
if err != nil {
slog.Error("failed to cleanup prior version", "error", err)
}
// We never prompt to move the app after an upgrade
skipMove = true
// Start hidden after updates to prevent UI from opening automatically
startHidden = true
}
if !skipMove && !fastStartup {
if maybeMoveAndRestart() == MoveCompleted {
return
}
}
// Check if another instance is already running
// On Windows, focus the existing instance; on other platforms, kill it
handleExistingInstance(startHidden)
// on macOS, offer the user to create a symlink
// from /usr/local/bin/ollama to the app bundle
installSymlink()
var ln net.Listener
if devMode {
// Use a fixed port in dev mode for predictable API access
ln, err = net.Listen("tcp", "127.0.0.1:3001")
} else {
ln, err = net.Listen("tcp", "127.0.0.1:0")
}
if err != nil {
slog.Error("failed to find available port", "error", err)
return
}
port := ln.Addr().(*net.TCPAddr).Port
token := uuid.NewString()
wv.port = port
wv.token = token
uiServerPort = port
st := &store.Store{}
// Enable CORS in development mode
if devMode {
os.Setenv("OLLAMA_CORS", "1")
// Check if Vite dev server is running on port 5173
var conn net.Conn
var err error
for _, addr := range []string{"127.0.0.1:5173", "localhost:5173"} {
conn, err = net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
conn.Close()
break
}
}
if err != nil {
slog.Error("Vite dev server not running on port 5173")
fmt.Fprintln(os.Stderr, "Error: Vite dev server is not running on port 5173")
fmt.Fprintln(os.Stderr, "Please run 'npm run dev' in the ui/app directory to start the UI in development mode")
os.Exit(1)
}
}
// Initialize tools registry
toolRegistry := tools.NewRegistry()
slog.Info("initialized tools registry", "tool_count", len(toolRegistry.List()))
// ctx is the app-level context that will be used to stop the app
ctx, cancel := context.WithCancel(context.Background())
// octx is the ollama server context that will be used to stop the ollama server
octx, ocancel := context.WithCancel(ctx)
// TODO (jmorganca): instead we should instantiate the
// webview with the store instead of assigning it here, however
// making the webview a global variable is easier for now
wv.Store = st
done := make(chan error, 1)
osrv := server.New(st, devMode)
go func() {
slog.Info("starting ollama server")
done <- osrv.Run(octx)
}()
uiServer := ui.Server{
Token: token,
Restart: func() {
ocancel()
<-done
octx, ocancel = context.WithCancel(ctx)
go func() {
done <- osrv.Run(octx)
}()
},
Store: st,
ToolRegistry: toolRegistry,
Dev: devMode,
Logger: slog.Default(),
}
srv := &http.Server{
Handler: uiServer.Handler(),
}
if _, err := uiServer.UserData(ctx); err != nil {
slog.Warn("failed to load user data", "error", err)
}
// Start the UI server
slog.Info("starting ui server", "port", port)
go func() {
slog.Debug("starting ui server on port", "port", port)
err = srv.Serve(ln)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Warn("desktop server", "error", err)
}
slog.Debug("background desktop server done")
}()
updater := &updater.Updater{Store: st}
updater.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
hasCompletedFirstRun, err := st.HasCompletedFirstRun()
if err != nil {
slog.Error("failed to load has completed first run", "error", err)
}
if !hasCompletedFirstRun {
err = st.SetHasCompletedFirstRun(true)
if err != nil {
slog.Error("failed to set has completed first run", "error", err)
}
}
// capture SIGINT and SIGTERM signals and gracefully shutdown the app
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-signals
slog.Info("received SIGINT or SIGTERM signal, shutting down")
quit()
}()
if urlSchemeRequest != "" {
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
} else {
slog.Debug("no URL scheme request to handle")
}
osRun(cancel, hasCompletedFirstRun, startHidden)
slog.Info("shutting down desktop server")
if err := srv.Close(); err != nil {
slog.Warn("error shutting down desktop server", "error", err)
}
slog.Info("shutting down ollama server")
cancel()
<-done
}
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
// slow the user down with an upgrade.
if updater.IsUpdatePending() {
if fastStartup {
// CLI triggered app startup use-case
slog.Info("deferring pending update for fast startup")
} else {
if err := updater.DoUpgradeAtStartup(); err != nil {
slog.Info("unable to perform upgrade at startup", "error", err)
// Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization
UpdateAvailable("")
} else {
slog.Debug("launching new version...")
// TODO - consider a timer that aborts if this takes too long and we haven't been killed yet...
LaunchNewApp()
os.Exit(0)
}
}
}
}
func checkUserLoggedIn(uiServerPort int) bool {
if uiServerPort == 0 {
slog.Debug("UI server not ready yet, skipping auth check")
return false
}
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/me", uiServerPort))
if err != nil {
slog.Debug("failed to call local auth endpoint", "error", err)
return false
}
defer resp.Body.Close()
// Check if the response is successful
if resp.StatusCode != http.StatusOK {
slog.Debug("auth endpoint returned non-OK status", "status", resp.StatusCode)
return false
}
var user struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
slog.Debug("failed to parse user response", "error", err)
return false
}
// Verify we have a valid user with an ID and name
if user.ID == "" || user.Name == "" {
slog.Debug("user response missing required fields", "id", user.ID, "name", user.Name)
return false
}
slog.Debug("user is logged in", "user_id", user.ID, "user_name", user.Name)
return true
}
// handleConnectURLScheme fetches the connect URL and opens it in the browser
func handleConnectURLScheme() {
if checkUserLoggedIn(uiServerPort) {
slog.Info("user is already logged in, opening settings instead")
sendUIRequestMessage("/")
return
}
connectURL, err := auth.BuildConnectURL("https://ollama.com")
if err != nil {
slog.Error("failed to build connect URL", "error", err)
openInBrowser("https://ollama.com/connect")
return
}
openInBrowser(connectURL)
}
// openInBrowser opens the specified URL in the default browser
func openInBrowser(url string) {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
case "darwin":
cmd = "open"
args = []string{url}
default: // "linux", "freebsd", "openbsd", "netbsd"... should not reach here
slog.Warn("unsupported OS for openInBrowser", "os", runtime.GOOS)
}
slog.Info("executing browser command", "cmd", cmd, "args", args)
if err := exec.Command(cmd, args...).Start(); err != nil {
slog.Error("failed to open URL in browser", "url", url, "cmd", cmd, "args", args, "error", err)
}
}
// parseURLScheme parses an ollama:// URL and returns whether it's a connect URL and the UI path
func parseURLScheme(urlSchemeRequest string) (isConnect bool, uiPath string, err error) {
parsedURL, err := url.Parse(urlSchemeRequest)
if err != nil {
return false, "", err
}
// Check if this is a connect URL
if parsedURL.Host == "connect" || strings.TrimPrefix(parsedURL.Path, "/") == "connect" {
return true, "", nil
}
// Extract the UI path
path := "/"
if parsedURL.Path != "" && parsedURL.Path != "/" {
// For URLs like ollama:///settings, use the path directly
path = parsedURL.Path
} else if parsedURL.Host != "" {
// For URLs like ollama://settings (without triple slash),
// the "settings" part is parsed as the host, not the path.
// We need to convert it to a path by prepending "/"
// This also handles ollama://settings/ where Windows adds a trailing slash
path = "/" + parsedURL.Host
}
return false, path, nil
}
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
isConnect, uiPath, err := parseURLScheme(urlSchemeRequest)
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
sendUIRequestMessage(uiPath)
}
}

269
app/cmd/app/app_darwin.go Normal file
View File

@@ -0,0 +1,269 @@
//go:build windows || darwin
package main
// #cgo CFLAGS: -x objective-c
// #cgo LDFLAGS: -framework Webkit -framework Cocoa -framework LocalAuthentication -framework ServiceManagement
// #include "app_darwin.h"
// #include "../../updater/updater_darwin.h"
// typedef const char cchar_t;
import "C"
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unsafe"
"github.com/ollama/ollama/app/updater"
"github.com/ollama/ollama/app/version"
)
var ollamaPath = func() string {
if updater.BundlePath != "" {
return filepath.Join(updater.BundlePath, "Contents", "Resources", "ollama")
}
pwd, err := os.Getwd()
if err != nil {
slog.Warn("failed to get pwd", "error", err)
return ""
}
return filepath.Join(pwd, "ollama")
}()
var (
isApp = updater.BundlePath != ""
appLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "app.log")
launchAgentPath = filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "com.ollama.ollama.plist")
)
// TODO(jmorganca): pre-create the window and pass
// it to the webview instead of using the internal one
//
//export StartUI
func StartUI(path *C.cchar_t) {
p := C.GoString(path)
wv.Run(p)
styleWindow(wv.webview.Window())
C.setWindowDelegate(wv.webview.Window())
}
//export ShowUI
func ShowUI() {
// If webview is already running, just show the window
if wv.IsRunning() && wv.webview != nil {
showWindow(wv.webview.Window())
} else {
root := C.CString("/")
defer C.free(unsafe.Pointer(root))
StartUI(root)
}
}
//export StopUI
func StopUI() {
wv.Terminate()
}
//export StartUpdate
func StartUpdate() {
if err := updater.DoUpgrade(true); err != nil {
slog.Error("upgrade failed", "error", err)
return
}
slog.Debug("launching new version...")
// TODO - consider a timer that aborts if this takes too long and we haven't been killed yet...
LaunchNewApp()
// not reached if upgrade works, the new app will kill this process
}
//export darwinStartHiddenTasks
func darwinStartHiddenTasks() {
startHiddenTasks()
}
func init() {
// Temporary code to mimic Squirrel ShipIt behavior
if len(os.Args) > 2 {
if os.Args[1] == "___launch___" {
path := strings.TrimPrefix(os.Args[2], "file://")
slog.Info("Ollama binary called as ShipIt - launching", "app", path)
appName := C.CString(path)
defer C.free(unsafe.Pointer(appName))
C.launchApp(appName)
slog.Info("other instance has been launched")
time.Sleep(5 * time.Second)
slog.Info("exiting with zero status")
os.Exit(0)
}
}
}
// maybeMoveAndRestart checks if we should relocate
// and returns true if we did and should immediately exit
func maybeMoveAndRestart() appMove {
if updater.BundlePath == "" {
// Typically developer mode with 'go run ./cmd/app'
return CannotMove
}
// Respect users intent if they chose "keep" vs. "replace" when dragging to Applications
if strings.HasPrefix(updater.BundlePath, strings.TrimSuffix(updater.SystemWidePath, filepath.Ext(updater.SystemWidePath))) {
return AlreadyMoved
}
// Ask to move to applications directory
status := (appMove)(C.askToMoveToApplications())
if status == MoveCompleted {
// Double check
if _, err := os.Stat(updater.SystemWidePath); err != nil {
slog.Warn("stat failure after move", "path", updater.SystemWidePath, "error", err)
return MoveError
}
}
return status
}
// handleExistingInstance handles existing instances on macOS
func handleExistingInstance(_ bool) {
C.killOtherInstances()
}
func installSymlink() {
if !isApp {
return
}
cliPath := C.CString(ollamaPath)
defer C.free(unsafe.Pointer(cliPath))
// Check the users path first
cmd, _ := exec.LookPath("ollama")
if cmd != "" {
resolved, err := os.Readlink(cmd)
if err == nil {
tmp, err := filepath.Abs(resolved)
if err == nil {
resolved = tmp
}
} else {
resolved = cmd
}
if resolved == ollamaPath {
slog.Info("ollama already in users PATH", "cli", cmd)
return
}
}
code := C.installSymlink(cliPath)
if code != 0 {
slog.Error("Failed to install symlink")
}
}
func UpdateAvailable(ver string) error {
slog.Debug("update detected, adjusting menu")
// TODO (jmorganca): find a better check for development mode than checking the bundle path
if updater.BundlePath != "" {
C.updateAvailable()
}
return nil
}
func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
registerLaunchAgent(hasCompletedFirstRun)
// Run the native macOS app
// Note: this will block until the app is closed
slog.Debug("starting native darwin event loop")
C.run(C._Bool(hasCompletedFirstRun), C._Bool(startHidden))
}
func quit() {
C.quit()
}
func LaunchNewApp() {
appName := C.CString(updater.BundlePath)
defer C.free(unsafe.Pointer(appName))
C.launchApp(appName)
}
// Send a request to the main app thread to load a UI page
func sendUIRequestMessage(path string) {
p := C.CString(path)
defer C.free(unsafe.Pointer(p))
C.uiRequest(p)
}
func registerLaunchAgent(hasCompletedFirstRun bool) {
// Remove any stale Login Item registrations
C.unregisterSelfFromLoginItem()
C.registerSelfAsLoginItem(C._Bool(hasCompletedFirstRun))
}
func logStartup() {
appPath := updater.BundlePath
if appPath == updater.SystemWidePath {
// Detect sandboxed scenario
exe, err := os.Executable()
if err == nil {
p := filepath.Dir(exe)
if filepath.Base(p) == "MacOS" {
p = filepath.Dir(filepath.Dir(p))
if p != appPath {
slog.Info("starting sandboxed Ollama", "app", appPath, "sandbox", p)
return
}
}
}
}
slog.Info("starting Ollama", "app", appPath, "version", version.Version, "OS", updater.UserAgentOS)
}
func hideWindow(ptr unsafe.Pointer) {
C.hideWindow(C.uintptr_t(uintptr(ptr)))
}
func showWindow(ptr unsafe.Pointer) {
C.showWindow(C.uintptr_t(uintptr(ptr)))
}
func styleWindow(ptr unsafe.Pointer) {
C.styleWindow(C.uintptr_t(uintptr(ptr)))
}
func runInBackground() {
cmd := exec.Command(filepath.Join(updater.BundlePath, "Contents", "MacOS", "Ollama"), "hidden")
if cmd != nil {
err := cmd.Run()
if err != nil {
slog.Error("failed to run Ollama", "bundlePath", updater.BundlePath, "error", err)
os.Exit(1)
}
} else {
slog.Error("failed to start Ollama in background", "bundlePath", updater.BundlePath)
os.Exit(1)
}
}
func drag(ptr unsafe.Pointer) {
C.drag(C.uintptr_t(uintptr(ptr)))
}
func doubleClick(ptr unsafe.Pointer) {
C.doubleClick(C.uintptr_t(uintptr(ptr)))
}
//export handleConnectURL
func handleConnectURL() {
handleConnectURLScheme()
}
// checkAndHandleExistingInstance is not needed on non-Windows platforms
func checkAndHandleExistingInstance(_ string) bool {
return false
}

43
app/cmd/app/app_darwin.h Normal file
View File

@@ -0,0 +1,43 @@
#import <Cocoa/Cocoa.h>
#import <Security/Security.h>
@interface AppDelegate : NSObject <NSApplicationDelegate>
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
@end
enum AppMove
{
CannotMove,
UserDeclinedMove,
MoveCompleted,
AlreadyMoved,
LoginSession,
PermissionDenied,
MoveError,
};
void run(bool firstTimeRun, bool startHidden);
void killOtherInstances();
enum AppMove askToMoveToApplications();
int createSymlinkWithAuthorization();
int installSymlink(const char *cliPath);
extern void Restart();
// extern void Quit();
void StartUI(const char *path);
void ShowUI();
void StopUI();
void StartUpdate();
void darwinStartHiddenTasks();
void launchApp(const char *appPath);
void updateAvailable();
void quit();
void uiRequest(char *path);
void registerSelfAsLoginItem(bool firstTimeRun);
void unregisterSelfFromLoginItem();
void setWindowDelegate(void *window);
void showWindow(uintptr_t wndPtr);
void hideWindow(uintptr_t wndPtr);
void styleWindow(uintptr_t wndPtr);
void drag(uintptr_t wndPtr);
void doubleClick(uintptr_t wndPtr);
void handleConnectURL();

1125
app/cmd/app/app_darwin.m Normal file

File diff suppressed because it is too large Load Diff

439
app/cmd/app/app_windows.go Normal file
View File

@@ -0,0 +1,439 @@
//go:build windows || darwin
package main
import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"unsafe"
"github.com/ollama/ollama/app/updater"
"github.com/ollama/ollama/app/version"
"github.com/ollama/ollama/app/wintray"
"golang.org/x/sys/windows"
)
var (
u32 = windows.NewLazySystemDLL("User32.dll")
pBringWindowToTop = u32.NewProc("BringWindowToTop")
pShowWindow = u32.NewProc("ShowWindow")
pSendMessage = u32.NewProc("SendMessageA")
pGetSystemMetrics = u32.NewProc("GetSystemMetrics")
pGetWindowRect = u32.NewProc("GetWindowRect")
pSetWindowPos = u32.NewProc("SetWindowPos")
pSetForegroundWindow = u32.NewProc("SetForegroundWindow")
pSetActiveWindow = u32.NewProc("SetActiveWindow")
pIsIconic = u32.NewProc("IsIconic")
appPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Ollama")
appLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "app.log")
startupShortcut = filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "Ollama.lnk")
ollamaPath string
DesktopAppName = "ollama app.exe"
)
func init() {
// With alternate install location use executable location
exe, err := os.Executable()
if err != nil {
slog.Warn("error discovering executable directory", "error", err)
} else {
appPath = filepath.Dir(exe)
}
ollamaPath = filepath.Join(appPath, "ollama.exe")
// Handle developer mode (go run ./cmd/app)
if _, err := os.Stat(ollamaPath); err != nil {
pwd, err := os.Getwd()
if err != nil {
slog.Warn("missing ollama.exe and failed to get pwd", "error", err)
return
}
distAppPath := filepath.Join(pwd, "dist", "windows-"+runtime.GOARCH)
distOllamaPath := filepath.Join(distAppPath, "ollama.exe")
if _, err := os.Stat(distOllamaPath); err == nil {
slog.Info("detected developer mode")
appPath = distAppPath
ollamaPath = distOllamaPath
}
}
}
func maybeMoveAndRestart() appMove {
return 0
}
// handleExistingInstance checks for existing instances and optionally focuses them
func handleExistingInstance(startHidden bool) {
if wintray.CheckAndFocusExistingInstance(!startHidden) {
slog.Info("existing instance found, exiting")
os.Exit(0)
}
}
func installSymlink() {}
type appCallbacks struct {
t wintray.TrayCallbacks
shutdown func()
}
var app = &appCallbacks{}
func (ac *appCallbacks) UIRun(path string) {
wv.Run(path)
}
func (*appCallbacks) UIShow() {
if wv.webview != nil {
showWindow(wv.webview.Window())
} else {
wv.Run("/")
}
}
func (*appCallbacks) UITerminate() {
wv.Terminate()
}
func (*appCallbacks) UIRunning() bool {
return wv.IsRunning()
}
func (app *appCallbacks) Quit() {
app.t.Quit()
wv.Terminate()
}
// TODO - reconcile with above for consistency between mac/windows
func quit() {
wv.Terminate()
}
func (app *appCallbacks) DoUpdate() {
// Safeguard in case we have requests in flight that need to drain...
slog.Info("Waiting for server to shutdown")
app.shutdown()
if err := updater.DoUpgrade(true); err != nil {
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
}
}
// HandleURLScheme implements the URLSchemeHandler interface
func (app *appCallbacks) HandleURLScheme(urlScheme string) {
handleURLSchemeRequest(urlScheme)
}
// handleURLSchemeRequest processes URL scheme requests from other instances
func handleURLSchemeRequest(urlScheme string) {
isConnect, uiPath, err := parseURLScheme(urlScheme)
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
sendUIRequestMessage(uiPath)
}
}
func UpdateAvailable(ver string) error {
return app.t.UpdateAvailable(ver)
}
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
var err error
app.shutdown = shutdown
app.t, err = wintray.NewTray(app)
if err != nil {
log.Fatalf("Failed to start: %s", err)
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
// TODO - can this be generalized?
go func() {
<-signals
slog.Debug("shutting down due to signal")
app.t.Quit()
wv.Terminate()
}()
// On windows, we run the final tasks in the main thread
// before starting the tray event loop. These final tasks
// may trigger the UI, and must do that from the main thread.
if !startHidden {
// Determine if the process was started from a shortcut
// ~\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\Ollama
const STARTF_TITLEISLINKNAME = 0x00000800
var info windows.StartupInfo
if err := windows.GetStartupInfo(&info); err != nil {
slog.Debug("unable to retrieve startup info", "error", err)
} else if info.Flags&STARTF_TITLEISLINKNAME == STARTF_TITLEISLINKNAME {
linkPath := windows.UTF16PtrToString(info.Title)
if strings.Contains(linkPath, "Startup") {
startHidden = true
}
}
}
if startHidden {
startHiddenTasks()
} else {
ptr := wv.Run("/")
// Set the window icon using the tray icon
if ptr != nil {
iconHandle := app.t.GetIconHandle()
if iconHandle != 0 {
hwnd := uintptr(ptr)
const ICON_SMALL = 0
const ICON_BIG = 1
const WM_SETICON = 0x0080
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
}
}
centerWindow(ptr)
}
if !hasCompletedFirstRun {
// Only create the login shortcut on first start
// so we can respect users deletion of the link
err = createLoginShortcut()
if err != nil {
slog.Warn("unable to create login shortcut", "error", err)
}
}
app.t.TrayRun() // This will block the main thread
}
func createLoginShortcut() error {
// The installer lays down a shortcut for us so we can copy it without
// having to resort to calling COM APIs to establish the shortcut
shortcutOrigin := filepath.Join(appPath, "lib", "Ollama.lnk")
_, err := os.Stat(startupShortcut)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
in, err := os.Open(shortcutOrigin)
if err != nil {
return fmt.Errorf("unable to open shortcut %s : %w", shortcutOrigin, err)
}
defer in.Close()
out, err := os.Create(startupShortcut)
if err != nil {
return fmt.Errorf("unable to open startup link %s : %w", startupShortcut, err)
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return fmt.Errorf("unable to copy shortcut %s : %w", startupShortcut, err)
}
err = out.Sync()
if err != nil {
return fmt.Errorf("unable to sync shortcut %s : %w", startupShortcut, err)
}
slog.Info("Created Startup shortcut", "shortcut", startupShortcut)
} else {
slog.Warn("unexpected error looking up Startup shortcut", "error", err)
}
} else {
slog.Debug("Startup link already exists", "shortcut", startupShortcut)
}
return nil
}
// Send a request to the main app thread to load a UI page
func sendUIRequestMessage(path string) {
wintray.SendUIRequestMessage(path)
}
func LaunchNewApp() {
}
func logStartup() {
slog.Info("starting Ollama", "app", appPath, "version", version.Version, "OS", updater.UserAgentOS)
}
const (
SW_HIDE = 0 // Hides the window
SW_SHOW = 5 // Shows window in its current size/position
SW_SHOWNA = 8 // Shows without activating
SW_MINIMIZE = 6 // Minimizes the window
SW_RESTORE = 9 // Restores to previous size/position
SW_SHOWDEFAULT = 10 // Sets show state based on program state
SM_CXSCREEN = 0
SM_CYSCREEN = 1
HWND_TOP = 0
SWP_NOSIZE = 0x0001
SWP_NOMOVE = 0x0002
SWP_NOZORDER = 0x0004
SWP_SHOWWINDOW = 0x0040
// Menu constants
MF_STRING = 0x00000000
MF_SEPARATOR = 0x00000800
MF_GRAYED = 0x00000001
TPM_RETURNCMD = 0x0100
)
// POINT structure for cursor position
type POINT struct {
X int32
Y int32
}
// Rect structure for GetWindowRect
type Rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
func centerWindow(ptr unsafe.Pointer) {
hwnd := uintptr(ptr)
if hwnd == 0 {
return
}
var rect Rect
pGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
screenWidth, _, _ := pGetSystemMetrics.Call(uintptr(SM_CXSCREEN))
screenHeight, _, _ := pGetSystemMetrics.Call(uintptr(SM_CYSCREEN))
windowWidth := rect.Right - rect.Left
windowHeight := rect.Bottom - rect.Top
x := (int32(screenWidth) - windowWidth) / 2
y := (int32(screenHeight) - windowHeight) / 2
// Ensure the window is not positioned off-screen
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
pSetWindowPos.Call(
hwnd,
uintptr(HWND_TOP),
uintptr(x),
uintptr(y),
uintptr(windowWidth), // Keep original width
uintptr(windowHeight), // Keep original height
uintptr(SWP_SHOWWINDOW),
)
}
func showWindow(ptr unsafe.Pointer) {
hwnd := uintptr(ptr)
if hwnd != 0 {
iconHandle := app.t.GetIconHandle()
if iconHandle != 0 {
const ICON_SMALL = 0
const ICON_BIG = 1
const WM_SETICON = 0x0080
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
}
// Check if window is minimized
isMinimized, _, _ := pIsIconic.Call(hwnd)
if isMinimized != 0 {
// Restore the window if it's minimized
pShowWindow.Call(hwnd, uintptr(SW_RESTORE))
}
// Show the window
pShowWindow.Call(hwnd, uintptr(SW_SHOW))
// Bring window to top
pBringWindowToTop.Call(hwnd)
// Force window to foreground
pSetForegroundWindow.Call(hwnd)
// Make it the active window
pSetActiveWindow.Call(hwnd)
// Ensure window is positioned on top
pSetWindowPos.Call(
hwnd,
uintptr(HWND_TOP),
0, 0, 0, 0,
uintptr(SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW),
)
}
}
// HideWindow hides the application window
func hideWindow(ptr unsafe.Pointer) {
hwnd := uintptr(ptr)
if hwnd != 0 {
pShowWindow.Call(
hwnd,
uintptr(SW_HIDE),
)
}
}
func runInBackground() {
exe, err := os.Executable()
if err != nil {
slog.Error("failed to get executable path", "error", err)
os.Exit(1)
}
cmd := exec.Command(exe, "hidden")
if cmd != nil {
err = cmd.Run()
if err != nil {
slog.Error("failed to run Ollama", "exe", exe, "error", err)
os.Exit(1)
}
} else {
slog.Error("failed to start Ollama", "exe", exe)
os.Exit(1)
}
}
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 {
if urlSchemeRequest == "" {
return false
}
// Try to send URL to existing instance using wintray messaging
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
os.Exit(0)
return true
}
// No existing instance, we'll handle it ourselves
return false
}

27
app/cmd/app/menu.h Normal file
View File

@@ -0,0 +1,27 @@
#ifndef MENU_H
#define MENU_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct
{
char *label;
int enabled;
int separator;
} menuItem;
// TODO (jmorganca): these need to be forward declared in the webview.h file
// for now but ideally they should be in this header file on windows too
#ifndef WIN32
int menu_get_item_count();
void *menu_get_items();
void menu_handle_selection(char *item);
#endif
#ifdef __cplusplus
}
#endif
#endif

528
app/cmd/app/webview.go Normal file
View File

@@ -0,0 +1,528 @@
//go:build windows || darwin
package main
// #include "menu.h"
import "C"
import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"github.com/ollama/ollama/app/dialog"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/webview"
)
type Webview struct {
port int
token string
webview webview.WebView
mutex sync.Mutex
Store *store.Store
}
// Run initializes the webview and starts its event loop.
// Note: this must be called from the primary app thread
// This returns the OS native window handle to the caller
func (w *Webview) Run(path string) unsafe.Pointer {
var url string
if devMode {
// In development mode, use the local dev server
url = fmt.Sprintf("http://localhost:5173%s", path)
} else {
url = fmt.Sprintf("http://127.0.0.1:%d%s", w.port, path)
}
w.mutex.Lock()
defer w.mutex.Unlock()
if w.webview == nil {
// Note: turning on debug on macos throws errors but is marginally functional for debugging
// TODO (jmorganca): we should pre-create the window and then provide it here to
// webview so we can hide it from the start and make other modifications
wv := webview.New(debug)
// start the window hidden
hideWindow(wv.Window())
wv.SetTitle("Ollama")
// TODO (jmorganca): this isn't working yet since it needs to be set
// on the first page load, ideally in an interstitial page like `/token`
// that exists only to set the cookie and redirect to /
// wv.Init(fmt.Sprintf(`document.cookie = "token=%s; path=/"`, w.token))
init := `
// Disable reload
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
return false;
}
});
// Prevent back/forward navigation
window.addEventListener('popstate', function(e) {
e.preventDefault();
history.pushState(null, '', window.location.pathname);
return false;
});
// Clear history on load
window.addEventListener('load', function() {
history.pushState(null, '', window.location.pathname);
window.history.replaceState(null, '', window.location.pathname);
});
// Set token cookie
document.cookie = "token=` + w.token + `; path=/";
`
// Windows-specific scrollbar styling
if runtime.GOOS == "windows" {
init += `
// Fix scrollbar styling for Edge WebView2 on Windows only
function updateScrollbarStyles() {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const existingStyle = document.getElementById('scrollbar-style');
if (existingStyle) existingStyle.remove();
const style = document.createElement('style');
style.id = 'scrollbar-style';
if (isDark) {
style.textContent = ` + "`" + `
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
::-webkit-scrollbar-track { background: #1a1a1a !important; }
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
::-webkit-scrollbar-button {
background: transparent !important;
border: none !important;
width: 0px !important;
height: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:vertical:end:increment {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background: transparent !important;
width: 0px !important;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background: transparent !important;
width: 0px !important;
}
` + "`" + `;
} else {
style.textContent = ` + "`" + `
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
::-webkit-scrollbar-button {
background: transparent !important;
border: none !important;
width: 0px !important;
height: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:vertical:end:increment {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background: transparent !important;
width: 0px !important;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background: transparent !important;
width: 0px !important;
}
` + "`" + `;
}
document.head.appendChild(style);
}
window.addEventListener('load', updateScrollbarStyles);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
`
}
// on windows make ctrl+n open new chat
// TODO (jmorganca): later we should use proper accelerators
// once we introduce a native menu for the window
// this is only used on windows since macOS uses the proper accelerators
if runtime.GOOS == "windows" {
init += `
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault();
// Use the existing navigation method
history.pushState({}, '', '/c/new');
window.dispatchEvent(new PopStateEvent('popstate'));
return false;
}
});
`
}
init += `
window.OLLAMA_WEBSEARCH = true;
`
wv.Init(init)
// Add keyboard handler for zoom
wv.Init(`
window.addEventListener('keydown', function(e) {
// CMD/Ctrl + Plus/Equals (zoom in)
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
e.preventDefault();
window.zoomIn && window.zoomIn();
return false;
}
// CMD/Ctrl + Minus (zoom out)
if ((e.metaKey || e.ctrlKey) && e.key === '-') {
e.preventDefault();
window.zoomOut && window.zoomOut();
return false;
}
// CMD/Ctrl + 0 (reset zoom)
if ((e.metaKey || e.ctrlKey) && e.key === '0') {
e.preventDefault();
window.zoomReset && window.zoomReset();
return false;
}
}, true);
`)
wv.Bind("zoomIn", func() {
current := wv.GetZoom()
wv.SetZoom(current + 0.1)
})
wv.Bind("zoomOut", func() {
current := wv.GetZoom()
wv.SetZoom(current - 0.1)
})
wv.Bind("zoomReset", func() {
wv.SetZoom(1.0)
})
wv.Bind("ready", func() {
showWindow(wv.Window())
})
wv.Bind("close", func() {
hideWindow(wv.Window())
})
// Webviews do not allow access to the file system by default, so we need to
// bind file system operations here
wv.Bind("selectModelsDirectory", func() {
go func() {
// Helper function to call the JavaScript callback with data or null
callCallback := func(data interface{}) {
dataJSON, _ := json.Marshal(data)
wv.Dispatch(func() {
wv.Eval(fmt.Sprintf("window.__selectModelsDirectoryCallback && window.__selectModelsDirectoryCallback(%s)", dataJSON))
})
}
directory, err := dialog.Directory().Title("Select Model Directory").ShowHidden(true).Browse()
if err != nil {
slog.Debug("Directory selection cancelled or failed", "error", err)
callCallback(nil)
return
}
slog.Debug("Directory selected", "path", directory)
callCallback(directory)
}()
})
// Bind selectFiles function for selecting multiple files at once
wv.Bind("selectFiles", func() {
go func() {
// Helper function to call the JavaScript callback with data or null
callCallback := func(data interface{}) {
dataJSON, _ := json.Marshal(data)
wv.Dispatch(func() {
wv.Eval(fmt.Sprintf("window.__selectFilesCallback && window.__selectFilesCallback(%s)", dataJSON))
})
}
// Define allowed extensions for native dialog filtering
textExts := []string{
"pdf", "docx", "txt", "md", "csv", "json", "xml", "html", "htm",
"js", "jsx", "ts", "tsx", "py", "java", "cpp", "c", "cc", "h", "cs", "php", "rb",
"go", "rs", "swift", "kt", "scala", "sh", "bat", "yaml", "yml", "toml", "ini",
"cfg", "conf", "log", "rtf",
}
imageExts := []string{"png", "jpg", "jpeg"}
allowedExts := append(textExts, imageExts...)
// Use native multiple file selection with extension filtering
filenames, err := dialog.File().
Filter("Supported Files", allowedExts...).
Title("Select Files").
LoadMultiple()
if err != nil {
slog.Debug("Multiple file selection cancelled or failed", "error", err)
callCallback(nil)
return
}
if len(filenames) == 0 {
callCallback(nil)
return
}
var files []map[string]string
maxFileSize := int64(10 * 1024 * 1024) // 10MB
for _, filename := range filenames {
// Check file extension (double-check after native dialog filtering)
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
validExt := false
for _, allowedExt := range allowedExts {
if ext == allowedExt {
validExt = true
break
}
}
if !validExt {
slog.Warn("file extension not allowed, skipping", "filename", filepath.Base(filename), "extension", ext)
continue
}
// Check file size before reading (pre-filter large files)
fileStat, err := os.Stat(filename)
if err != nil {
slog.Error("failed to get file info", "error", err, "filename", filename)
continue
}
if fileStat.Size() > maxFileSize {
slog.Warn("file too large, skipping", "filename", filepath.Base(filename), "size", fileStat.Size())
continue
}
fileBytes, err := os.ReadFile(filename)
if err != nil {
slog.Error("failed to read file", "error", err, "filename", filename)
continue
}
mimeType := http.DetectContentType(fileBytes)
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileBytes))
fileResult := map[string]string{
"filename": filepath.Base(filename),
"path": filename,
"dataURL": dataURL,
}
files = append(files, fileResult)
}
if len(files) == 0 {
callCallback(nil)
} else {
callCallback(files)
}
}()
})
wv.Bind("drag", func() {
wv.Dispatch(func() {
drag(wv.Window())
})
})
wv.Bind("doubleClick", func() {
wv.Dispatch(func() {
doubleClick(wv.Window())
})
})
// Add binding for working directory selection
wv.Bind("selectWorkingDirectory", func() {
go func() {
// Helper function to call the JavaScript callback with data or null
callCallback := func(data interface{}) {
dataJSON, _ := json.Marshal(data)
wv.Dispatch(func() {
wv.Eval(fmt.Sprintf("window.__selectWorkingDirectoryCallback && window.__selectWorkingDirectoryCallback(%s)", dataJSON))
})
}
directory, err := dialog.Directory().Title("Select Working Directory").ShowHidden(true).Browse()
if err != nil {
slog.Debug("Directory selection cancelled or failed", "error", err)
callCallback(nil)
return
}
slog.Debug("Directory selected", "path", directory)
callCallback(directory)
}()
})
wv.Bind("setContextMenuItems", func(items []map[string]interface{}) error {
menuMutex.Lock()
defer menuMutex.Unlock()
if len(menuItems) > 0 {
pinner.Unpin()
}
menuItems = nil
for _, item := range items {
menuItem := C.menuItem{
label: C.CString(item["label"].(string)),
enabled: 0,
separator: 0,
}
if item["enabled"] != nil {
menuItem.enabled = 1
}
if item["separator"] != nil {
menuItem.separator = 1
}
menuItems = append(menuItems, menuItem)
}
return nil
})
// Debounce resize events
var resizeTimer *time.Timer
var resizeMutex sync.Mutex
wv.Bind("resize", func(width, height int) {
if w.Store != nil {
resizeMutex.Lock()
if resizeTimer != nil {
resizeTimer.Stop()
}
resizeTimer = time.AfterFunc(100*time.Millisecond, func() {
err := w.Store.SetWindowSize(width, height)
if err != nil {
slog.Error("failed to set window size", "error", err)
}
})
resizeMutex.Unlock()
}
})
// On Darwin, we can't have 2 threads both running global event loops
// but on Windows, the event loops are tied to the window, so we're
// able to run in both the tray and webview
if runtime.GOOS != "darwin" {
slog.Debug("starting webview event loop")
go func() {
wv.Run()
slog.Debug("webview event loop exited")
}()
}
if w.Store != nil {
width, height, 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)
}
}
wv.SetSize(800, 600, webview.HintMin)
w.webview = wv
w.webview.Navigate(url)
} else {
w.webview.Eval(fmt.Sprintf(`
history.pushState({}, '', '%s');
`, path))
showWindow(w.webview.Window())
}
return w.webview.Window()
}
func (w *Webview) Terminate() {
w.mutex.Lock()
if w.webview == nil {
w.mutex.Unlock()
return
}
wv := w.webview
w.webview = nil
w.mutex.Unlock()
wv.Terminate()
wv.Destroy()
}
func (w *Webview) IsRunning() bool {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.webview != nil
}
var (
menuItems []C.menuItem
menuMutex sync.RWMutex
pinner runtime.Pinner
)
//export menu_get_item_count
func menu_get_item_count() C.int {
menuMutex.RLock()
defer menuMutex.RUnlock()
return C.int(len(menuItems))
}
//export menu_get_items
func menu_get_items() unsafe.Pointer {
menuMutex.RLock()
defer menuMutex.RUnlock()
if len(menuItems) == 0 {
return nil
}
// Return pointer to the slice data
pinner.Pin(&menuItems[0])
return unsafe.Pointer(&menuItems[0])
}
//export menu_handle_selection
func menu_handle_selection(item *C.char) {
wv.webview.Eval(fmt.Sprintf("window.handleContextMenuResult('%s')", C.GoString(item)))
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>Squirrel</string>
<key>CFBundleIconFile</key>
<string/>
<key>CFBundleIdentifier</key>
<string>com.github.Squirrel</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Squirrel</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTSDKBuild</key>
<string>22E245</string>
<key>DTSDKName</key>
<string>macosx13.3</string>
<key>DTXcode</key>
<string>1431</string>
<key>DTXcodeBuild</key>
<string>14E300c</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2013 GitHub. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string/>
</dict>
</plist>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Ollama</string>
<key>CFBundleExecutable</key>
<string>Ollama</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>com.electron.ollama</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Ollama</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.0</string>
<key>CFBundleVersion</key>
<string>0.0.0</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTSDKBuild</key>
<string>22E245</string>
<key>DTSDKName</key>
<string>macosx14.0</string>
<key>DTXcode</key>
<string>1431</string>
<key>DTXcodeBuild</key>
<string>14E300c</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>Ollama URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ollama</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ollama.ollama</string>
<key>BundleProgram</key>
<string>Contents/Frameworks/Squirrel.framework/Versions/A/Squirrel</string>
<key>ProgramArguments</key>
<array>
<string>Contents/Frameworks/Squirrel.framework/Versions/A/Squirrel</string>
<string>background</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>POSIXSpawnType</key>
<string>Interactive</string>
<key>LSUIElement</key>
<true/>
<key>LSBackgroundOnly</key>
<false/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

15
app/dialog/LICENSE Normal file
View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2018, the dialog authors.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

43
app/dialog/cocoa/dlg.h Normal file
View File

@@ -0,0 +1,43 @@
#include <objc/NSObjCRuntime.h>
typedef enum {
MSG_YESNO,
MSG_ERROR,
MSG_INFO,
} AlertStyle;
typedef struct {
char* msg;
char* title;
AlertStyle style;
} AlertDlgParams;
#define LOADDLG 0
#define SAVEDLG 1
#define DIRDLG 2 // browse for directory
typedef struct {
int mode; /* which dialog style to invoke (see earlier defines) */
char* buf; /* buffer to store selected file */
int nbuf; /* number of bytes allocated at buf */
char* title; /* title for dialog box (can be nil) */
void** exts; /* list of valid extensions (elements actual type is NSString*) */
int numext; /* number of items in exts */
int relaxext; /* allow other extensions? */
char* startDir; /* directory to start in (can be nil) */
char* filename; /* default filename for dialog box (can be nil) */
int showHidden; /* show hidden files? */
int allowMultiple; /* allow multiple file selection? */
} FileDlgParams;
typedef enum {
DLG_OK,
DLG_CANCEL,
DLG_URLFAIL,
} DlgResult;
DlgResult alertDlg(AlertDlgParams*);
DlgResult fileDlg(FileDlgParams*);
void* NSStr(void* buf, int len);
void NSRelease(void* obj);

195
app/dialog/cocoa/dlg.m Normal file
View File

@@ -0,0 +1,195 @@
#import <Cocoa/Cocoa.h>
#include "dlg.h"
#include <string.h>
#include <sys/syslimits.h>
void* NSStr(void* buf, int len) {
return (void*)[[NSString alloc] initWithBytes:buf length:len encoding:NSUTF8StringEncoding];
}
void checkActivationPolicy() {
NSApplicationActivationPolicy policy = [NSApp activationPolicy];
// prohibited NSApp will not show the panel at all.
// It probably means that this is not run in a GUI app, that would set the policy on its own,
// but in a terminal app - setting it to accessory will allow dialogs to show
if (policy == NSApplicationActivationPolicyProhibited) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
}
}
void NSRelease(void* obj) {
[(NSObject*)obj release];
}
@interface AlertDlg : NSObject {
AlertDlgParams* params;
DlgResult result;
}
+ (AlertDlg*)init:(AlertDlgParams*)params;
- (DlgResult)run;
@end
DlgResult alertDlg(AlertDlgParams* params) {
return [[AlertDlg init:params] run];
}
@implementation AlertDlg
+ (AlertDlg*)init:(AlertDlgParams*)params {
AlertDlg* d = [AlertDlg alloc];
d->params = params;
return d;
}
- (DlgResult)run {
if(![NSThread isMainThread]) {
[self performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:YES];
return self->result;
}
NSAlert* alert = [[NSAlert alloc] init];
if(self->params->title != nil) {
[[alert window] setTitle:[[NSString alloc] initWithUTF8String:self->params->title]];
}
[alert setMessageText:[[NSString alloc] initWithUTF8String:self->params->msg]];
switch (self->params->style) {
case MSG_YESNO:
[alert addButtonWithTitle:@"Yes"];
[alert addButtonWithTitle:@"No"];
break;
case MSG_ERROR:
[alert setIcon:[NSImage imageNamed:NSImageNameCaution]];
[alert addButtonWithTitle:@"OK"];
break;
case MSG_INFO:
[alert setIcon:[NSImage imageNamed:NSImageNameInfo]];
[alert addButtonWithTitle:@"OK"];
break;
}
checkActivationPolicy();
self->result = [alert runModal] == NSAlertFirstButtonReturn ? DLG_OK : DLG_CANCEL;
return self->result;
}
@end
@interface FileDlg : NSObject {
FileDlgParams* params;
DlgResult result;
}
+ (FileDlg*)init:(FileDlgParams*)params;
- (DlgResult)run;
@end
DlgResult fileDlg(FileDlgParams* params) {
return [[FileDlg init:params] run];
}
@implementation FileDlg
+ (FileDlg*)init:(FileDlgParams*)params {
FileDlg* d = [FileDlg alloc];
d->params = params;
return d;
}
- (DlgResult)run {
if(![NSThread isMainThread]) {
[self performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:YES];
} else if(self->params->mode == SAVEDLG) {
self->result = [self save];
} else {
self->result = [self load];
}
return self->result;
}
- (NSInteger)runPanel:(NSSavePanel*)panel {
[panel setFloatingPanel:YES];
[panel setShowsHiddenFiles:self->params->showHidden ? YES : NO];
[panel setCanCreateDirectories:YES];
if(self->params->title != nil) {
[panel setTitle:[[NSString alloc] initWithUTF8String:self->params->title]];
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if(self->params->numext > 0) {
[panel setAllowedFileTypes:[NSArray arrayWithObjects:(NSString**)self->params->exts count:self->params->numext]];
}
#pragma clang diagnostic pop
if(self->params->relaxext) {
[panel setAllowsOtherFileTypes:YES];
}
if(self->params->startDir) {
[panel setDirectoryURL:[NSURL URLWithString:[[NSString alloc] initWithUTF8String:self->params->startDir]]];
}
if(self->params->filename != nil) {
[panel setNameFieldStringValue:[[NSString alloc] initWithUTF8String:self->params->filename]];
}
checkActivationPolicy();
return [panel runModal];
}
- (DlgResult)save {
NSSavePanel* panel = [NSSavePanel savePanel];
if(![self runPanel:panel]) {
return DLG_CANCEL;
} else if(![[panel URL] getFileSystemRepresentation:self->params->buf maxLength:self->params->nbuf]) {
return DLG_URLFAIL;
}
return DLG_OK;
}
- (DlgResult)load {
NSOpenPanel* panel = [NSOpenPanel openPanel];
if(self->params->mode == DIRDLG) {
[panel setCanChooseDirectories:YES];
[panel setCanChooseFiles:NO];
}
if(self->params->allowMultiple) {
[panel setAllowsMultipleSelection:YES];
}
if(![self runPanel:panel]) {
return DLG_CANCEL;
}
NSArray* urls = [panel URLs];
if(self->params->allowMultiple && [urls count] >= 1) {
// For multiple files, we need to return all paths separated by null bytes
char* bufPtr = self->params->buf;
int remainingBuf = self->params->nbuf;
// Calculate total required buffer size first
int totalSize = 0;
for(NSURL* url in urls) {
char tempBuf[PATH_MAX];
if(![url getFileSystemRepresentation:tempBuf maxLength:PATH_MAX]) {
return DLG_URLFAIL;
}
totalSize += strlen(tempBuf) + 1; // +1 for null terminator
}
totalSize += 1; // Final null terminator
if(totalSize > self->params->nbuf) {
// Not enough buffer space
return DLG_URLFAIL;
}
// Now actually copy the paths (we know we have space)
bufPtr = self->params->buf;
for(NSURL* url in urls) {
char tempBuf[PATH_MAX];
[url getFileSystemRepresentation:tempBuf maxLength:PATH_MAX];
int pathLen = strlen(tempBuf);
strcpy(bufPtr, tempBuf);
bufPtr += pathLen + 1;
}
*bufPtr = '\0'; // Final null terminator
}
return DLG_OK;
}
@end

View File

@@ -0,0 +1,183 @@
package cocoa
// #cgo darwin LDFLAGS: -framework Cocoa
// #include <stdlib.h>
// #include <sys/syslimits.h>
// #include "dlg.h"
import "C"
import (
"bytes"
"errors"
"unsafe"
)
type AlertParams struct {
p C.AlertDlgParams
}
func mkAlertParams(msg, title string, style C.AlertStyle) *AlertParams {
a := AlertParams{C.AlertDlgParams{msg: C.CString(msg), style: style}}
if title != "" {
a.p.title = C.CString(title)
}
return &a
}
func (a *AlertParams) run() C.DlgResult {
return C.alertDlg(&a.p)
}
func (a *AlertParams) free() {
C.free(unsafe.Pointer(a.p.msg))
if a.p.title != nil {
C.free(unsafe.Pointer(a.p.title))
}
}
func nsStr(s string) unsafe.Pointer {
return C.NSStr(unsafe.Pointer(&[]byte(s)[0]), C.int(len(s)))
}
func YesNoDlg(msg, title string) bool {
a := mkAlertParams(msg, title, C.MSG_YESNO)
defer a.free()
return a.run() == C.DLG_OK
}
func InfoDlg(msg, title string) {
a := mkAlertParams(msg, title, C.MSG_INFO)
defer a.free()
a.run()
}
func ErrorDlg(msg, title string) {
a := mkAlertParams(msg, title, C.MSG_ERROR)
defer a.free()
a.run()
}
const (
BUFSIZE = C.PATH_MAX
MULTI_FILE_BUF_SIZE = 32768
)
// MultiFileDlg opens a file dialog that allows multiple file selection
func MultiFileDlg(title string, exts []string, relaxExt bool, startDir string, showHidden bool) ([]string, error) {
return fileDlgWithOptions(C.LOADDLG, title, exts, relaxExt, startDir, "", showHidden, true)
}
// FileDlg opens a file dialog for single file selection (kept for compatibility)
func FileDlg(save bool, title string, exts []string, relaxExt bool, startDir string, filename string, showHidden bool) (string, error) {
mode := C.LOADDLG
if save {
mode = C.SAVEDLG
}
files, err := fileDlgWithOptions(mode, title, exts, relaxExt, startDir, filename, showHidden, false)
if err != nil {
return "", err
}
if len(files) == 0 {
return "", nil
}
return files[0], nil
}
func DirDlg(title string, startDir string, showHidden bool) (string, error) {
files, err := fileDlgWithOptions(C.DIRDLG, title, nil, false, startDir, "", showHidden, false)
if err != nil {
return "", err
}
if len(files) == 0 {
return "", nil
}
return files[0], nil
}
// fileDlgWithOptions is the unified file dialog function that handles both single and multiple selection
func fileDlgWithOptions(mode int, title string, exts []string, relaxExt bool, startDir, filename string, showHidden, allowMultiple bool) ([]string, error) {
// Use larger buffer for multiple files, smaller for single
bufSize := BUFSIZE
if allowMultiple {
bufSize = MULTI_FILE_BUF_SIZE
}
p := C.FileDlgParams{
mode: C.int(mode),
nbuf: C.int(bufSize),
}
if allowMultiple {
p.allowMultiple = C.int(1) // Enable multiple selection //nolint:structcheck
}
if showHidden {
p.showHidden = 1
}
p.buf = (*C.char)(C.malloc(C.size_t(bufSize)))
defer C.free(unsafe.Pointer(p.buf))
buf := (*(*[MULTI_FILE_BUF_SIZE]byte)(unsafe.Pointer(p.buf)))[:bufSize]
if title != "" {
p.title = C.CString(title)
defer C.free(unsafe.Pointer(p.title))
}
if startDir != "" {
p.startDir = C.CString(startDir)
defer C.free(unsafe.Pointer(p.startDir))
}
if filename != "" {
p.filename = C.CString(filename)
defer C.free(unsafe.Pointer(p.filename))
}
if len(exts) > 0 {
if len(exts) > 999 {
panic("more than 999 extensions not supported")
}
ptrSize := int(unsafe.Sizeof(&title))
p.exts = (*unsafe.Pointer)(C.malloc(C.size_t(ptrSize * len(exts))))
defer C.free(unsafe.Pointer(p.exts))
cext := (*(*[999]unsafe.Pointer)(unsafe.Pointer(p.exts)))[:]
for i, ext := range exts {
cext[i] = nsStr(ext)
defer C.NSRelease(cext[i])
}
p.numext = C.int(len(exts))
if relaxExt {
p.relaxext = 1
}
}
// Execute dialog and parse results
switch C.fileDlg(&p) {
case C.DLG_OK:
if allowMultiple {
// Parse multiple null-terminated strings from buffer
var files []string
start := 0
for i := range len(buf) - 1 {
if buf[i] == 0 {
if i > start {
files = append(files, string(buf[start:i]))
}
start = i + 1
// Check for double null (end of list)
if i+1 < len(buf) && buf[i+1] == 0 {
break
}
}
}
return files, nil
} else {
// Single file - return as array for consistency
filename := string(buf[:bytes.Index(buf, []byte{0})])
return []string{filename}, nil
}
case C.DLG_CANCEL:
return nil, nil
case C.DLG_URLFAIL:
return nil, errors.New("failed to get file-system representation for selected URL")
}
panic("unhandled case")
}

182
app/dialog/dlgs.go Normal file
View File

@@ -0,0 +1,182 @@
//go:build windows || darwin
// Package dialog provides a simple cross-platform common dialog API.
// Eg. to prompt the user with a yes/no dialog:
//
// if dialog.MsgDlg("%s", "Do you want to continue?").YesNo() {
// // user pressed Yes
// }
//
// The general usage pattern is to call one of the toplevel *Dlg functions
// which return a *Builder structure. From here you can optionally call
// configuration functions (eg. Title) to customise the dialog, before
// using a launcher function to run the dialog.
package dialog
import (
"errors"
"fmt"
)
// ErrCancelled is an error returned when a user cancels/closes a dialog.
var ErrCancelled = errors.New("Cancelled")
// Cancelled refers to ErrCancelled.
// Deprecated: Use ErrCancelled instead.
var Cancelled = ErrCancelled
// Dlg is the common type for dialogs.
type Dlg struct {
Title string
}
// MsgBuilder is used for creating message boxes.
type MsgBuilder struct {
Dlg
Msg string
}
// Message initialises a MsgBuilder with the provided message.
func Message(format string, args ...interface{}) *MsgBuilder {
return &MsgBuilder{Msg: fmt.Sprintf(format, args...)}
}
// Title specifies what the title of the message dialog will be.
func (b *MsgBuilder) Title(title string) *MsgBuilder {
b.Dlg.Title = title
return b
}
// YesNo spawns the message dialog with two buttons, "Yes" and "No".
// Returns true iff the user selected "Yes".
func (b *MsgBuilder) YesNo() bool {
return b.yesNo()
}
// Info spawns the message dialog with an information icon and single button, "Ok".
func (b *MsgBuilder) Info() {
b.info()
}
// Error spawns the message dialog with an error icon and single button, "Ok".
func (b *MsgBuilder) Error() {
b.error()
}
// FileFilter represents a category of files (eg. audio files, spreadsheets).
type FileFilter struct {
Desc string
Extensions []string
}
// FileBuilder is used for creating file browsing dialogs.
type FileBuilder struct {
Dlg
StartDir string
StartFile string
Filters []FileFilter
ShowHiddenFiles bool
}
// File initialises a FileBuilder using the default configuration.
func File() *FileBuilder {
return &FileBuilder{}
}
// Title specifies the title to be used for the dialog.
func (b *FileBuilder) Title(title string) *FileBuilder {
b.Dlg.Title = title
return b
}
// Filter adds a category of files to the types allowed by the dialog. Multiple
// calls to Filter are cumulative - any of the provided categories will be allowed.
// By default all files can be selected.
//
// The special extension '*' allows all files to be selected when the Filter is active.
func (b *FileBuilder) Filter(desc string, extensions ...string) *FileBuilder {
filt := FileFilter{desc, extensions}
if len(filt.Extensions) == 0 {
filt.Extensions = append(filt.Extensions, "*")
}
b.Filters = append(b.Filters, filt)
return b
}
// SetStartDir specifies the initial directory of the dialog.
func (b *FileBuilder) SetStartDir(startDir string) *FileBuilder {
b.StartDir = startDir
return b
}
// SetStartFile specifies the initial file name of the dialog.
func (b *FileBuilder) SetStartFile(startFile string) *FileBuilder {
b.StartFile = startFile
return b
}
// ShowHiddenFiles sets whether hidden files should be visible in the dialog.
func (b *FileBuilder) ShowHidden(show bool) *FileBuilder {
b.ShowHiddenFiles = show
return b
}
// Load spawns the file selection dialog using the configured settings,
// asking the user to select a single file. Returns ErrCancelled as the error
// if the user cancels or closes the dialog.
func (b *FileBuilder) Load() (string, error) {
return b.load()
}
// LoadMultiple spawns the file selection dialog using the configured settings,
// asking the user to select multiple files. Returns ErrCancelled as the error
// if the user cancels or closes the dialog.
func (b *FileBuilder) LoadMultiple() ([]string, error) {
return b.loadMultiple()
}
// Save spawns the file selection dialog using the configured settings,
// asking the user for a filename to save as. If the chosen file exists, the
// user is prompted whether they want to overwrite the file. Returns
// ErrCancelled as the error if the user cancels/closes the dialog, or selects
// not to overwrite the file.
func (b *FileBuilder) Save() (string, error) {
return b.save()
}
// DirectoryBuilder is used for directory browse dialogs.
type DirectoryBuilder struct {
Dlg
StartDir string
ShowHiddenFiles bool
}
// Directory initialises a DirectoryBuilder using the default configuration.
func Directory() *DirectoryBuilder {
return &DirectoryBuilder{}
}
// Browse spawns the directory selection dialog using the configured settings,
// asking the user to select a single folder. Returns ErrCancelled as the error
// if the user cancels or closes the dialog.
func (b *DirectoryBuilder) Browse() (string, error) {
return b.browse()
}
// Title specifies the title to be used for the dialog.
func (b *DirectoryBuilder) Title(title string) *DirectoryBuilder {
b.Dlg.Title = title
return b
}
// StartDir specifies the initial directory to be used for the dialog.
func (b *DirectoryBuilder) SetStartDir(dir string) *DirectoryBuilder {
b.StartDir = dir
return b
}
// ShowHiddenFiles sets whether hidden files should be visible in the dialog.
func (b *DirectoryBuilder) ShowHidden(show bool) *DirectoryBuilder {
b.ShowHiddenFiles = show
return b
}

82
app/dialog/dlgs_darwin.go Normal file
View File

@@ -0,0 +1,82 @@
package dialog
import (
"github.com/ollama/ollama/app/dialog/cocoa"
)
func (b *MsgBuilder) yesNo() bool {
return cocoa.YesNoDlg(b.Msg, b.Dlg.Title)
}
func (b *MsgBuilder) info() {
cocoa.InfoDlg(b.Msg, b.Dlg.Title)
}
func (b *MsgBuilder) error() {
cocoa.ErrorDlg(b.Msg, b.Dlg.Title)
}
func (b *FileBuilder) load() (string, error) {
return b.run(false)
}
func (b *FileBuilder) loadMultiple() ([]string, error) {
return b.runMultiple()
}
func (b *FileBuilder) save() (string, error) {
return b.run(true)
}
func (b *FileBuilder) run(save bool) (string, error) {
star := false
var exts []string
for _, filt := range b.Filters {
for _, ext := range filt.Extensions {
if ext == "*" {
star = true
} else {
exts = append(exts, ext)
}
}
}
if star && save {
/* OSX doesn't allow the user to switch visible file types/extensions. Also
** NSSavePanel's allowsOtherFileTypes property has no effect for an open
** dialog, so if "*" is a possible extension we must always show all files. */
exts = nil
}
f, err := cocoa.FileDlg(save, b.Dlg.Title, exts, star, b.StartDir, b.StartFile, b.ShowHiddenFiles)
if f == "" && err == nil {
return "", ErrCancelled
}
return f, err
}
func (b *FileBuilder) runMultiple() ([]string, error) {
star := false
var exts []string
for _, filt := range b.Filters {
for _, ext := range filt.Extensions {
if ext == "*" {
star = true
} else {
exts = append(exts, ext)
}
}
}
files, err := cocoa.MultiFileDlg(b.Dlg.Title, exts, star, b.StartDir, b.ShowHiddenFiles)
if len(files) == 0 && err == nil {
return nil, ErrCancelled
}
return files, err
}
func (b *DirectoryBuilder) browse() (string, error) {
f, err := cocoa.DirDlg(b.Dlg.Title, b.StartDir, b.ShowHiddenFiles)
if f == "" && err == nil {
return "", ErrCancelled
}
return f, err
}

241
app/dialog/dlgs_windows.go Normal file
View File

@@ -0,0 +1,241 @@
package dialog
import (
"fmt"
"reflect"
"syscall"
"unicode/utf16"
"unsafe"
"github.com/TheTitanrain/w32"
)
const multiFileBufferSize = w32.MAX_PATH * 10
type WinDlgError int
func (e WinDlgError) Error() string {
return fmt.Sprintf("CommDlgExtendedError: %#x", e)
}
func err() error {
e := w32.CommDlgExtendedError()
if e == 0 {
return ErrCancelled
}
return WinDlgError(e)
}
func (b *MsgBuilder) yesNo() bool {
r := w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Confirm?"), w32.MB_YESNO)
return r == w32.IDYES
}
func (b *MsgBuilder) info() {
w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Information"), w32.MB_OK|w32.MB_ICONINFORMATION)
}
func (b *MsgBuilder) error() {
w32.MessageBox(w32.HWND(0), b.Msg, firstOf(b.Dlg.Title, "Error"), w32.MB_OK|w32.MB_ICONERROR)
}
type filedlg struct {
buf []uint16
filters []uint16
opf *w32.OPENFILENAME
}
func (d filedlg) Filename() string {
i := 0
for i < len(d.buf) && d.buf[i] != 0 {
i++
}
return string(utf16.Decode(d.buf[:i]))
}
func (d filedlg) parseMultipleFilenames() []string {
var files []string
i := 0
// Find first null terminator (directory path)
for i < len(d.buf) && d.buf[i] != 0 {
i++
}
if i >= len(d.buf) {
return files
}
// Get directory path
dirPath := string(utf16.Decode(d.buf[:i]))
i++ // Skip null terminator
// Check if there are more files (multiple selection)
if i < len(d.buf) && d.buf[i] != 0 {
// Multiple files selected - parse filenames
for i < len(d.buf) {
start := i
// Find next null terminator
for i < len(d.buf) && d.buf[i] != 0 {
i++
}
if i >= len(d.buf) {
break
}
if start < i {
filename := string(utf16.Decode(d.buf[start:i]))
if dirPath != "" {
files = append(files, dirPath+"\\"+filename)
} else {
files = append(files, filename)
}
}
i++ // Skip null terminator
if i >= len(d.buf) || d.buf[i] == 0 {
break // End of list
}
}
} else {
// Single file selected
files = append(files, dirPath)
}
return files
}
func (b *FileBuilder) load() (string, error) {
d := openfile(w32.OFN_FILEMUSTEXIST|w32.OFN_NOCHANGEDIR, b)
if w32.GetOpenFileName(d.opf) {
return d.Filename(), nil
}
return "", err()
}
func (b *FileBuilder) loadMultiple() ([]string, error) {
d := openfile(w32.OFN_FILEMUSTEXIST|w32.OFN_NOCHANGEDIR|w32.OFN_ALLOWMULTISELECT|w32.OFN_EXPLORER, b)
d.buf = make([]uint16, multiFileBufferSize)
d.opf.File = utf16ptr(d.buf)
d.opf.MaxFile = uint32(len(d.buf))
if w32.GetOpenFileName(d.opf) {
return d.parseMultipleFilenames(), nil
}
return nil, err()
}
func (b *FileBuilder) save() (string, error) {
d := openfile(w32.OFN_OVERWRITEPROMPT|w32.OFN_NOCHANGEDIR, b)
if w32.GetSaveFileName(d.opf) {
return d.Filename(), nil
}
return "", err()
}
/* syscall.UTF16PtrFromString not sufficient because we need to encode embedded NUL bytes */
func utf16ptr(utf16 []uint16) *uint16 {
if utf16[len(utf16)-1] != 0 {
panic("refusing to make ptr to non-NUL terminated utf16 slice")
}
h := (*reflect.SliceHeader)(unsafe.Pointer(&utf16))
return (*uint16)(unsafe.Pointer(h.Data))
}
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
i := 0
for slice[len(slice)-1] != 0 {
i++
}
hdr.Len = i
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
return slice
}
func openfile(flags uint32, b *FileBuilder) (d filedlg) {
d.buf = make([]uint16, w32.MAX_PATH)
if b.StartFile != "" {
initialName, _ := syscall.UTF16FromString(b.StartFile)
for i := 0; i < len(initialName) && i < w32.MAX_PATH; i++ {
d.buf[i] = initialName[i]
}
}
d.opf = &w32.OPENFILENAME{
File: utf16ptr(d.buf),
MaxFile: uint32(len(d.buf)),
Flags: flags,
}
d.opf.StructSize = uint32(unsafe.Sizeof(*d.opf))
if b.StartDir != "" {
d.opf.InitialDir, _ = syscall.UTF16PtrFromString(b.StartDir)
}
if b.Dlg.Title != "" {
d.opf.Title, _ = syscall.UTF16PtrFromString(b.Dlg.Title)
}
for _, filt := range b.Filters {
/* build utf16 string of form "Music File\0*.mp3;*.ogg;*.wav;\0" */
d.filters = append(d.filters, utf16.Encode([]rune(filt.Desc))...)
d.filters = append(d.filters, 0)
for _, ext := range filt.Extensions {
s := fmt.Sprintf("*.%s;", ext)
d.filters = append(d.filters, utf16.Encode([]rune(s))...)
}
d.filters = append(d.filters, 0)
}
if d.filters != nil {
d.filters = append(d.filters, 0, 0) // two extra NUL chars to terminate the list
d.opf.Filter = utf16ptr(d.filters)
}
return d
}
type dirdlg struct {
bi *w32.BROWSEINFO
}
const (
bffm_INITIALIZED = 1
bffm_SELCHANGED = 2
bffm_VALIDATEFAILEDA = 3
bffm_VALIDATEFAILEDW = 4
bffm_SETSTATUSTEXTA = (w32.WM_USER + 100)
bffm_SETSTATUSTEXTW = (w32.WM_USER + 104)
bffm_ENABLEOK = (w32.WM_USER + 101)
bffm_SETSELECTIONA = (w32.WM_USER + 102)
bffm_SETSELECTIONW = (w32.WM_USER + 103)
bffm_SETOKTEXT = (w32.WM_USER + 105)
bffm_SETEXPANDED = (w32.WM_USER + 106)
bffm_SETSTATUSTEXT = bffm_SETSTATUSTEXTW
bffm_SETSELECTION = bffm_SETSELECTIONW
bffm_VALIDATEFAILED = bffm_VALIDATEFAILEDW
)
func callbackDefaultDir(hwnd w32.HWND, msg uint, lParam, lpData uintptr) int {
if msg == bffm_INITIALIZED {
_ = w32.SendMessage(hwnd, bffm_SETSELECTION, w32.TRUE, lpData)
}
return 0
}
func selectdir(b *DirectoryBuilder) (d dirdlg) {
d.bi = &w32.BROWSEINFO{Flags: w32.BIF_RETURNONLYFSDIRS | w32.BIF_NEWDIALOGSTYLE}
if b.Dlg.Title != "" {
d.bi.Title, _ = syscall.UTF16PtrFromString(b.Dlg.Title)
}
if b.StartDir != "" {
s16, _ := syscall.UTF16PtrFromString(b.StartDir)
d.bi.LParam = uintptr(unsafe.Pointer(s16))
d.bi.CallbackFunc = syscall.NewCallback(callbackDefaultDir)
}
return d
}
func (b *DirectoryBuilder) browse() (string, error) {
d := selectdir(b)
res := w32.SHBrowseForFolder(d.bi)
if res == 0 {
return "", ErrCancelled
}
return w32.SHGetPathFromIDList(res), nil
}

12
app/dialog/util.go Normal file
View File

@@ -0,0 +1,12 @@
//go:build windows
package dialog
func firstOf(args ...string) string {
for _, arg := range args {
if arg != "" {
return arg
}
}
return ""
}

30
app/format/field.go Normal file
View File

@@ -0,0 +1,30 @@
//go:build windows || darwin
package format
import (
"strings"
"unicode"
)
// KebabCase converts a string from camelCase or PascalCase to kebab-case.
// (e.g. "camelCase" -> "camel-case")
func KebabCase(str string) string {
var result strings.Builder
for i, char := range str {
if i > 0 {
prevChar := rune(str[i-1])
// Add hyphen before uppercase letters
if unicode.IsUpper(char) &&
(unicode.IsLower(prevChar) || unicode.IsDigit(prevChar) ||
(i < len(str)-1 && unicode.IsLower(rune(str[i+1])))) {
result.WriteRune('-')
}
}
result.WriteRune(unicode.ToLower(char))
}
return result.String()
}

34
app/format/field_test.go Normal file
View File

@@ -0,0 +1,34 @@
//go:build windows || darwin
package format
import "testing"
func TestKebabCase(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"already-kebab-case", "already-kebab-case"},
{"simpleCamelCase", "simple-camel-case"},
{"PascalCase", "pascal-case"},
{"camelCaseWithNumber123", "camel-case-with-number123"},
{"APIResponse", "api-response"},
{"mixedCASE", "mixed-case"},
{"WithACRONYMS", "with-acronyms"},
{"ALLCAPS", "allcaps"},
{"camelCaseWITHMixedACRONYMS", "camel-case-with-mixed-acronyms"},
{"numbers123in456string", "numbers123in456string"},
{"5", "5"},
{"S", "s"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := KebabCase(tt.input)
if result != tt.expected {
t.Errorf("toKebabCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -1,9 +0,0 @@
//go:build !windows
package lifecycle
import "errors"
func GetStarted() error {
return errors.New("not implemented")
}

View File

@@ -1,43 +0,0 @@
package lifecycle
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"syscall"
)
func GetStarted() error {
const CREATE_NEW_CONSOLE = 0x00000010
var err error
bannerScript := filepath.Join(AppDir, "ollama_welcome.ps1")
args := []string{
// TODO once we're signed, the execution policy bypass should be removed
"powershell", "-noexit", "-ExecutionPolicy", "Bypass", "-nologo", "-file", bannerScript,
}
args[0], err = exec.LookPath(args[0])
if err != nil {
return err
}
// Make sure the script actually exists
_, err = os.Stat(bannerScript)
if err != nil {
return fmt.Errorf("getting started banner script error %s", err)
}
slog.Info(fmt.Sprintf("opening getting started terminal with %v", args))
attrs := &os.ProcAttr{
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
Sys: &syscall.SysProcAttr{CreationFlags: CREATE_NEW_CONSOLE, HideWindow: false},
}
proc, err := os.StartProcess(args[0], args, attrs)
if err != nil {
return fmt.Errorf("unable to start getting started shell %w", err)
}
slog.Debug(fmt.Sprintf("getting started terminal PID: %d", proc.Pid))
return proc.Release()
}

View File

@@ -1,94 +0,0 @@
package lifecycle
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/tray"
"github.com/ollama/ollama/envconfig"
)
func Run() {
InitLogging()
slog.Info("app config", "env", envconfig.Values())
ctx, cancel := context.WithCancel(context.Background())
var done chan int
t, err := tray.NewTray()
if err != nil {
log.Fatalf("Failed to start: %s", err)
}
callbacks := t.GetCallbacks()
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
go func() {
slog.Debug("starting callback loop")
for {
select {
case <-callbacks.Quit:
slog.Debug("quit called")
t.Quit()
case <-signals:
slog.Debug("shutting down due to signal")
t.Quit()
case <-callbacks.Update:
err := DoUpgrade(cancel, done)
if err != nil {
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
}
case <-callbacks.ShowLogs:
ShowLogs()
case <-callbacks.DoFirstUse:
err := GetStarted()
if err != nil {
slog.Warn(fmt.Sprintf("Failed to launch getting started shell: %s", err))
}
}
}
}()
// Are we first use?
if !store.GetFirstTimeRun() {
slog.Debug("First time run")
err = t.DisplayFirstUseNotification()
if err != nil {
slog.Debug(fmt.Sprintf("XXX failed to display first use notification %v", err))
}
store.SetFirstTimeRun(true)
} else {
slog.Debug("Not first time, skipping first run notification")
}
if IsServerRunning(ctx) {
slog.Info("Detected another instance of ollama running, exiting")
os.Exit(1)
} else {
done, err = SpawnServer(ctx, CLIName)
if err != nil {
// TODO - should we retry in a backoff loop?
// TODO - should we pop up a warning and maybe add a menu item to view application logs?
slog.Error(fmt.Sprintf("Failed to spawn ollama server %s", err))
done = make(chan int, 1)
done <- 1
}
}
StartBackgroundUpdaterChecker(ctx, t.UpdateAvailable)
t.Run()
cancel()
slog.Info("Waiting for ollama server to shutdown...")
if done != nil {
<-done
}
slog.Info("Ollama app exiting")
}

View File

@@ -1,62 +0,0 @@
package lifecycle
import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/logutil"
)
func InitLogging() {
var logFile *os.File
var err error
// Detect if we're a GUI app on windows, and if not, send logs to console
if os.Stderr.Fd() != 0 {
// Console app detected
logFile = os.Stderr
// TODO - write one-line to the app.log file saying we're running in console mode to help avoid confusion
} else {
rotateLogs(AppLogFile)
logFile, err = os.OpenFile(AppLogFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o755)
if err != nil {
slog.Error(fmt.Sprintf("failed to create server log %v", err))
return
}
}
slog.SetDefault(logutil.NewLogger(logFile, envconfig.LogLevel()))
slog.Info("ollama app started")
}
func rotateLogs(logFile string) {
if _, err := os.Stat(logFile); os.IsNotExist(err) {
return
}
index := strings.LastIndex(logFile, ".")
pre := logFile[:index]
post := "." + logFile[index+1:]
for i := LogRotationCount; i > 0; i-- {
older := pre + "-" + strconv.Itoa(i) + post
newer := pre + "-" + strconv.Itoa(i-1) + post
if i == 1 {
newer = pre + post
}
if _, err := os.Stat(newer); err == nil {
if _, err := os.Stat(older); err == nil {
err := os.Remove(older)
if err != nil {
slog.Warn("Failed to remove older log", "older", older, "error", err)
continue
}
}
err := os.Rename(newer, older)
if err != nil {
slog.Warn("Failed to rotate log", "older", older, "newer", newer, "error", err)
}
}
}
}

View File

@@ -1,9 +0,0 @@
//go:build !windows
package lifecycle
import "log/slog"
func ShowLogs() {
slog.Warn("not implemented")
}

View File

@@ -1,44 +0,0 @@
package lifecycle
import (
"os"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRotateLogs(t *testing.T) {
logDir := t.TempDir()
logFile := filepath.Join(logDir, "testlog.log")
// No log exists
rotateLogs(logFile)
require.NoError(t, os.WriteFile(logFile, []byte("1"), 0o644))
assert.FileExists(t, logFile)
// First rotation
rotateLogs(logFile)
assert.FileExists(t, filepath.Join(logDir, "testlog-1.log"))
assert.NoFileExists(t, filepath.Join(logDir, "testlog-2.log"))
assert.NoFileExists(t, logFile)
// Should be a no-op without a new log
rotateLogs(logFile)
assert.FileExists(t, filepath.Join(logDir, "testlog-1.log"))
assert.NoFileExists(t, filepath.Join(logDir, "testlog-2.log"))
assert.NoFileExists(t, logFile)
for i := 2; i <= LogRotationCount+1; i++ {
require.NoError(t, os.WriteFile(logFile, []byte(strconv.Itoa(i)), 0o644))
assert.FileExists(t, logFile)
rotateLogs(logFile)
assert.NoFileExists(t, logFile)
for j := 1; j < i; j++ {
assert.FileExists(t, filepath.Join(logDir, "testlog-"+strconv.Itoa(j)+".log"))
}
assert.NoFileExists(t, filepath.Join(logDir, "testlog-"+strconv.Itoa(i+1)+".log"))
}
}

View File

@@ -1,19 +0,0 @@
package lifecycle
import (
"fmt"
"log/slog"
"os/exec"
"syscall"
)
func ShowLogs() {
cmd_path := "c:\\Windows\\system32\\cmd.exe"
slog.Debug(fmt.Sprintf("viewing logs with start %s", AppDataDir))
cmd := exec.Command(cmd_path, "/c", "start", AppDataDir)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: false, CreationFlags: 0x08000000}
err := cmd.Start()
if err != nil {
slog.Error(fmt.Sprintf("Failed to open log dir: %s", err))
}
}

View File

@@ -1,84 +0,0 @@
package lifecycle
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
)
var (
AppName = "ollama app"
CLIName = "ollama"
AppDir = "/opt/Ollama"
AppDataDir = "/opt/Ollama"
// TODO - should there be a distinct log dir?
UpdateStageDir = "/tmp"
AppLogFile = "/tmp/ollama_app.log"
ServerLogFile = "/tmp/ollama.log"
UpgradeLogFile = "/tmp/ollama_update.log"
Installer = "OllamaSetup.exe"
LogRotationCount = 5
)
func init() {
if runtime.GOOS == "windows" {
AppName += ".exe"
CLIName += ".exe"
// Logs, configs, downloads go to LOCALAPPDATA
localAppData := os.Getenv("LOCALAPPDATA")
AppDataDir = filepath.Join(localAppData, "Ollama")
UpdateStageDir = filepath.Join(AppDataDir, "updates")
AppLogFile = filepath.Join(AppDataDir, "app.log")
ServerLogFile = filepath.Join(AppDataDir, "server.log")
UpgradeLogFile = filepath.Join(AppDataDir, "upgrade.log")
exe, err := os.Executable()
if err != nil {
slog.Warn("error discovering executable directory", "error", err)
AppDir = filepath.Join(localAppData, "Programs", "Ollama")
} else {
AppDir = filepath.Dir(exe)
}
// Make sure we have PATH set correctly for any spawned children
paths := strings.Split(os.Getenv("PATH"), ";")
// Start with whatever we find in the PATH/LD_LIBRARY_PATH
found := false
for _, path := range paths {
d, err := filepath.Abs(path)
if err != nil {
continue
}
if strings.EqualFold(AppDir, d) {
found = true
}
}
if !found {
paths = append(paths, AppDir)
pathVal := strings.Join(paths, ";")
slog.Debug("setting PATH=" + pathVal)
err := os.Setenv("PATH", pathVal)
if err != nil {
slog.Error(fmt.Sprintf("failed to update PATH: %s", err))
}
}
// Make sure our logging dir exists
_, err = os.Stat(AppDataDir)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(AppDataDir, 0o755); err != nil {
slog.Error(fmt.Sprintf("create ollama dir %s: %v", AppDataDir, err))
}
}
} else if runtime.GOOS == "darwin" {
// TODO
AppName += ".app"
// } else if runtime.GOOS == "linux" {
// TODO
}
}

View File

@@ -1,186 +0,0 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/ollama/ollama/api"
)
func getCLIFullPath(command string) string {
var cmdPath string
appExe, err := os.Executable()
if err == nil {
// Check both the same location as the tray app, as well as ./bin
cmdPath = filepath.Join(filepath.Dir(appExe), command)
_, err := os.Stat(cmdPath)
if err == nil {
return cmdPath
}
cmdPath = filepath.Join(filepath.Dir(appExe), "bin", command)
_, err = os.Stat(cmdPath)
if err == nil {
return cmdPath
}
}
cmdPath, err = exec.LookPath(command)
if err == nil {
_, err := os.Stat(cmdPath)
if err == nil {
return cmdPath
}
}
pwd, err := os.Getwd()
if err == nil {
cmdPath = filepath.Join(pwd, command)
_, err = os.Stat(cmdPath)
if err == nil {
return cmdPath
}
}
return command
}
func start(ctx context.Context, command string) (*exec.Cmd, error) {
cmd := getCmd(ctx, getCLIFullPath(command))
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to spawn server stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to spawn server stderr pipe: %w", err)
}
rotateLogs(ServerLogFile)
logFile, err := os.OpenFile(ServerLogFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o755)
if err != nil {
return nil, fmt.Errorf("failed to create server log: %w", err)
}
logDir := filepath.Dir(ServerLogFile)
_, err = os.Stat(logDir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("stat ollama server log dir %s: %v", logDir, err)
}
if err := os.MkdirAll(logDir, 0o755); err != nil {
return nil, fmt.Errorf("create ollama server log dir %s: %v", logDir, err)
}
}
go func() {
defer logFile.Close()
io.Copy(logFile, stdout) //nolint:errcheck
}()
go func() {
defer logFile.Close()
io.Copy(logFile, stderr) //nolint:errcheck
}()
// Re-wire context done behavior to attempt a graceful shutdown of the server
cmd.Cancel = func() error {
if cmd.Process != nil {
err := terminate(cmd)
if err != nil {
slog.Warn("error trying to gracefully terminate server", "err", err)
return cmd.Process.Kill()
}
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-tick.C:
exited, err := isProcessExited(cmd.Process.Pid)
if err != nil {
return err
}
if exited {
return nil
}
case <-time.After(5 * time.Second):
slog.Warn("graceful server shutdown timeout, killing", "pid", cmd.Process.Pid)
return cmd.Process.Kill()
}
}
}
return nil
}
// run the command and wait for it to finish
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start server %w", err)
}
if cmd.Process != nil {
slog.Info(fmt.Sprintf("started ollama server with pid %d", cmd.Process.Pid))
}
slog.Info(fmt.Sprintf("ollama server logs %s", ServerLogFile))
return cmd, nil
}
func SpawnServer(ctx context.Context, command string) (chan int, error) {
done := make(chan int)
go func() {
// Keep the server running unless we're shuttind down the app
crashCount := 0
for {
slog.Info("starting server...")
cmd, err := start(ctx, command)
if err != nil {
crashCount++
slog.Error(fmt.Sprintf("failed to start server %s", err))
time.Sleep(500 * time.Millisecond * time.Duration(crashCount))
continue
}
cmd.Wait() //nolint:errcheck
var code int
if cmd.ProcessState != nil {
code = cmd.ProcessState.ExitCode()
}
select {
case <-ctx.Done():
slog.Info(fmt.Sprintf("server shutdown with exit code %d", code))
done <- code
return
default:
crashCount++
slog.Warn(fmt.Sprintf("server crash %d - exit code %d - respawning", crashCount, code))
time.Sleep(500 * time.Millisecond * time.Duration(crashCount))
break
}
}
}()
return done, nil
}
func IsServerRunning(ctx context.Context) bool {
client, err := api.ClientFromEnvironment()
if err != nil {
slog.Info("unable to connect to server")
return false
}
err = client.Heartbeat(ctx)
if err != nil {
slog.Debug(fmt.Sprintf("heartbeat from server: %s", err))
slog.Info("unable to connect to server")
return false
}
return true
}

View File

@@ -1,38 +0,0 @@
//go:build !windows
package lifecycle
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"syscall"
)
func getCmd(ctx context.Context, cmd string) *exec.Cmd {
return exec.CommandContext(ctx, cmd, "serve")
}
func terminate(cmd *exec.Cmd) error {
return cmd.Process.Signal(os.Interrupt)
}
func isProcessExited(pid int) (bool, error) {
proc, err := os.FindProcess(pid)
if err != nil {
return false, fmt.Errorf("failed to find process: %v", err)
}
err = proc.Signal(syscall.Signal(0))
if err != nil {
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
return true, nil
}
return false, fmt.Errorf("error signaling process: %v", err)
}
return false, nil
}

View File

@@ -1,91 +0,0 @@
package lifecycle
import (
"context"
"fmt"
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
func getCmd(ctx context.Context, exePath string) *exec.Cmd {
cmd := exec.CommandContext(ctx, exePath, "serve")
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP,
}
return cmd
}
func terminate(cmd *exec.Cmd) error {
dll, err := windows.LoadDLL("kernel32.dll")
if err != nil {
return err
}
//nolint:errcheck
defer dll.Release()
pid := cmd.Process.Pid
f, err := dll.FindProc("AttachConsole")
if err != nil {
return err
}
r1, _, err := f.Call(uintptr(pid))
if r1 == 0 && err != syscall.ERROR_ACCESS_DENIED {
return err
}
f, err = dll.FindProc("SetConsoleCtrlHandler")
if err != nil {
return err
}
r1, _, err = f.Call(0, 1)
if r1 == 0 {
return err
}
f, err = dll.FindProc("GenerateConsoleCtrlEvent")
if err != nil {
return err
}
r1, _, err = f.Call(windows.CTRL_BREAK_EVENT, uintptr(pid))
if r1 == 0 {
return err
}
r1, _, err = f.Call(windows.CTRL_C_EVENT, uintptr(pid))
if r1 == 0 {
return err
}
return nil
}
const STILL_ACTIVE = 259
func isProcessExited(pid int) (bool, error) {
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION, false, uint32(pid))
if err != nil {
return false, fmt.Errorf("failed to open process: %v", err)
}
//nolint:errcheck
defer windows.CloseHandle(hProcess)
var exitCode uint32
err = windows.GetExitCodeProcess(hProcess, &exitCode)
if err != nil {
return false, fmt.Errorf("failed to get exit code: %v", err)
}
if exitCode == STILL_ACTIVE {
return false, nil
}
return true, nil
}

View File

@@ -1,12 +0,0 @@
//go:build !windows
package lifecycle
import (
"context"
"errors"
)
func DoUpgrade(cancel context.CancelFunc, done chan int) error {
return errors.New("not implemented")
}

View File

@@ -1,74 +0,0 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
)
func DoUpgrade(cancel context.CancelFunc, done chan int) error {
files, err := filepath.Glob(filepath.Join(UpdateStageDir, "*", "*.exe")) // TODO generalize for multiplatform
if err != nil {
return fmt.Errorf("failed to lookup downloads: %s", err)
}
if len(files) == 0 {
return errors.New("no update downloads found")
} else if len(files) > 1 {
// Shouldn't happen
slog.Warn(fmt.Sprintf("multiple downloads found, using first one %v", files))
}
installerExe := files[0]
slog.Info("starting upgrade with " + installerExe)
slog.Info("upgrade log file " + UpgradeLogFile)
// make the upgrade show progress, but non interactive
installArgs := []string{
"/CLOSEAPPLICATIONS", // Quit the tray app if it's still running
"/LOG=" + filepath.Base(UpgradeLogFile), // Only relative seems reliable, so set pwd
"/FORCECLOSEAPPLICATIONS", // Force close the tray app - might be needed
"/SP", // Skip the "This will install... Do you wish to continue" prompt
"/NOCANCEL", // Disable the ability to cancel upgrade mid-flight to avoid partially installed upgrades
"/SILENT",
}
// Safeguard in case we have requests in flight that need to drain...
slog.Info("Waiting for server to shutdown")
cancel()
if done != nil {
<-done
} else {
// Shouldn't happen
slog.Warn("done chan was nil, not actually waiting")
}
slog.Debug(fmt.Sprintf("starting installer: %s %v", installerExe, installArgs))
os.Chdir(filepath.Dir(UpgradeLogFile)) //nolint:errcheck
cmd := exec.Command(installerExe, installArgs...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("unable to start ollama app %w", err)
}
if cmd.Process != nil {
err = cmd.Process.Release()
if err != nil {
slog.Error(fmt.Sprintf("failed to release server process: %s", err))
}
} else {
// TODO - some details about why it didn't start, or is this a pedantic error case?
return errors.New("installer process did not start")
}
// TODO should we linger for a moment and check to make sure it's actually running by checking the pid?
slog.Info("Installer started in background, exiting")
os.Exit(0)
// Not reached
return nil
}

View File

@@ -0,0 +1,45 @@
//go:build windows || darwin
// package logrotate provides utilities for rotating logs
// TODO (jmorgan): this most likely doesn't need it's own
// package and can be moved to app where log files are created
package logrotate
import (
"log/slog"
"os"
"strconv"
"strings"
)
const MaxLogFiles = 5
func Rotate(filename string) {
if _, err := os.Stat(filename); os.IsNotExist(err) {
return
}
index := strings.LastIndex(filename, ".")
pre := filename[:index]
post := "." + filename[index+1:]
for i := MaxLogFiles; i > 0; i-- {
older := pre + "-" + strconv.Itoa(i) + post
newer := pre + "-" + strconv.Itoa(i-1) + post
if i == 1 {
newer = pre + post
}
if _, err := os.Stat(newer); err == nil {
if _, err := os.Stat(older); err == nil {
err := os.Remove(older)
if err != nil {
slog.Warn("Failed to remove older log", "older", older, "error", err)
continue
}
}
err := os.Rename(newer, older)
if err != nil {
slog.Warn("Failed to rotate log", "older", older, "newer", newer, "error", err)
}
}
}
}

View File

@@ -0,0 +1,70 @@
//go:build windows || darwin
package logrotate
import (
"os"
"path/filepath"
"strconv"
"testing"
)
func TestRotate(t *testing.T) {
logDir := t.TempDir()
logFile := filepath.Join(logDir, "testlog.log")
// No log exists
Rotate(logFile)
if err := os.WriteFile(logFile, []byte("1"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected log file to exist")
}
// First rotation
Rotate(logFile)
if _, err := os.Stat(filepath.Join(logDir, "testlog-1.log")); os.IsNotExist(err) {
t.Fatal("expected rotated log file to exist")
}
if _, err := os.Stat(filepath.Join(logDir, "testlog-2.log")); !os.IsNotExist(err) {
t.Fatal("expected no second rotated log file")
}
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
t.Fatal("expected original log file to be moved")
}
// Should be a no-op without a new log
Rotate(logFile)
if _, err := os.Stat(filepath.Join(logDir, "testlog-1.log")); os.IsNotExist(err) {
t.Fatal("expected rotated log file to still exist")
}
if _, err := os.Stat(filepath.Join(logDir, "testlog-2.log")); !os.IsNotExist(err) {
t.Fatal("expected no second rotated log file")
}
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
t.Fatal("expected no original log file")
}
for i := 2; i <= MaxLogFiles+1; i++ {
if err := os.WriteFile(logFile, []byte(strconv.Itoa(i)), 0o644); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected log file to exist")
}
Rotate(logFile)
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
t.Fatal("expected log file to be moved")
}
for j := 1; j < i; j++ {
if _, err := os.Stat(filepath.Join(logDir, "testlog-"+strconv.Itoa(j)+".log")); os.IsNotExist(err) {
t.Fatalf("expected rotated log file %d to exist", j)
}
}
if _, err := os.Stat(filepath.Join(logDir, "testlog-"+strconv.Itoa(i+1)+".log")); !os.IsNotExist(err) {
t.Fatalf("expected no rotated log file %d", i+1)
}
}
}

View File

@@ -1,12 +0,0 @@
package main
// Compile with the following to get rid of the cmd pop up on windows
// go build -ldflags="-H windowsgui" .
import (
"github.com/ollama/ollama/app/lifecycle"
)
func main() {
lifecycle.Run()
}

View File

@@ -37,8 +37,10 @@ PrivilegesRequired=lowest
OutputBaseFilename="OllamaSetup"
SetupIconFile={#MyIcon}
UninstallDisplayIcon={uninstallexe}
Compression=lzma2
SolidCompression=no
Compression=lzma2/ultra64
LZMAUseSeparateProcess=yes
LZMANumBlockThreads=8
SolidCompression=yes
WizardStyle=modern
ChangesEnvironment=yes
OutputDir=..\dist\
@@ -46,7 +48,7 @@ OutputDir=..\dist\
; Disable logging once everything's battle tested
; Filename will be %TEMP%\Setup Log*.txt
SetupLogging=yes
CloseApplications=yes
CloseApplications=no
RestartApplications=no
RestartIfNeededByRun=no
@@ -68,7 +70,6 @@ DisableFinishedPage=yes
DisableReadyMemo=yes
DisableReadyPage=yes
DisableStartupPrompt=yes
DisableWelcomePage=yes
; TODO - percentage can't be set less than 100, so how to make it shorter?
; WizardSizePercent=100,80
@@ -87,30 +88,42 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
DialogFontSize=12
[Files]
#if DirExists("..\dist\windows-amd64")
Source: "..\dist\windows-amd64-app.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
#endif
#if DirExists("..\dist\windows-arm64")
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
Source: "..\dist\windows-arm64-app.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: IsArm64(); Flags: ignoreversion 64bit
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit
; For local development, rely on binary compatibility at runtime since we can't cross compile
#if FileExists("..\dist\windows-ollama-app-arm64.exe")
Source: "..\dist\windows-ollama-app-arm64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
#else
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
#endif
#if FileExists("..\dist\windows-arm64\ollama.exe")
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
#endif
Source: "..\dist\ollama_welcome.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
Name: "{app}\lib\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
[InstallDelete]
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
[Run]
#if DirExists("..\dist\windows-arm64")
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
#endif
#if DirExists("..\dist\windows-amd64")
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
#endif
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
[UninstallRun]
@@ -126,13 +139,13 @@ Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
Type: filesandordirs; Name: "{%TEMP}\ollama*"
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Ollama"
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama"
Type: filesandordirs; Name: "{%USERPROFILE}\.ollama\models"
Type: filesandordirs; Name: "{%USERPROFILE}\.ollama\history"
Type: filesandordirs; Name: "{userstartup}\{#MyAppName}.lnk"
; NOTE: if the user has a custom OLLAMA_MODELS it will be preserved
[InstallDelete]
Type: filesandordirs; Name: "{%TEMP}\ollama*"
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama"
Type: filesandordirs; Name: "{app}\lib\ollama"
[Messages]
WizardReady=Ollama
@@ -148,6 +161,10 @@ SetupAppRunningError=Another Ollama installer is running.%n%nPlease cancel or fi
Root: HKCU; Subkey: "Environment"; \
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
Check: NeedsAddPath('{app}')
; Register ollama:// URL protocol
Root: HKCU; Subkey: "Software\Classes\ollama"; ValueType: string; ValueName: ""; ValueData: "URL:Ollama Protocol"; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\ollama"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey
Root: HKCU; Subkey: "Software\Classes\ollama\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey
[Code]
@@ -182,7 +199,11 @@ var
v3: Cardinal;
v4: Cardinal;
begin
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
if (IsArm64()) then begin
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
end else begin
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
end;
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
@@ -202,3 +223,152 @@ begin
else
Result := TRUE;
end;
function GetDirSize(Path: String): Int64;
var
FindRec: TFindRec;
FilePath: string;
Size: Int64;
begin
if FindFirst(Path + '\*', FindRec) then begin
Result := 0;
try
repeat
if (FindRec.Name <> '.') and (FindRec.Name <> '..') then begin
FilePath := Path + '\' + FindRec.Name;
if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0 then begin
Size := GetDirSize(FilePath);
end else begin
Size := Int64(FindRec.SizeHigh) shl 32 + FindRec.SizeLow;
end;
Result := Result + Size;
end;
until not FindNext(FindRec);
finally
FindClose(FindRec);
end;
end else begin
Log(Format('Failed to list %s', [Path]));
Result := -1;
end;
end;
var
DeleteModelsChecked: Boolean;
ModelsDir: string;
procedure InitializeUninstallProgressForm();
var
UninstallPage: TNewNotebookPage;
UninstallButton: TNewButton;
DeleteModelsCheckbox: TNewCheckBox;
OriginalPageNameLabel: string;
OriginalPageDescriptionLabel: string;
OriginalCancelButtonEnabled: Boolean;
OriginalCancelButtonModalResult: Integer;
ctrl: TWinControl;
ModelDirA: AnsiString;
ModelsSize: Int64;
begin
if not UninstallSilent then begin
ctrl := UninstallProgressForm.CancelButton;
UninstallButton := TNewButton.Create(UninstallProgressForm);
UninstallButton.Parent := UninstallProgressForm;
UninstallButton.Left := ctrl.Left - ctrl.Width - ScaleX(10);
UninstallButton.Top := ctrl.Top;
UninstallButton.Width := ctrl.Width;
UninstallButton.Height := ctrl.Height;
UninstallButton.TabOrder := ctrl.TabOrder;
UninstallButton.Caption := 'Uninstall';
UninstallButton.ModalResult := mrOK;
UninstallProgressForm.CancelButton.TabOrder := UninstallButton.TabOrder + 1;
UninstallPage := TNewNotebookPage.Create(UninstallProgressForm);
UninstallPage.Notebook := UninstallProgressForm.InnerNotebook;
UninstallPage.Parent := UninstallProgressForm.InnerNotebook;
UninstallPage.Align := alClient;
UninstallProgressForm.InnerNotebook.ActivePage := UninstallPage;
ctrl := UninstallProgressForm.StatusLabel;
with TNewStaticText.Create(UninstallProgressForm) do begin
Parent := UninstallPage;
Top := ctrl.Top;
Left := ctrl.Left;
Width := ctrl.Width;
Height := ctrl.Height;
AutoSize := False;
ShowAccelChar := False;
Caption := '';
end;
if (DirExists(GetEnv('USERPROFILE') + '\.ollama\models\blobs')) then begin
ModelsDir := GetEnv('USERPROFILE') + '\.ollama\models';
ModelsSize := GetDirSize(ModelsDir);
end;
DeleteModelsCheckbox := TNewCheckBox.Create(UninstallProgressForm);
DeleteModelsCheckbox.Parent := UninstallPage;
DeleteModelsCheckbox.Top := ctrl.Top + ScaleY(30);
DeleteModelsCheckbox.Left := ctrl.Left;
DeleteModelsCheckbox.Width := ScaleX(300);
if ModelsSize > 1024*1024*1024 then begin
DeleteModelsCheckbox.Caption := 'Remove models (' + IntToStr(ModelsSize/(1024*1024*1024)) + ' GB) ' + ModelsDir;
end else if ModelsSize > 1024*1024 then begin
DeleteModelsCheckbox.Caption := 'Remove models (' + IntToStr(ModelsSize/(1024*1024)) + ' MB) ' + ModelsDir;
end else begin
DeleteModelsCheckbox.Caption := 'Remove models ' + ModelsDir;
end;
DeleteModelsCheckbox.Checked := True;
OriginalPageNameLabel := UninstallProgressForm.PageNameLabel.Caption;
OriginalPageDescriptionLabel := UninstallProgressForm.PageDescriptionLabel.Caption;
OriginalCancelButtonEnabled := UninstallProgressForm.CancelButton.Enabled;
OriginalCancelButtonModalResult := UninstallProgressForm.CancelButton.ModalResult;
UninstallProgressForm.PageNameLabel.Caption := '';
UninstallProgressForm.PageDescriptionLabel.Caption := '';
UninstallProgressForm.CancelButton.Enabled := True;
UninstallProgressForm.CancelButton.ModalResult := mrCancel;
if UninstallProgressForm.ShowModal = mrCancel then Abort;
UninstallButton.Visible := False;
UninstallProgressForm.PageNameLabel.Caption := OriginalPageNameLabel;
UninstallProgressForm.PageDescriptionLabel.Caption := OriginalPageDescriptionLabel;
UninstallProgressForm.CancelButton.Enabled := OriginalCancelButtonEnabled;
UninstallProgressForm.CancelButton.ModalResult := OriginalCancelButtonModalResult;
UninstallProgressForm.InnerNotebook.ActivePage := UninstallProgressForm.InstallingPage;
if DeleteModelsCheckbox.Checked then begin
DeleteModelsChecked:=True;
end else begin
DeleteModelsChecked:=False;
end;
end;
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usDone then begin
if DeleteModelsChecked then begin
Log('user requested model cleanup');
if (VarIsEmpty(ModelsDir)) then begin
Log('cleaning up home directory models')
DelTree(GetEnv('USERPROFILE') + '\.ollama\models', True, True, True);
end else begin
Log('cleaning up custom directory models ' + ModelsDir)
DelTree(ModelsDir + '\blobs', True, True, True);
DelTree(ModelsDir + '\manifests', True, True, True);
end;
end else begin
Log('user requested to preserve model dir');
end;
end;
end;
procedure TaskKill(FileName: String);
var
ResultCode: Integer;
begin
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;

View File

@@ -1,8 +0,0 @@
# TODO - consider ANSI colors and maybe ASCII art...
write-host ""
write-host "Welcome to Ollama!"
write-host ""
write-host "Run your first model:"
write-host ""
write-host "`tollama run llama3.2"
write-host ""

357
app/server/server.go Normal file
View File

@@ -0,0 +1,357 @@
//go:build windows || darwin
package server
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/app/logrotate"
"github.com/ollama/ollama/app/store"
)
const restartDelay = time.Second
// Server is a managed ollama server process
type Server struct {
store *store.Store
bin string // resolved path to `ollama`
log io.WriteCloser
dev bool // true if running with the dev flag
}
type InferenceCompute struct {
Library string
Variant string
Compute string
Driver string
Name string
VRAM string
}
func New(s *store.Store, devMode bool) *Server {
p := resolvePath("ollama")
return &Server{store: s, bin: p, dev: devMode}
}
func resolvePath(name string) string {
// look in the app bundle first
if exe, _ := os.Executable(); exe != "" {
var dir string
if runtime.GOOS == "windows" {
dir = filepath.Dir(exe)
} else {
dir = filepath.Join(filepath.Dir(exe), "..", "Resources")
}
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return filepath.Join(dir, name)
}
}
// check the development dist path
for _, path := range []string{
filepath.Join("dist", runtime.GOOS, name),
filepath.Join("dist", runtime.GOOS+"-"+runtime.GOARCH, name),
} {
if _, err := os.Stat(path); err == nil {
return path
}
}
// fallback to system path
if p, _ := exec.LookPath(name); p != "" {
return p
}
return name
}
// cleanup checks the pid file for a running ollama process
// and shuts it down gracefully if it is running
func cleanup() error {
data, err := os.ReadFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer os.Remove(pidFile)
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return err
}
proc, err := os.FindProcess(pid)
if err != nil {
return nil
}
ok, err := terminated(pid)
if err != nil {
slog.Debug("cleanup: error checking if terminated", "pid", pid, "err", err)
}
if ok {
return nil
}
slog.Info("detected previous ollama process, cleaning up", "pid", pid)
return stop(proc)
}
// stop waits for a process with the provided pid to exit by polling
// `terminated(pid)`. If the process has not exited within 5 seconds, it logs a
// warning and kills the process.
func stop(proc *os.Process) error {
if proc == nil {
return nil
}
if err := terminate(proc); err != nil {
slog.Warn("graceful terminate failed, killing", "err", err)
return proc.Kill()
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
select {
case <-deadline.C:
slog.Warn("timeout waiting for graceful shutdown; killing", "pid", proc.Pid)
return proc.Kill()
default:
ok, err := terminated(proc.Pid)
if err != nil {
slog.Error("error checking if ollama process is terminated", "err", err)
return err
}
if ok {
return nil
}
time.Sleep(10 * time.Millisecond)
}
}
}
func (s *Server) Run(ctx context.Context) error {
l, err := openRotatingLog()
if err != nil {
return err
}
s.log = l
defer s.log.Close()
if err := cleanup(); err != nil {
slog.Warn("failed to cleanup previous ollama process", "err", err)
}
reaped := false
for ctx.Err() == nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(restartDelay):
}
cmd, err := s.cmd(ctx)
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
err = os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
if err != nil {
slog.Warn("failed to write pid file", "file", pidFile, "err", err)
}
if err = cmd.Wait(); err != nil && !errors.Is(err, context.Canceled) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 && !s.dev && !reaped {
reaped = true
// This could be a port conflict, try to kill any existing ollama processes
if err := reapServers(); err != nil {
slog.Warn("failed to stop existing ollama server", "err", err)
} else {
slog.Debug("conflicting server stopped, waiting for port to be released")
continue
}
}
slog.Error("ollama exited", "err", err)
}
}
return ctx.Err()
}
func (s *Server) cmd(ctx context.Context) (*exec.Cmd, error) {
settings, err := s.store.Settings()
if err != nil {
return nil, err
}
cmd := commandContext(ctx, s.bin, "serve")
cmd.Stdout, cmd.Stderr = s.log, s.log
// Copy and mutate the environment to merge in settings the user has specified without dups
env := map[string]string{}
for _, kv := range os.Environ() {
s := strings.SplitN(kv, "=", 2)
env[s[0]] = s[1]
}
if settings.Expose {
env["OLLAMA_HOST"] = "0.0.0.0"
}
if settings.Browser {
env["OLLAMA_ORIGINS"] = "*"
}
if settings.Models != "" {
if _, err := os.Stat(settings.Models); err == nil {
env["OLLAMA_MODELS"] = settings.Models
} else {
slog.Warn("models path not accessible, clearing models setting", "path", settings.Models, "err", err)
settings.Models = ""
s.store.SetSettings(settings)
}
}
if settings.ContextLength > 0 {
env["OLLAMA_CONTEXT_LENGTH"] = strconv.Itoa(settings.ContextLength)
}
cmd.Env = []string{}
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
return stop(cmd.Process)
}
return cmd, nil
}
func openRotatingLog() (io.WriteCloser, error) {
// TODO consider rotation based on size or time, not just every server invocation
dir := filepath.Dir(serverLogPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
logrotate.Rotate(serverLogPath)
f, err := os.OpenFile(serverLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
return f, nil
}
// Attempt to retrieve inference compute information from the server
// log. Set ctx to timeout to control how long to wait for the logs to appear
func GetInferenceComputer(ctx context.Context) ([]InferenceCompute, error) {
inference := []InferenceCompute{}
marker := regexp.MustCompile(`inference compute.*library=`)
q := `inference compute.*%s=["]([^"]*)["]`
nq := `inference compute.*%s=(\S+)\s`
type regex struct {
q *regexp.Regexp
nq *regexp.Regexp
}
regexes := map[string]regex{
"library": {
q: regexp.MustCompile(fmt.Sprintf(q, "library")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "library")),
},
"variant": {
q: regexp.MustCompile(fmt.Sprintf(q, "variant")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "variant")),
},
"compute": {
q: regexp.MustCompile(fmt.Sprintf(q, "compute")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "compute")),
},
"driver": {
q: regexp.MustCompile(fmt.Sprintf(q, "driver")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "driver")),
},
"name": {
q: regexp.MustCompile(fmt.Sprintf(q, "name")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "name")),
},
"total": {
q: regexp.MustCompile(fmt.Sprintf(q, "total")),
nq: regexp.MustCompile(fmt.Sprintf(nq, "total")),
},
}
get := func(field, line string) string {
regex, ok := regexes[field]
if !ok {
slog.Warn("missing field", "field", field)
return ""
}
match := regex.q.FindStringSubmatch(line)
if len(match) > 1 {
return match[1]
}
match = regex.nq.FindStringSubmatch(line)
if len(match) > 1 {
return match[1]
}
return ""
}
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("timeout scanning server log for inference compute details")
default:
}
file, err := os.Open(serverLogPath)
if err != nil {
slog.Debug("failed to open server log", "log", serverLogPath, "error", err)
time.Sleep(time.Second)
continue
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
match := marker.FindStringSubmatch(line)
if len(match) > 0 {
ic := InferenceCompute{
Library: get("library", line),
Variant: get("variant", line),
Compute: get("compute", line),
Driver: get("driver", line),
Name: get("name", line),
VRAM: get("total", line),
}
slog.Info("Matched", "inference compute", ic)
inference = append(inference, ic)
} else {
// Break out on first non matching line after we start matching
if len(inference) > 0 {
return inference, nil
}
}
}
time.Sleep(100 * time.Millisecond)
}
}

249
app/server/server_test.go Normal file
View File

@@ -0,0 +1,249 @@
//go:build windows || darwin
package server
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/ollama/ollama/app/store"
)
func TestNew(t *testing.T) {
tmpDir := t.TempDir()
st := &store.Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer st.Close() // Ensure database is closed before cleanup
s := New(st, false)
if s == nil {
t.Fatal("expected non-nil server")
}
if s.bin == "" {
t.Error("expected non-empty bin path")
}
}
func TestServerCmd(t *testing.T) {
os.Unsetenv("OLLAMA_HOST")
os.Unsetenv("OLLAMA_ORIGINS")
os.Unsetenv("OLLAMA_MODELS")
var defaultModels string
home, err := os.UserHomeDir()
if err == nil {
defaultModels = filepath.Join(home, ".ollama", "models")
os.MkdirAll(defaultModels, 0o755)
}
tmpModels := t.TempDir()
tests := []struct {
name string
settings store.Settings
want []string
dont []string
}{
{
name: "default",
settings: store.Settings{},
want: []string{"OLLAMA_MODELS=" + defaultModels},
dont: []string{"OLLAMA_HOST=", "OLLAMA_ORIGINS="},
},
{
name: "expose",
settings: store.Settings{Expose: true},
want: []string{"OLLAMA_HOST=0.0.0.0", "OLLAMA_MODELS=" + defaultModels},
dont: []string{"OLLAMA_ORIGINS="},
},
{
name: "browser",
settings: store.Settings{Browser: true},
want: []string{"OLLAMA_ORIGINS=*", "OLLAMA_MODELS=" + defaultModels},
dont: []string{"OLLAMA_HOST="},
},
{
name: "models",
settings: store.Settings{Models: tmpModels},
want: []string{"OLLAMA_MODELS=" + tmpModels},
dont: []string{"OLLAMA_HOST=", "OLLAMA_ORIGINS="},
},
{
name: "inaccessible_models",
settings: store.Settings{Models: "/nonexistent/external/drive/models"},
want: []string{},
dont: []string{"OLLAMA_MODELS="},
},
{
name: "all",
settings: store.Settings{
Expose: true,
Browser: true,
Models: tmpModels,
},
want: []string{
"OLLAMA_HOST=0.0.0.0",
"OLLAMA_ORIGINS=*",
"OLLAMA_MODELS=" + tmpModels,
},
dont: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
st := &store.Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer st.Close() // Ensure database is closed before cleanup
st.SetSettings(tt.settings)
s := &Server{
store: st,
}
cmd, err := s.cmd(t.Context())
if err != nil {
t.Fatalf("s.cmd() error = %v", err)
}
for _, want := range tt.want {
found := false
for _, env := range cmd.Env {
if strings.Contains(env, want) {
found = true
break
}
}
if !found {
t.Errorf("expected environment variable containing %s", want)
}
}
for _, dont := range tt.dont {
for _, env := range cmd.Env {
if strings.Contains(env, dont) {
t.Errorf("unexpected environment variable: %s", env)
}
}
}
if cmd.Cancel == nil {
t.Error("expected non-nil cancel function")
}
})
}
}
func TestGetInferenceComputer(t *testing.T) {
tests := []struct {
name string
log string
exp []InferenceCompute
}{
{
name: "metal",
log: `time=2025-06-30T09:23:07.374-07:00 level=DEBUG source=sched.go:108 msg="starting llm scheduler"
time=2025-06-30T09:23:07.416-07:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="96.0 GiB" available="96.0 GiB"
time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not found" key=general.alignment default=32
`,
exp: []InferenceCompute{{
Library: "metal",
Driver: "0.0",
VRAM: "96.0 GiB",
}},
},
{
name: "cpu",
log: `time=2025-07-01T17:59:51.470Z level=INFO source=gpu.go:377 msg="no compatible GPUs were discovered"
time=2025-07-01T17:59:51.470Z level=INFO source=types.go:130 msg="inference compute" id=0 library=cpu variant="" compute="" driver=0.0 name="" total="31.3 GiB" available="30.4 GiB"
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
`,
exp: []InferenceCompute{{
Library: "cpu",
Driver: "0.0",
VRAM: "31.3 GiB",
}},
},
{
name: "cuda1",
log: `time=2025-07-01T19:33:43.162Z level=DEBUG source=amd_linux.go:419 msg="amdgpu driver not detected /sys/module/amdgpu"
releasing cuda driver library
time=2025-07-01T19:33:43.162Z level=INFO source=types.go:130 msg="inference compute" id=GPU-452cac9f-6960-839c-4fb3-0cec83699196 library=cuda variant=v12 compute=6.1 driver=12.7 name="NVIDIA GeForce GT 1030" total="3.9 GiB" available="3.9 GiB"
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
`,
exp: []InferenceCompute{{
Library: "cuda",
Variant: "v12",
Compute: "6.1",
Driver: "12.7",
Name: "NVIDIA GeForce GT 1030",
VRAM: "3.9 GiB",
}},
},
{
name: "frank",
log: `time=2025-07-01T19:36:13.315Z level=INFO source=amd_linux.go:386 msg="amdgpu is supported" gpu=GPU-9abb57639fa80c50 gpu_type=gfx1030
releasing cuda driver library
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-d6de3398-9932-6902-11ec-fee8e424c8a2 library=cuda variant=v12 compute=7.5 driver=12.8 name="NVIDIA GeForce RTX 2080 Ti" total="10.6 GiB" available="10.4 GiB"
time=2025-07-01T19:36:13.315Z level=INFO source=types.go:130 msg="inference compute" id=GPU-9abb57639fa80c50 library=rocm variant="" compute=gfx1030 driver=6.3 name=1002:73bf total="16.0 GiB" available="1.3 GiB"
[GIN] 2025/07/01 - 18:00:09 | 200 | 50.263µs | 100.126.204.152 | HEAD "/"
`,
exp: []InferenceCompute{
{
Library: "cuda",
Variant: "v12",
Compute: "7.5",
Driver: "12.8",
Name: "NVIDIA GeForce RTX 2080 Ti",
VRAM: "10.6 GiB",
},
{
Library: "rocm",
Compute: "gfx1030",
Driver: "6.3",
Name: "1002:73bf",
VRAM: "16.0 GiB",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
serverLogPath = filepath.Join(tmpDir, "server.log")
err := os.WriteFile(serverLogPath, []byte(tt.log), 0o644)
if err != nil {
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
defer cancel()
ics, err := GetInferenceComputer(ctx)
if err != nil {
t.Fatalf(" failed to get inference compute: %v", err)
}
if !reflect.DeepEqual(ics, tt.exp) {
t.Fatalf("got:\n%#v\nwant:\n%#v", ics, tt.exp)
}
})
}
}
func TestGetInferenceComputerTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
defer cancel()
tmpDir := t.TempDir()
serverLogPath = filepath.Join(tmpDir, "server.log")
err := os.WriteFile(serverLogPath, []byte("foo\nbar\nbaz\n"), 0o644)
if err != nil {
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
}
_, err = GetInferenceComputer(ctx)
if err == nil {
t.Fatal("expected timeout")
}
if !strings.Contains(err.Error(), "timeout") {
t.Fatalf("unexpected error: %s", err)
}
}

104
app/server/server_unix.go Normal file
View File

@@ -0,0 +1,104 @@
//go:build darwin
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
)
var (
pidFile = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "ollama.pid")
serverLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "server.log")
)
func commandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.CommandContext(ctx, name, arg...)
}
func terminate(proc *os.Process) error {
return proc.Signal(os.Interrupt)
}
func terminated(pid int) (bool, error) {
proc, err := os.FindProcess(pid)
if err != nil {
return false, fmt.Errorf("failed to find process: %v", err)
}
err = proc.Signal(syscall.Signal(0))
if err != nil {
if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) {
return true, nil
}
return false, fmt.Errorf("error signaling process: %v", err)
}
return false, nil
}
// reapServers kills all ollama processes except our own
func reapServers() error {
// Get our own PID to avoid killing ourselves
currentPID := os.Getpid()
// Use pkill to kill ollama processes
// -x matches the whole command name exactly
// We'll get the list first, then kill selectively
cmd := exec.Command("pgrep", "-x", "ollama")
output, err := cmd.Output()
if err != nil {
// No ollama processes found
slog.Debug("no ollama processes found")
return nil //nolint:nilerr
}
pidsStr := strings.TrimSpace(string(output))
if pidsStr == "" {
return nil
}
pids := strings.Split(pidsStr, "\n")
for _, pidStr := range pids {
pidStr = strings.TrimSpace(pidStr)
if pidStr == "" {
continue
}
pid, err := strconv.Atoi(pidStr)
if err != nil {
slog.Debug("failed to parse PID", "pidStr", pidStr, "err", err)
continue
}
if pid == currentPID {
continue
}
proc, err := os.FindProcess(pid)
if err != nil {
slog.Debug("failed to find process", "pid", pid, "err", err)
continue
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
// Try SIGKILL if SIGTERM fails
if err := proc.Signal(syscall.SIGKILL); err != nil {
slog.Warn("failed to stop external ollama process", "pid", pid, "err", err)
continue
}
}
slog.Info("stopped external ollama process", "pid", pid)
}
return nil
}

View File

@@ -0,0 +1,149 @@
package server
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
var (
pidFile = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "ollama.pid")
serverLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "server.log")
)
func commandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, name, arg...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP,
}
return cmd
}
func terminate(proc *os.Process) error {
dll, err := windows.LoadDLL("kernel32.dll")
if err != nil {
return err
}
defer dll.Release()
pid := proc.Pid
f, err := dll.FindProc("AttachConsole")
if err != nil {
return err
}
r1, _, err := f.Call(uintptr(pid))
if r1 == 0 && err != syscall.ERROR_ACCESS_DENIED {
return err
}
f, err = dll.FindProc("SetConsoleCtrlHandler")
if err != nil {
return err
}
r1, _, err = f.Call(0, 1)
if r1 == 0 {
return err
}
f, err = dll.FindProc("GenerateConsoleCtrlEvent")
if err != nil {
return err
}
r1, _, err = f.Call(windows.CTRL_BREAK_EVENT, uintptr(pid))
if r1 == 0 {
return err
}
r1, _, err = f.Call(windows.CTRL_C_EVENT, uintptr(pid))
if r1 == 0 {
return err
}
return nil
}
const STILL_ACTIVE = 259
func terminated(pid int) (bool, error) {
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION, false, uint32(pid))
if err != nil {
if errno, ok := err.(windows.Errno); ok && errno == windows.ERROR_INVALID_PARAMETER {
return true, nil
}
return false, fmt.Errorf("failed to open process: %v", err)
}
defer windows.CloseHandle(hProcess)
var exitCode uint32
err = windows.GetExitCodeProcess(hProcess, &exitCode)
if err != nil {
return false, fmt.Errorf("failed to get exit code: %v", err)
}
if exitCode == STILL_ACTIVE {
return false, nil
}
return true, nil
}
// reapServers kills all ollama processes except our own
func reapServers() error {
// Get current process ID to avoid killing ourselves
currentPID := os.Getpid()
// Use wmic to find ollama processes
cmd := exec.Command("wmic", "process", "where", "name='ollama.exe'", "get", "ProcessId")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
// No ollama processes found
slog.Debug("no ollama processes found")
return nil //nolint:nilerr
}
lines := strings.Split(string(output), "\n")
var pids []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line == "ProcessId" {
continue
}
if _, err := strconv.Atoi(line); err == nil {
pids = append(pids, line)
}
}
for _, pidStr := range pids {
pid, err := strconv.Atoi(pidStr)
if err != nil {
continue
}
if pid == currentPID {
continue
}
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
if err := cmd.Run(); err != nil {
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
}
}
return nil
}

1222
app/store/database.go Normal file

File diff suppressed because it is too large Load Diff

407
app/store/database_test.go Normal file
View File

@@ -0,0 +1,407 @@
//go:build windows || darwin
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
_ "github.com/mattn/go-sqlite3"
)
func TestSchemaMigrations(t *testing.T) {
t.Run("schema comparison after migration", func(t *testing.T) {
tmpDir := t.TempDir()
migratedDBPath := filepath.Join(tmpDir, "migrated.db")
migratedDB := loadV2Schema(t, migratedDBPath)
defer migratedDB.Close()
if err := migratedDB.migrate(); err != nil {
t.Fatalf("migration failed: %v", err)
}
// Create fresh database with current schema
freshDBPath := filepath.Join(tmpDir, "fresh.db")
freshDB, err := newDatabase(freshDBPath)
if err != nil {
t.Fatalf("failed to create fresh database: %v", err)
}
defer freshDB.Close()
// Extract tables and indexes from both databases, directly comparing their schemas won't work due to ordering
migratedSchema := schemaMap(migratedDB)
freshSchema := schemaMap(freshDB)
if !cmp.Equal(migratedSchema, freshSchema) {
t.Errorf("Schema difference found:\n%s", cmp.Diff(freshSchema, migratedSchema))
}
// Verify both databases have the same final schema version
migratedVersion, _ := migratedDB.getSchemaVersion()
freshVersion, _ := freshDB.getSchemaVersion()
if migratedVersion != freshVersion {
t.Errorf("schema version mismatch: migrated=%d, fresh=%d", migratedVersion, freshVersion)
}
})
t.Run("idempotent migrations", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db := loadV2Schema(t, dbPath)
defer db.Close()
// Run migration twice
if err := db.migrate(); err != nil {
t.Fatalf("first migration failed: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("second migration failed: %v", err)
}
// Verify schema version is still correct
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Errorf("expected schema version %d after double migration, got %d", currentSchemaVersion, version)
}
})
t.Run("init database has correct schema version", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Get the schema version from the newly initialized database
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
// Verify it matches the currentSchemaVersion constant
if version != currentSchemaVersion {
t.Errorf("expected schema version %d in initialized database, got %d", currentSchemaVersion, version)
}
})
}
func TestChatDeletionWithCascade(t *testing.T) {
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create test chat
testChatID := "test-chat-cascade-123"
testChat := Chat{
ID: testChatID,
Title: "Test Chat for Cascade Delete",
CreatedAt: time.Now(),
Messages: []Message{
{
Role: "user",
Content: "Hello, this is a test message",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
Role: "assistant",
Content: "Hi there! This is a response.",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
// Save the chat with messages
if err := db.saveChat(testChat); err != nil {
t.Fatalf("failed to save test chat: %v", err)
}
// Verify chat and messages exist
chatCount := countRows(t, db, "chats")
messageCount := countRows(t, db, "messages")
if chatCount != 1 {
t.Errorf("expected 1 chat, got %d", chatCount)
}
if messageCount != 2 {
t.Errorf("expected 2 messages, got %d", messageCount)
}
// Verify specific chat exists
var exists bool
err = db.conn.QueryRow("SELECT EXISTS(SELECT 1 FROM chats WHERE id = ?)", testChatID).Scan(&exists)
if err != nil {
t.Fatalf("failed to check chat existence: %v", err)
}
if !exists {
t.Error("test chat should exist before deletion")
}
// Verify messages exist for this chat
messageCountForChat := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if messageCountForChat != 2 {
t.Errorf("expected 2 messages for test chat, got %d", messageCountForChat)
}
// Delete the chat
if err := db.deleteChat(testChatID); err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify chat is deleted
chatCountAfter := countRows(t, db, "chats")
if chatCountAfter != 0 {
t.Errorf("expected 0 chats after deletion, got %d", chatCountAfter)
}
// Verify messages are CASCADE deleted
messageCountAfter := countRows(t, db, "messages")
if messageCountAfter != 0 {
t.Errorf("expected 0 messages after CASCADE deletion, got %d", messageCountAfter)
}
// Verify specific chat no longer exists
err = db.conn.QueryRow("SELECT EXISTS(SELECT 1 FROM chats WHERE id = ?)", testChatID).Scan(&exists)
if err != nil {
t.Fatalf("failed to check chat existence after deletion: %v", err)
}
if exists {
t.Error("test chat should not exist after deletion")
}
// Verify no orphaned messages remain
orphanedCount := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCount != 0 {
t.Errorf("expected 0 orphaned messages, got %d", orphanedCount)
}
})
t.Run("foreign keys are enabled", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Verify foreign keys are enabled
var foreignKeysEnabled int
err = db.conn.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled)
if err != nil {
t.Fatalf("failed to check foreign keys: %v", err)
}
if foreignKeysEnabled != 1 {
t.Errorf("expected foreign keys to be enabled (1), got %d", foreignKeysEnabled)
}
})
// This test is only relevant for v8 migrations, but we keep it here for now
// since it's a useful test to ensure that we don't introduce any new orphaned data
t.Run("cleanup orphaned data", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// First disable foreign keys to simulate the bug from ollama/ollama#11785
_, err = db.conn.Exec("PRAGMA foreign_keys = OFF")
if err != nil {
t.Fatalf("failed to disable foreign keys: %v", err)
}
// Create a chat and message
testChatID := "orphaned-test-chat"
testMessageID := int64(999)
_, err = db.conn.Exec("INSERT INTO chats (id, title) VALUES (?, ?)", testChatID, "Orphaned Test Chat")
if err != nil {
t.Fatalf("failed to insert test chat: %v", err)
}
_, err = db.conn.Exec("INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)",
testMessageID, testChatID, "user", "test message")
if err != nil {
t.Fatalf("failed to insert test message: %v", err)
}
// Delete chat but keep message (simulating the bug from ollama/ollama#11785)
_, err = db.conn.Exec("DELETE FROM chats WHERE id = ?", testChatID)
if err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify we have orphaned message
orphanedCount := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCount != 1 {
t.Errorf("expected 1 orphaned message, got %d", orphanedCount)
}
// Run cleanup
if err := db.cleanupOrphanedData(); err != nil {
t.Fatalf("failed to cleanup orphaned data: %v", err)
}
// Verify orphaned message is gone
orphanedCountAfter := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCountAfter != 0 {
t.Errorf("expected 0 orphaned messages after cleanup, got %d", orphanedCountAfter)
}
})
}
func countRows(t *testing.T, db *database, table string) int {
t.Helper()
var count int
err := db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count)
if err != nil {
t.Fatalf("failed to count rows in %s: %v", table, err)
}
return count
}
func countRowsWithCondition(t *testing.T, db *database, table, condition string, args ...interface{}) int {
t.Helper()
var count int
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", table, condition)
err := db.conn.QueryRow(query, args...).Scan(&count)
if err != nil {
t.Fatalf("failed to count rows with condition: %v", err)
}
return count
}
// Test helpers for schema migration testing
// schemaMap returns both tables/columns and indexes (ignoring order)
func schemaMap(db *database) map[string]interface{} {
result := make(map[string]any)
result["tables"] = columnMap(db)
result["indexes"] = indexMap(db)
return result
}
// columnMap returns a map of table names to their column sets (ignoring order)
func columnMap(db *database) map[string][]string {
result := make(map[string][]string)
// Get all table names
tableQuery := `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
rows, _ := db.conn.Query(tableQuery)
defer rows.Close()
for rows.Next() {
var tableName string
rows.Scan(&tableName)
// Get columns for this table
colQuery := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
colRows, _ := db.conn.Query(colQuery)
var columns []string
for colRows.Next() {
var cid int
var name, dataType sql.NullString
var notNull, primaryKey int
var defaultValue sql.NullString
colRows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &primaryKey)
// Create a normalized column description
colDesc := fmt.Sprintf("%s %s", name.String, dataType.String)
if notNull == 1 {
colDesc += " NOT NULL"
}
if defaultValue.Valid && defaultValue.String != "" {
// Skip DEFAULT for schema_version as it doesn't get updated during migrations
if name.String != "schema_version" {
colDesc += " DEFAULT " + defaultValue.String
}
}
if primaryKey == 1 {
colDesc += " PRIMARY KEY"
}
columns = append(columns, colDesc)
}
colRows.Close()
// Sort columns to ignore order differences
sort.Strings(columns)
result[tableName] = columns
}
return result
}
// indexMap returns a map of index names to their definitions
func indexMap(db *database) map[string]string {
result := make(map[string]string)
// Get all indexes (excluding auto-created primary key indexes)
indexQuery := `SELECT name, sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name`
rows, _ := db.conn.Query(indexQuery)
defer rows.Close()
for rows.Next() {
var name, sql string
rows.Scan(&name, &sql)
// Normalize the SQL by removing extra whitespace
sql = strings.Join(strings.Fields(sql), " ")
result[name] = sql
}
return result
}
// loadV2Schema loads the version 2 schema from testdata/schema.sql
func loadV2Schema(t *testing.T, dbPath string) *database {
t.Helper()
// Read the v1 schema file
schemaFile := filepath.Join("testdata", "schema.sql")
schemaSQL, err := os.ReadFile(schemaFile)
if err != nil {
t.Fatalf("failed to read schema file: %v", err)
}
// Open database connection
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Execute the v1 schema
_, err = conn.Exec(string(schemaSQL))
if err != nil {
conn.Close()
t.Fatalf("failed to execute v1 schema: %v", err)
}
return &database{conn: conn}
}

128
app/store/image.go Normal file
View File

@@ -0,0 +1,128 @@
//go:build windows || darwin
package store
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
type Image struct {
Filename string `json:"filename"`
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
MimeType string `json:"mime_type,omitempty"`
}
// Bytes loads image data from disk for a given ImageData reference
func (i *Image) Bytes() ([]byte, error) {
return ImgBytes(i.Path)
}
// ImgBytes reads image data from the specified file path
func ImgBytes(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("empty image path")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read image file %s: %w", path, err)
}
return data, nil
}
// ImgDir returns the directory path for storing images for a specific chat
func (s *Store) ImgDir() string {
dbPath := s.DBPath
if dbPath == "" {
dbPath = defaultDBPath
}
storeDir := filepath.Dir(dbPath)
return filepath.Join(storeDir, "cache", "images")
}
// ImgToFile saves image data to disk and returns ImageData reference
func (s *Store) ImgToFile(chatID string, imageBytes []byte, filename, mimeType string) (Image, error) {
baseImageDir := s.ImgDir()
if err := os.MkdirAll(baseImageDir, 0o755); err != nil {
return Image{}, fmt.Errorf("create base image directory: %w", err)
}
// Root prevents path traversal issues
root, err := os.OpenRoot(baseImageDir)
if err != nil {
return Image{}, fmt.Errorf("open image root directory: %w", err)
}
defer root.Close()
// Create chat-specific subdirectory within the root
chatDir := sanitize(chatID)
if err := root.Mkdir(chatDir, 0o755); err != nil && !os.IsExist(err) {
return Image{}, fmt.Errorf("create chat directory: %w", err)
}
// Generate a unique filename to avoid conflicts
// Use hash of content + original filename for uniqueness
hash := sha256.Sum256(imageBytes)
hashStr := hex.EncodeToString(hash[:])[:16] // Use first 16 chars of hash
// Extract file extension from original filename or mime type
ext := filepath.Ext(filename)
if ext == "" {
switch mimeType {
case "image/jpeg":
ext = ".jpg"
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
default:
ext = ".img"
}
}
// Create unique filename: hash + original name + extension
baseFilename := sanitize(strings.TrimSuffix(filename, ext))
uniqueFilename := fmt.Sprintf("%s_%s%s", hashStr, baseFilename, ext)
relativePath := filepath.Join(chatDir, uniqueFilename)
file, err := root.Create(relativePath)
if err != nil {
return Image{}, fmt.Errorf("create image file: %w", err)
}
defer file.Close()
if _, err := file.Write(imageBytes); err != nil {
return Image{}, fmt.Errorf("write image data: %w", err)
}
return Image{
Filename: uniqueFilename,
Path: filepath.Join(baseImageDir, relativePath),
Size: int64(len(imageBytes)),
MimeType: mimeType,
}, nil
}
// sanitize removes unsafe characters from filenames
func sanitize(filename string) string {
// Convert to safe characters only
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return '_'
}, filename)
// Clean up and validate
safe = strings.Trim(safe, "_")
if safe == "" {
return "image"
}
return safe
}

231
app/store/migration_test.go Normal file
View File

@@ -0,0 +1,231 @@
//go:build windows || darwin
package store
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestConfigMigration(t *testing.T) {
tmpDir := t.TempDir()
// Create a legacy config.json
legacyConfig := legacyData{
ID: "test-device-id-12345",
FirstTimeRun: true, // In old system, true meant "has completed first run"
}
configData, err := json.MarshalIndent(legacyConfig, "", " ")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, configData, 0o644); err != nil {
t.Fatal(err)
}
// Override the legacy config path for testing
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = configPath
defer func() { legacyConfigPath = oldLegacyConfigPath }()
// Create store with database in same directory
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// First access should trigger migration
id, err := s.ID()
if err != nil {
t.Fatalf("failed to get ID: %v", err)
}
if id != "test-device-id-12345" {
t.Errorf("expected migrated ID 'test-device-id-12345', got '%s'", id)
}
// Check HasCompletedFirstRun
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatalf("failed to get has completed first run: %v", err)
}
if !hasCompleted {
t.Error("expected has completed first run to be true after migration")
}
// Verify migration is marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
t.Fatalf("failed to check migration status: %v", err)
}
if !migrated {
t.Error("expected config to be marked as migrated")
}
// Create a new store instance to verify migration doesn't run again
s2 := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s2.Close()
// Delete the config file to ensure we're not reading from it
os.Remove(configPath)
// Verify data is still there
id2, err := s2.ID()
if err != nil {
t.Fatalf("failed to get ID from second store: %v", err)
}
if id2 != "test-device-id-12345" {
t.Errorf("expected persisted ID 'test-device-id-12345', got '%s'", id2)
}
}
func TestNoConfigToMigrate(t *testing.T) {
tmpDir := t.TempDir()
// Override the legacy config path for testing
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
defer func() { legacyConfigPath = oldLegacyConfigPath }()
// Create store without any config.json
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// Should generate a new ID
id, err := s.ID()
if err != nil {
t.Fatalf("failed to get ID: %v", err)
}
if id == "" {
t.Error("expected auto-generated ID, got empty string")
}
// HasCompletedFirstRun should be false (default)
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatalf("failed to get has completed first run: %v", err)
}
if hasCompleted {
t.Error("expected has completed first run to be false by default")
}
// Migration should still be marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
t.Fatalf("failed to check migration status: %v", err)
}
if !migrated {
t.Error("expected config to be marked as migrated even with no config.json")
}
}
const (
v1Schema = `
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 1
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);
`
)
func TestMigrationFromEpoc(t *testing.T) {
tmpDir := t.TempDir()
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// Open database connection
conn, err := sql.Open("sqlite3", s.DBPath+"?_foreign_keys=on&_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
// Test the connection
if err := conn.Ping(); err != nil {
conn.Close()
t.Fatal(err)
}
s.db = &database{conn: conn}
t.Logf("DB created: %s", s.DBPath)
_, err = s.db.conn.Exec(v1Schema)
if err != nil {
t.Fatal(err)
}
version, err := s.db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != 1 {
t.Fatalf("expected: %d\n got: %d", 1, version)
}
t.Logf("v1 schema created")
if err := s.db.migrate(); err != nil {
t.Fatal(err)
}
t.Logf("migrations completed")
version, err = s.db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Fatalf("expected: %d\n got: %d", currentSchemaVersion, version)
}
}

61
app/store/schema.sql Normal file
View File

@@ -0,0 +1,61 @@
-- This is the version 2 schema for the app database, the first released schema to users.
-- Do not modify this file. It is used to test that the database schema stays in a consistent state between schema migrations.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
survey BOOLEAN NOT NULL DEFAULT TRUE,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
context_length INTEGER NOT NULL DEFAULT 4096,
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 2
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);

60
app/store/schema_test.go Normal file
View File

@@ -0,0 +1,60 @@
//go:build windows || darwin
package store
import (
"path/filepath"
"testing"
)
func TestSchemaVersioning(t *testing.T) {
tmpDir := t.TempDir()
// Override legacy config path to avoid migration logs
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
defer func() { legacyConfigPath = oldLegacyConfigPath }()
t.Run("new database has correct schema version", func(t *testing.T) {
dbPath := filepath.Join(tmpDir, "new_db.sqlite")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Check schema version
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Errorf("expected schema version %d, got %d", currentSchemaVersion, version)
}
})
t.Run("can update schema version", func(t *testing.T) {
dbPath := filepath.Join(tmpDir, "update_db.sqlite")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Set a different version
testVersion := 42
if err := db.setSchemaVersion(testVersion); err != nil {
t.Fatalf("failed to set schema version: %v", err)
}
// Verify it was updated
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != testVersion {
t.Errorf("expected schema version %d, got %d", testVersion, version)
}
})
}

View File

@@ -1,97 +1,495 @@
//go:build windows || darwin
// Package store provides a simple JSON file store for the desktop application
// to save and load data such as ollama server configuration, messages,
// login information and more.
package store
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/google/uuid"
"github.com/ollama/ollama/app/types/not"
)
type File struct {
Filename string `json:"filename"`
Data []byte `json:"data"`
}
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Plan string `json:"plan"`
CachedAt time.Time `json:"cachedAt"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking string `json:"thinking"`
Stream bool `json:"stream"`
Model string `json:"model,omitempty"`
Attachments []File `json:"attachments,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCall *ToolCall `json:"tool_call,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolResult *json.RawMessage `json:"tool_result,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ThinkingTimeStart *time.Time `json:"thinkingTimeStart,omitempty" ts_type:"Date | undefined" ts_transform:"__VALUE__ && new Date(__VALUE__)"`
ThinkingTimeEnd *time.Time `json:"thinkingTimeEnd,omitempty" ts_type:"Date | undefined" ts_transform:"__VALUE__ && new Date(__VALUE__)"`
}
// MessageOptions contains optional parameters for creating a Message
type MessageOptions struct {
Model string
Attachments []File
Stream bool
Thinking string
ToolCalls []ToolCall
ToolCall *ToolCall
ToolResult *json.RawMessage
ThinkingTimeStart *time.Time
ThinkingTimeEnd *time.Time
}
// NewMessage creates a new Message with the given options
func NewMessage(role, content string, opts *MessageOptions) Message {
now := time.Now()
msg := Message{
Role: role,
Content: content,
CreatedAt: now,
UpdatedAt: now,
}
if opts != nil {
msg.Model = opts.Model
msg.Attachments = opts.Attachments
msg.Stream = opts.Stream
msg.Thinking = opts.Thinking
msg.ToolCalls = opts.ToolCalls
msg.ToolCall = opts.ToolCall
msg.ToolResult = opts.ToolResult
msg.ThinkingTimeStart = opts.ThinkingTimeStart
msg.ThinkingTimeEnd = opts.ThinkingTimeEnd
}
return msg
}
type ToolCall struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
type ToolFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
Result any `json:"result,omitempty"`
}
type Model struct {
Model string `json:"model"` // Model name
Digest string `json:"digest,omitempty"` // Model digest from the registry
ModifiedAt *time.Time `json:"modified_at,omitempty"` // When the model was last modified locally
}
type Chat struct {
ID string `json:"id"`
Messages []Message `json:"messages"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
BrowserState json.RawMessage `json:"browser_state,omitempty" ts_type:"BrowserStateData"`
}
// NewChat creates a new Chat with the ID, with CreatedAt timestamp initialized
func NewChat(id string) *Chat {
return &Chat{
ID: id,
Messages: []Message{},
CreatedAt: time.Now(),
}
}
type Settings struct {
// Expose is a boolean that indicates if the ollama server should
// be exposed to the network
Expose bool
// Browser is a boolean that indicates if the ollama server should
// be exposed to browser windows (e.g. CORS set to allow all origins)
Browser bool
// Survey is a boolean that indicates if the user allows anonymous
// inference information to be shared with Ollama
Survey bool
// Models is a string that contains the models to load on startup
Models string
// TODO(parthsareen): temporary for experimentation
// Agent indicates if the app should use multi-turn tools to fulfill user requests
Agent bool
// Tools indicates if the app should use single-turn tools to fulfill user requests
Tools bool
// WorkingDir specifies the working directory for all agent operations
WorkingDir string
// ContextLength specifies the context length for the ollama server (using OLLAMA_CONTEXT_LENGTH)
ContextLength int
// AirplaneMode when true, turns off Ollama Turbo features and only uses local models
AirplaneMode bool
// TurboEnabled indicates if Ollama Turbo features are enabled
TurboEnabled bool
// Maps gpt-oss specific frontend name' BrowserToolEnabled' to db field 'websearch_enabled'
WebSearchEnabled bool
// ThinkEnabled indicates if thinking is enabled
ThinkEnabled bool
// ThinkLevel indicates the level of thinking to use for models that support multiple levels
ThinkLevel string
// SelectedModel stores the last model that the user selected
SelectedModel string
// SidebarOpen indicates if the chat sidebar is open
SidebarOpen bool
}
type Store struct {
// DBPath allows overriding the default database path (mainly for testing)
DBPath string
// dbMu protects database initialization only
dbMu sync.Mutex
db *database
}
var defaultDBPath = func() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "db.sqlite")
case "darwin":
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "db.sqlite")
default:
return filepath.Join(os.Getenv("HOME"), ".ollama", "db.sqlite")
}
}()
// legacyConfigPath is the path to the old config.json file
var legacyConfigPath = func() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "config.json")
case "darwin":
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "config.json")
default:
return filepath.Join(os.Getenv("HOME"), ".ollama", "config.json")
}
}()
// legacyData represents the old config.json structure (only fields we need to migrate)
type legacyData struct {
ID string `json:"id"`
FirstTimeRun bool `json:"first-time-run"`
}
var (
lock sync.Mutex
store Store
)
func GetID() string {
lock.Lock()
defer lock.Unlock()
if store.ID == "" {
initStore()
func (s *Store) ensureDB() error {
// Fast path: check if db is already initialized
if s.db != nil {
return nil
}
return store.ID
}
func GetFirstTimeRun() bool {
lock.Lock()
defer lock.Unlock()
if store.ID == "" {
initStore()
// Slow path: initialize database with lock
s.dbMu.Lock()
defer s.dbMu.Unlock()
// Double-check after acquiring lock
if s.db != nil {
return nil
}
return store.FirstTimeRun
}
func SetFirstTimeRun(val bool) {
lock.Lock()
defer lock.Unlock()
if store.FirstTimeRun == val {
return
dbPath := s.DBPath
if dbPath == "" {
dbPath = defaultDBPath
}
store.FirstTimeRun = val
writeStore(getStorePath())
}
// lock must be held
func initStore() {
storeFile, err := os.Open(getStorePath())
if err == nil {
defer storeFile.Close()
err = json.NewDecoder(storeFile).Decode(&store)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return fmt.Errorf("create db directory: %w", err)
}
database, err := newDatabase(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
// Generate device ID if needed
id, err := database.getID()
if err != nil || id == "" {
// Generate new UUID for device
u, err := uuid.NewV7()
if err == nil {
slog.Debug(fmt.Sprintf("loaded existing store %s - ID: %s", getStorePath(), store.ID))
return
database.setID(u.String())
}
} else if !errors.Is(err, os.ErrNotExist) {
slog.Debug(fmt.Sprintf("unexpected error searching for store: %s", err))
}
slog.Debug("initializing new store")
store.ID = uuid.NewString()
writeStore(getStorePath())
s.db = database
// Check if we need to migrate from config.json
migrated, err := database.isConfigMigrated()
if err != nil || !migrated {
if err := s.migrateFromConfig(database); err != nil {
slog.Warn("failed to migrate from config.json", "error", err)
}
}
return nil
}
func writeStore(storeFilename string) {
ollamaDir := filepath.Dir(storeFilename)
_, err := os.Stat(ollamaDir)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(ollamaDir, 0o755); err != nil {
slog.Error(fmt.Sprintf("create ollama dir %s: %v", ollamaDir, err))
return
// migrateFromConfig attempts to migrate ID and FirstTimeRun from config.json
func (s *Store) migrateFromConfig(database *database) error {
configPath := legacyConfigPath
// Check if config.json exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// No config to migrate, mark as migrated
return database.setConfigMigrated(true)
}
// Read the config file
b, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read legacy config: %w", err)
}
var legacy legacyData
if err := json.Unmarshal(b, &legacy); err != nil {
// If we can't parse it, just mark as migrated and move on
slog.Warn("failed to parse legacy config.json", "error", err)
return database.setConfigMigrated(true)
}
// Migrate the ID if present
if legacy.ID != "" {
if err := database.setID(legacy.ID); err != nil {
return fmt.Errorf("migrate device ID: %w", err)
}
slog.Info("migrated device ID from config.json")
}
hasCompleted := legacy.FirstTimeRun // If old FirstTimeRun is true, it means first run was completed
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
return fmt.Errorf("migrate first time run: %w", err)
}
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
// Mark as migrated
if err := database.setConfigMigrated(true); err != nil {
return fmt.Errorf("mark config as migrated: %w", err)
}
slog.Info("successfully migrated settings from config.json")
return nil
}
func (s *Store) ID() (string, error) {
if err := s.ensureDB(); err != nil {
return "", err
}
return s.db.getID()
}
func (s *Store) HasCompletedFirstRun() (bool, error) {
if err := s.ensureDB(); err != nil {
return false, err
}
return s.db.getHasCompletedFirstRun()
}
func (s *Store) SetHasCompletedFirstRun(hasCompleted bool) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setHasCompletedFirstRun(hasCompleted)
}
func (s *Store) Settings() (Settings, error) {
if err := s.ensureDB(); err != nil {
return Settings{}, fmt.Errorf("load settings: %w", err)
}
settings, err := s.db.getSettings()
if err != nil {
return Settings{}, err
}
// Set default models directory if not set
if settings.Models == "" {
dir := os.Getenv("OLLAMA_MODELS")
if dir != "" {
settings.Models = dir
} else {
home, err := os.UserHomeDir()
if err == nil {
settings.Models = filepath.Join(home, ".ollama", "models")
}
}
}
payload, err := json.Marshal(store)
if err != nil {
slog.Error(fmt.Sprintf("failed to marshal store: %s", err))
return
}
fp, err := os.OpenFile(storeFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
slog.Error(fmt.Sprintf("write store payload %s: %v", storeFilename, err))
return
}
defer fp.Close()
if n, err := fp.Write(payload); err != nil || n != len(payload) {
slog.Error(fmt.Sprintf("write store payload %s: %d vs %d -- %v", storeFilename, n, len(payload), err))
return
}
slog.Debug("Store contents: " + string(payload))
slog.Info(fmt.Sprintf("wrote store: %s", storeFilename))
return settings, nil
}
func (s *Store) SetSettings(settings Settings) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setSettings(settings)
}
func (s *Store) Chats() ([]Chat, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
return s.db.getAllChats()
}
func (s *Store) Chat(id string) (*Chat, error) {
return s.ChatWithOptions(id, true)
}
func (s *Store) ChatWithOptions(id string, loadAttachmentData bool) (*Chat, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
chat, err := s.db.getChatWithOptions(id, loadAttachmentData)
if err != nil {
return nil, fmt.Errorf("%w: chat %s", not.Found, id)
}
return chat, nil
}
func (s *Store) SetChat(chat Chat) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.saveChat(chat)
}
func (s *Store) DeleteChat(id string) error {
if err := s.ensureDB(); err != nil {
return err
}
// Delete from database
if err := s.db.deleteChat(id); err != nil {
return fmt.Errorf("%w: chat %s", not.Found, id)
}
// Also delete associated images
chatImgDir := filepath.Join(s.ImgDir(), id)
if err := os.RemoveAll(chatImgDir); err != nil {
// Log error but don't fail the deletion
slog.Warn("failed to delete chat images", "chat_id", id, "error", err)
}
return nil
}
func (s *Store) WindowSize() (int, int, error) {
if err := s.ensureDB(); err != nil {
return 0, 0, err
}
return s.db.getWindowSize()
}
func (s *Store) SetWindowSize(width, height int) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setWindowSize(width, height)
}
func (s *Store) UpdateLastMessage(chatID string, message Message) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.updateLastMessage(chatID, message)
}
func (s *Store) AppendMessage(chatID string, message Message) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.appendMessage(chatID, message)
}
func (s *Store) UpdateChatBrowserState(chatID string, state json.RawMessage) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.updateChatBrowserState(chatID, state)
}
func (s *Store) User() (*User, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
return s.db.getUser()
}
func (s *Store) SetUser(user User) error {
if err := s.ensureDB(); err != nil {
return err
}
user.CachedAt = time.Now()
return s.db.setUser(user)
}
func (s *Store) ClearUser() error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.clearUser()
}
func (s *Store) Close() error {
s.dbMu.Lock()
defer s.dbMu.Unlock()
if s.db != nil {
return s.db.Close()
}
return nil
}

View File

@@ -1,13 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
// TODO - system wide location?
home := os.Getenv("HOME")
return filepath.Join(home, "Library", "Application Support", "Ollama", "config.json")
}

View File

@@ -1,16 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
if os.Geteuid() == 0 {
// TODO where should we store this on linux for system-wide operation?
return "/etc/ollama/config.json"
}
home := os.Getenv("HOME")
return filepath.Join(home, ".ollama", "config.json")
}

192
app/store/store_test.go Normal file
View File

@@ -0,0 +1,192 @@
//go:build windows || darwin
package store
import (
"path/filepath"
"testing"
)
func TestStore(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
t.Run("default id", func(t *testing.T) {
// ID should be automatically generated
id, err := s.ID()
if err != nil {
t.Fatal(err)
}
if id == "" {
t.Error("expected non-empty ID")
}
// Verify ID is persisted
id2, err := s.ID()
if err != nil {
t.Fatal(err)
}
if id != id2 {
t.Errorf("expected ID %s, got %s", id, id2)
}
})
t.Run("has completed first run", func(t *testing.T) {
// Default should be false (hasn't completed first run yet)
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatal(err)
}
if hasCompleted {
t.Error("expected has completed first run to be false by default")
}
if err := s.SetHasCompletedFirstRun(true); err != nil {
t.Fatal(err)
}
hasCompleted, err = s.HasCompletedFirstRun()
if err != nil {
t.Fatal(err)
}
if !hasCompleted {
t.Error("expected has completed first run to be true")
}
})
t.Run("settings", func(t *testing.T) {
sc := Settings{
Expose: true,
Browser: true,
Survey: true,
Models: "/tmp/models",
Agent: true,
Tools: false,
WorkingDir: "/tmp/work",
}
if err := s.SetSettings(sc); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
// Compare fields individually since Models might get a default
if loaded.Expose != sc.Expose || loaded.Browser != sc.Browser ||
loaded.Agent != sc.Agent || loaded.Survey != sc.Survey ||
loaded.Tools != sc.Tools || loaded.WorkingDir != sc.WorkingDir {
t.Errorf("expected %v, got %v", sc, loaded)
}
})
t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err)
}
width, height, err := s.WindowSize()
if err != nil {
t.Fatal(err)
}
if width != 1024 || height != 768 {
t.Errorf("expected 1024x768, got %dx%d", width, height)
}
})
t.Run("create and retrieve chat", func(t *testing.T) {
chat := NewChat("test-chat-1")
chat.Title = "Test Chat"
chat.Messages = append(chat.Messages, NewMessage("user", "Hello", nil))
chat.Messages = append(chat.Messages, NewMessage("assistant", "Hi there!", &MessageOptions{
Model: "llama4",
}))
if err := s.SetChat(*chat); err != nil {
t.Fatalf("failed to save chat: %v", err)
}
retrieved, err := s.Chat("test-chat-1")
if err != nil {
t.Fatalf("failed to retrieve chat: %v", err)
}
if retrieved.ID != chat.ID {
t.Errorf("expected ID %s, got %s", chat.ID, retrieved.ID)
}
if retrieved.Title != chat.Title {
t.Errorf("expected title %s, got %s", chat.Title, retrieved.Title)
}
if len(retrieved.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(retrieved.Messages))
}
if retrieved.Messages[0].Content != "Hello" {
t.Errorf("expected first message 'Hello', got %s", retrieved.Messages[0].Content)
}
if retrieved.Messages[1].Content != "Hi there!" {
t.Errorf("expected second message 'Hi there!', got %s", retrieved.Messages[1].Content)
}
})
t.Run("list chats", func(t *testing.T) {
chat2 := NewChat("test-chat-2")
chat2.Title = "Another Chat"
chat2.Messages = append(chat2.Messages, NewMessage("user", "Test", nil))
if err := s.SetChat(*chat2); err != nil {
t.Fatalf("failed to save chat: %v", err)
}
chats, err := s.Chats()
if err != nil {
t.Fatalf("failed to list chats: %v", err)
}
if len(chats) != 2 {
t.Fatalf("expected 2 chats, got %d", len(chats))
}
})
t.Run("delete chat", func(t *testing.T) {
if err := s.DeleteChat("test-chat-1"); err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify it's gone
_, err := s.Chat("test-chat-1")
if err == nil {
t.Error("expected error retrieving deleted chat")
}
// Verify other chat still exists
chats, err := s.Chats()
if err != nil {
t.Fatalf("failed to list chats: %v", err)
}
if len(chats) != 1 {
t.Fatalf("expected 1 chat after deletion, got %d", len(chats))
}
})
}
// setupTestStore creates a temporary store for testing
func setupTestStore(t *testing.T) (*Store, func()) {
t.Helper()
tmpDir := t.TempDir()
// Override legacy config path to ensure no migration happens
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
s := &Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
cleanup := func() {
s.Close()
legacyConfigPath = oldLegacyConfigPath
}
return s, cleanup
}

View File

@@ -1,11 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
localAppData := os.Getenv("LOCALAPPDATA")
return filepath.Join(localAppData, "Ollama", "config.json")
}

61
app/store/testdata/schema.sql vendored Normal file
View File

@@ -0,0 +1,61 @@
-- This is the version 2 schema for the app database, the first released schema to users.
-- Do not modify this file. It is used to test that the database schema stays in a consistent state between schema migrations.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
survey BOOLEAN NOT NULL DEFAULT TRUE,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
context_length INTEGER NOT NULL DEFAULT 4096,
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 2
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);

863
app/tools/browser.go Normal file
View File

@@ -0,0 +1,863 @@
//go:build windows || darwin
package tools
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/ollama/ollama/app/ui/responses"
)
type PageType string
const (
PageTypeSearchResults PageType = "initial_results"
PageTypeWebpage PageType = "webpage"
)
// DefaultViewTokens is the number of tokens to show to the model used when calling displayPage
const DefaultViewTokens = 1024
/*
The Browser tool provides web browsing capability for gpt-oss.
The model uses the tool by usually doing a search first and then choosing to either open a page,
find a term in a page, or do another search.
The tool optionally may open a URL directly - especially if one is passed in.
Each action is saved into an append-only page stack `responses.BrowserStateData` to keep
track of the history of the browsing session.
Each `Execute()` for a tool returns the full current state of the browser. ui.go manages the
browser state representation between the tool, ui, and db.
A new Browser object is created per request - the state is reconstructed by ui.go.
The initialization of the browser will receive a `responses.BrowserStateData` with the stitched history.
*/
// BrowserState manages the browsing session on a per-chat basis
type BrowserState struct {
mu sync.RWMutex
Data *responses.BrowserStateData
}
type Browser struct {
state *BrowserState
}
// State is only accessed in a single thread, as each chat has its own browser state
func (b *Browser) State() *responses.BrowserStateData {
b.state.mu.RLock()
defer b.state.mu.RUnlock()
return b.state.Data
}
func (b *Browser) savePage(page *responses.Page) {
b.state.Data.URLToPage[page.URL] = page
b.state.Data.PageStack = append(b.state.Data.PageStack, page.URL)
}
func (b *Browser) getPageFromStack(url string) (*responses.Page, error) {
page, ok := b.state.Data.URLToPage[url]
if !ok {
return nil, fmt.Errorf("page not found for url %s", url)
}
return page, nil
}
func NewBrowser(state *responses.BrowserStateData) *Browser {
if state == nil {
state = &responses.BrowserStateData{
PageStack: []string{},
ViewTokens: DefaultViewTokens,
URLToPage: make(map[string]*responses.Page),
}
}
b := &BrowserState{
Data: state,
}
return &Browser{
state: b,
}
}
type BrowserSearch struct {
Browser
webSearch *BrowserWebSearch
}
// NewBrowserSearch creates a new browser search instance
func NewBrowserSearch(bb *Browser) *BrowserSearch {
if bb == nil {
bb = &Browser{
state: &BrowserState{
Data: &responses.BrowserStateData{
PageStack: []string{},
ViewTokens: DefaultViewTokens,
URLToPage: make(map[string]*responses.Page),
},
},
}
}
return &BrowserSearch{
Browser: *bb,
webSearch: &BrowserWebSearch{},
}
}
func (b *BrowserSearch) Name() string {
return "browser.search"
}
func (b *BrowserSearch) Description() string {
return "Search the web for information"
}
func (b *BrowserSearch) Prompt() string {
return ""
}
func (b *BrowserSearch) Schema() map[string]any {
return map[string]any{}
}
func (b *BrowserSearch) Execute(ctx context.Context, args map[string]any) (any, string, error) {
query, ok := args["query"].(string)
if !ok {
return nil, "", fmt.Errorf("query parameter is required")
}
topn, ok := args["topn"].(int)
if !ok {
topn = 5
}
searchArgs := map[string]any{
"queries": []any{query},
"max_results": topn,
}
result, err := b.webSearch.Execute(ctx, searchArgs)
if err != nil {
return nil, "", fmt.Errorf("search error: %w", err)
}
searchResponse, ok := result.(*WebSearchResponse)
if !ok {
return nil, "", fmt.Errorf("invalid search results format")
}
// Build main search results page that contains all search results
searchResultsPage := b.buildSearchResultsPageCollection(query, searchResponse)
b.savePage(searchResultsPage)
cursor := len(b.state.Data.PageStack) - 1
// cache result for each page
for _, queryResults := range searchResponse.Results {
for i, result := range queryResults {
resultPage := b.buildSearchResultsPage(&result, i+1)
// save to global only, do not add to visited stack
b.state.Data.URLToPage[resultPage.URL] = resultPage
}
}
page := searchResultsPage
pageText, err := b.displayPage(page, cursor, 0, -1)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
func (b *Browser) buildSearchResultsPageCollection(query string, results *WebSearchResponse) *responses.Page {
page := &responses.Page{
URL: "search_results_" + query,
Title: query,
Links: make(map[int]string),
FetchedAt: time.Now(),
}
var textBuilder strings.Builder
linkIdx := 0
// Add the header lines to match format
textBuilder.WriteString("\n") // L0: empty
textBuilder.WriteString("URL: \n") // L1: URL: (empty for search)
textBuilder.WriteString("# Search Results\n") // L2: # Search Results
textBuilder.WriteString("\n") // L3: empty
for _, queryResults := range results.Results {
for _, result := range queryResults {
domain := result.URL
if u, err := url.Parse(result.URL); err == nil && u.Host != "" {
domain = u.Host
domain = strings.TrimPrefix(domain, "www.")
}
linkFormat := fmt.Sprintf("* 【%d†%s†%s】", linkIdx, result.Title, domain)
textBuilder.WriteString(linkFormat)
numChars := min(len(result.Content.FullText), 400)
snippet := strings.TrimSpace(result.Content.FullText[:numChars])
textBuilder.WriteString(snippet)
textBuilder.WriteString("\n")
page.Links[linkIdx] = result.URL
linkIdx++
}
}
page.Text = textBuilder.String()
page.Lines = wrapLines(page.Text, 80)
return page
}
func (b *Browser) buildSearchResultsPage(result *WebSearchResult, linkIdx int) *responses.Page {
page := &responses.Page{
URL: result.URL,
Title: result.Title,
Links: make(map[int]string),
FetchedAt: time.Now(),
}
var textBuilder strings.Builder
// Format the individual result page (only used when no full text is available)
linkFormat := fmt.Sprintf("【%d†%s】", linkIdx, result.Title)
textBuilder.WriteString(linkFormat)
textBuilder.WriteString("\n")
textBuilder.WriteString(fmt.Sprintf("URL: %s\n", result.URL))
numChars := min(len(result.Content.FullText), 300)
textBuilder.WriteString(result.Content.FullText[:numChars])
textBuilder.WriteString("\n\n")
// Only store link and snippet if we won't be processing full text later
// (full text processing will handle all links consistently)
if result.Content.FullText == "" {
page.Links[linkIdx] = result.URL
}
// Use full text if available, otherwise use snippet
if result.Content.FullText != "" {
// Prepend the URL line to the full text
page.Text = fmt.Sprintf("URL: %s\n%s", result.URL, result.Content.FullText)
// Process markdown links in the full text
processedText, processedLinks := processMarkdownLinks(page.Text)
page.Text = processedText
page.Links = processedLinks
} else {
page.Text = textBuilder.String()
}
page.Lines = wrapLines(page.Text, 80)
return page
}
// getEndLoc calculates the end location for viewport based on token limits
func (b *Browser) getEndLoc(loc, numLines, totalLines int, lines []string) int {
if numLines <= 0 {
// Auto-calculate based on viewTokens
txt := b.joinLinesWithNumbers(lines[loc:])
// If text is very short, no need to truncate (at least 1 char per token)
if len(txt) > b.state.Data.ViewTokens {
// Simple heuristic: approximate token counting
// Typical token is ~4 characters, but can be up to 128 chars
maxCharsPerToken := 128
// upper bound for text to analyze
upperBound := min((b.state.Data.ViewTokens+1)*maxCharsPerToken, len(txt))
textToAnalyze := txt[:upperBound]
// Simple approximation: count tokens as ~4 chars each
// This is less accurate than tiktoken but more performant
approxTokens := len(textToAnalyze) / 4
if approxTokens > b.state.Data.ViewTokens {
// Find the character position at viewTokens
endIdx := min(b.state.Data.ViewTokens*4, len(txt))
// Count newlines up to that position to get line count
numLines = strings.Count(txt[:endIdx], "\n") + 1
} else {
numLines = totalLines
}
} else {
numLines = totalLines
}
}
return min(loc+numLines, totalLines)
}
// joinLinesWithNumbers creates a string with line numbers, matching Python's join_lines
func (b *Browser) joinLinesWithNumbers(lines []string) string {
var builder strings.Builder
var hadZeroLine bool
for i, line := range lines {
if i == 0 {
builder.WriteString("L0:\n")
hadZeroLine = true
}
if hadZeroLine {
builder.WriteString(fmt.Sprintf("L%d: %s\n", i+1, line))
} else {
builder.WriteString(fmt.Sprintf("L%d: %s\n", i, line))
}
}
return builder.String()
}
// processMarkdownLinks finds all markdown links in the text and replaces them with the special format
// Returns the processed text and a map of link IDs to URLs
func processMarkdownLinks(text string) (string, map[int]string) {
links := make(map[int]string)
// Always start from 0 for consistent numbering across all pages
linkID := 0
// First, handle multi-line markdown links by joining them
// This regex finds markdown links that might be split across lines
multiLinePattern := regexp.MustCompile(`\[([^\]]+)\]\s*\n\s*\(([^)]+)\)`)
text = multiLinePattern.ReplaceAllStringFunc(text, func(match string) string {
// Replace newlines with spaces in the match
cleaned := strings.ReplaceAll(match, "\n", " ")
// Remove extra spaces
cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ")
return cleaned
})
// Now process all markdown links (including the cleaned multi-line ones)
linkPattern := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
processedText := linkPattern.ReplaceAllStringFunc(text, func(match string) string {
matches := linkPattern.FindStringSubmatch(match)
if len(matches) != 3 {
return match
}
linkText := strings.TrimSpace(matches[1])
linkURL := strings.TrimSpace(matches[2])
// Extract domain from URL
domain := linkURL
if u, err := url.Parse(linkURL); err == nil && u.Host != "" {
domain = u.Host
// Remove www. prefix if present
domain = strings.TrimPrefix(domain, "www.")
}
// Create the formatted link
formatted := fmt.Sprintf("【%d†%s†%s】", linkID, linkText, domain)
// Store the link
links[linkID] = linkURL
linkID++
return formatted
})
return processedText, links
}
func wrapLines(text string, width int) []string {
if width <= 0 {
width = 80
}
lines := strings.Split(text, "\n")
var wrapped []string
for _, line := range lines {
if line == "" {
// Preserve empty lines
wrapped = append(wrapped, "")
} else if len(line) <= width {
wrapped = append(wrapped, line)
} else {
// Word wrapping while preserving whitespace structure
words := strings.Fields(line)
if len(words) == 0 {
// Line with only whitespace
wrapped = append(wrapped, line)
continue
}
currentLine := ""
for _, word := range words {
// Check if adding this word would exceed width
testLine := currentLine
if testLine != "" {
testLine += " "
}
testLine += word
if len(testLine) > width && currentLine != "" {
// Current line would be too long, wrap it
wrapped = append(wrapped, currentLine)
currentLine = word
} else {
// Add word to current line
if currentLine != "" {
currentLine += " "
}
currentLine += word
}
}
// Add any remaining content
if currentLine != "" {
wrapped = append(wrapped, currentLine)
}
}
}
return wrapped
}
// displayPage formats and returns the page display for the model
func (b *Browser) displayPage(page *responses.Page, cursor, loc, numLines int) (string, error) {
totalLines := len(page.Lines)
if loc >= totalLines {
return "", fmt.Errorf("invalid location: %d (max: %d)", loc, totalLines-1)
}
// get viewport end location
endLoc := b.getEndLoc(loc, numLines, totalLines, page.Lines)
var displayBuilder strings.Builder
displayBuilder.WriteString(fmt.Sprintf("[%d] %s", cursor, page.Title))
if page.URL != "" {
displayBuilder.WriteString(fmt.Sprintf("(%s)\n", page.URL))
} else {
displayBuilder.WriteString("\n")
}
displayBuilder.WriteString(fmt.Sprintf("**viewing lines [%d - %d] of %d**\n\n", loc, endLoc-1, totalLines-1))
// Content with line numbers
var hadZeroLine bool
for i := loc; i < endLoc; i++ {
if i == 0 {
displayBuilder.WriteString("L0:\n")
hadZeroLine = true
}
if hadZeroLine {
displayBuilder.WriteString(fmt.Sprintf("L%d: %s\n", i+1, page.Lines[i]))
} else {
displayBuilder.WriteString(fmt.Sprintf("L%d: %s\n", i, page.Lines[i]))
}
}
return displayBuilder.String(), nil
}
type BrowserOpen struct {
Browser
crawlPage *BrowserCrawler
}
func NewBrowserOpen(bb *Browser) *BrowserOpen {
if bb == nil {
bb = &Browser{
state: &BrowserState{
Data: &responses.BrowserStateData{
PageStack: []string{},
ViewTokens: DefaultViewTokens,
URLToPage: make(map[string]*responses.Page),
},
},
}
}
return &BrowserOpen{
Browser: *bb,
crawlPage: &BrowserCrawler{},
}
}
func (b *BrowserOpen) Name() string {
return "browser.open"
}
func (b *BrowserOpen) Description() string {
return "Open a link in the browser"
}
func (b *BrowserOpen) Prompt() string {
return ""
}
func (b *BrowserOpen) Schema() map[string]any {
return map[string]any{}
}
func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, string, error) {
// Get cursor parameter first
cursor := -1
if c, ok := args["cursor"].(float64); ok {
cursor = int(c)
} else if c, ok := args["cursor"].(int); ok {
cursor = c
}
// Get loc parameter
loc := 0
if l, ok := args["loc"].(float64); ok {
loc = int(l)
} else if l, ok := args["loc"].(int); ok {
loc = l
}
// Get num_lines parameter
numLines := -1
if n, ok := args["num_lines"].(float64); ok {
numLines = int(n)
} else if n, ok := args["num_lines"].(int); ok {
numLines = n
}
// get page from cursor
var page *responses.Page
if cursor >= 0 {
if cursor >= len(b.state.Data.PageStack) {
return nil, "", fmt.Errorf("cursor %d is out of range (pageStack length: %d)", cursor, len(b.state.Data.PageStack))
}
var err error
page, err = b.getPageFromStack(b.state.Data.PageStack[cursor])
if err != nil {
return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err)
}
} else {
// get last page
if len(b.state.Data.PageStack) != 0 {
pageURL := b.state.Data.PageStack[len(b.state.Data.PageStack)-1]
var err error
page, err = b.getPageFromStack(pageURL)
if err != nil {
return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err)
}
}
}
// Try to get id as string (URL) first
if url, ok := args["id"].(string); ok {
// Check if we already have this page cached
if existingPage, ok := b.state.Data.URLToPage[url]; ok {
// Use cached page
b.savePage(existingPage)
// Always update cursor to point to the newly added page
cursor = len(b.state.Data.PageStack) - 1
pageText, err := b.displayPage(existingPage, cursor, loc, numLines)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
// Page not in cache, need to crawl it
if b.crawlPage == nil {
b.crawlPage = &BrowserCrawler{}
}
crawlResponse, err := b.crawlPage.Execute(ctx, map[string]any{
"urls": []any{url},
"latest": false,
})
if err != nil {
return nil, "", fmt.Errorf("failed to crawl URL %s: %w", url, err)
}
newPage, err := b.buildPageFromCrawlResult(url, crawlResponse)
if err != nil {
return nil, "", fmt.Errorf("failed to build page from crawl result: %w", err)
}
// Need to fall through if first search is directly an open command - no existing page
b.savePage(newPage)
// Always update cursor to point to the newly added page
cursor = len(b.state.Data.PageStack) - 1
pageText, err := b.displayPage(newPage, cursor, loc, numLines)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
// Try to get id as integer (link ID from current page)
if id, ok := args["id"].(float64); ok {
if page == nil {
return nil, "", fmt.Errorf("no current page to resolve link from")
}
idInt := int(id)
pageURL, ok := page.Links[idInt]
if !ok {
return nil, "", fmt.Errorf("invalid link id %d", idInt)
}
// Check if we have the linked page cached
newPage, ok := b.state.Data.URLToPage[pageURL]
if !ok {
if b.crawlPage == nil {
b.crawlPage = &BrowserCrawler{}
}
crawlResponse, err := b.crawlPage.Execute(ctx, map[string]any{
"urls": []any{pageURL},
"latest": false,
})
if err != nil {
return nil, "", fmt.Errorf("failed to crawl URL %s: %w", pageURL, err)
}
// Create new page from crawl result
newPage, err = b.buildPageFromCrawlResult(pageURL, crawlResponse)
if err != nil {
return nil, "", fmt.Errorf("failed to build page from crawl result: %w", err)
}
}
// Add to history stack regardless of cache status
b.savePage(newPage)
// Always update cursor to point to the newly added page
cursor = len(b.state.Data.PageStack) - 1
pageText, err := b.displayPage(newPage, cursor, loc, numLines)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
// If no id provided, just display current page
if page == nil {
return nil, "", fmt.Errorf("no current page to display")
}
// Only add to PageStack without updating URLToPage
b.state.Data.PageStack = append(b.state.Data.PageStack, page.URL)
cursor = len(b.state.Data.PageStack) - 1
pageText, err := b.displayPage(page, cursor, loc, numLines)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
// buildPageFromCrawlResult creates a Page from crawl API results
func (b *Browser) buildPageFromCrawlResult(requestedURL string, crawlResponse *CrawlResponse) (*responses.Page, error) {
// Initialize page with defaults
page := &responses.Page{
URL: requestedURL,
Title: requestedURL,
Text: "",
Links: make(map[int]string),
FetchedAt: time.Now(),
}
// Process crawl results - the API returns results grouped by URL
for url, urlResults := range crawlResponse.Results {
if len(urlResults) > 0 {
// Get the first result for this URL
result := urlResults[0]
// Extract content
if result.Content.FullText != "" {
page.Text = result.Content.FullText
}
// Extract title if available
if result.Title != "" {
page.Title = result.Title
}
// Update URL to the actual URL from results
page.URL = url
// Extract links if available from extras
for i, link := range result.Extras.Links {
if link.Href != "" {
page.Links[i] = link.Href
} else if link.URL != "" {
page.Links[i] = link.URL
}
}
// Only process the first URL's results
break
}
}
// If no text was extracted, set a default message
if page.Text == "" {
page.Text = "No content could be extracted from this page."
} else {
// Prepend the URL line to match Python implementation
page.Text = fmt.Sprintf("URL: %s\n%s", page.URL, page.Text)
}
// Process markdown links in the text
processedText, processedLinks := processMarkdownLinks(page.Text)
page.Text = processedText
page.Links = processedLinks
// Wrap lines for display
page.Lines = wrapLines(page.Text, 80)
return page, nil
}
type BrowserFind struct {
Browser
}
func NewBrowserFind(bb *Browser) *BrowserFind {
return &BrowserFind{
Browser: *bb,
}
}
func (b *BrowserFind) Name() string {
return "browser.find"
}
func (b *BrowserFind) Description() string {
return "Find a term in the browser"
}
func (b *BrowserFind) Prompt() string {
return ""
}
func (b *BrowserFind) Schema() map[string]any {
return map[string]any{}
}
func (b *BrowserFind) Execute(ctx context.Context, args map[string]any) (any, string, error) {
pattern, ok := args["pattern"].(string)
if !ok {
return nil, "", fmt.Errorf("pattern parameter is required")
}
// Get cursor parameter if provided, default to current page
cursor := -1
if c, ok := args["cursor"].(float64); ok {
cursor = int(c)
}
// Get the page to search in
var page *responses.Page
if cursor == -1 {
// Use current page
if len(b.state.Data.PageStack) == 0 {
return nil, "", fmt.Errorf("no pages to search in")
}
var err error
page, err = b.getPageFromStack(b.state.Data.PageStack[len(b.state.Data.PageStack)-1])
if err != nil {
return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err)
}
} else {
// Use specific cursor
if cursor < 0 || cursor >= len(b.state.Data.PageStack) {
return nil, "", fmt.Errorf("cursor %d is out of range [0-%d]", cursor, len(b.state.Data.PageStack)-1)
}
var err error
page, err = b.getPageFromStack(b.state.Data.PageStack[cursor])
if err != nil {
return nil, "", fmt.Errorf("page not found for cursor %d: %w", cursor, err)
}
}
if page == nil {
return nil, "", fmt.Errorf("page not found")
}
// Create find results page
findPage := b.buildFindResultsPage(pattern, page)
// Add the find results page to state
b.savePage(findPage)
newCursor := len(b.state.Data.PageStack) - 1
pageText, err := b.displayPage(findPage, newCursor, 0, -1)
if err != nil {
return nil, "", fmt.Errorf("failed to display page: %w", err)
}
return b.state.Data, pageText, nil
}
func (b *Browser) buildFindResultsPage(pattern string, page *responses.Page) *responses.Page {
findPage := &responses.Page{
Title: fmt.Sprintf("Find results for text: `%s` in `%s`", pattern, page.Title),
Links: make(map[int]string),
FetchedAt: time.Now(),
}
findPage.URL = fmt.Sprintf("find_results_%s", pattern)
var textBuilder strings.Builder
matchIdx := 0
maxResults := 50
numShowLines := 4
patternLower := strings.ToLower(pattern)
// Search through the page lines following the reference algorithm
var resultChunks []string
lineIdx := 0
for lineIdx < len(page.Lines) {
line := page.Lines[lineIdx]
lineLower := strings.ToLower(line)
if !strings.Contains(lineLower, patternLower) {
lineIdx++
continue
}
// Build snippet context
endLine := min(lineIdx+numShowLines, len(page.Lines))
var snippetBuilder strings.Builder
for j := lineIdx; j < endLine; j++ {
snippetBuilder.WriteString(page.Lines[j])
if j < endLine-1 {
snippetBuilder.WriteString("\n")
}
}
snippet := snippetBuilder.String()
// Format the match
linkFormat := fmt.Sprintf("【%d†match at L%d】", matchIdx, lineIdx)
resultChunk := fmt.Sprintf("%s\n%s", linkFormat, snippet)
resultChunks = append(resultChunks, resultChunk)
if len(resultChunks) >= maxResults {
break
}
matchIdx++
lineIdx += numShowLines
}
// Build final display text
if len(resultChunks) > 0 {
textBuilder.WriteString(strings.Join(resultChunks, "\n\n"))
}
if matchIdx == 0 {
findPage.Text = fmt.Sprintf("No `find` results for pattern: `%s`", pattern)
} else {
findPage.Text = textBuilder.String()
}
findPage.Lines = wrapLines(findPage.Text, 80)
return findPage
}

136
app/tools/browser_crawl.go Normal file
View File

@@ -0,0 +1,136 @@
//go:build windows || darwin
package tools
import (
"context"
"encoding/json"
"fmt"
)
// CrawlContent represents the content of a crawled page
type CrawlContent struct {
Snippet string `json:"snippet"`
FullText string `json:"full_text"`
}
// CrawlExtras represents additional data from the crawl API
type CrawlExtras struct {
Links []CrawlLink `json:"links"`
}
// CrawlLink represents a link found on a crawled page
type CrawlLink struct {
URL string `json:"url"`
Href string `json:"href"`
Text string `json:"text"`
}
// CrawlResult represents a single crawl result
type CrawlResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content CrawlContent `json:"content"`
Extras CrawlExtras `json:"extras"`
}
// CrawlResponse represents the complete response from the crawl API
type CrawlResponse struct {
Results map[string][]CrawlResult `json:"results"`
}
// BrowserCrawler tool for crawling web pages using ollama.com crawl API
type BrowserCrawler struct{}
func (g *BrowserCrawler) Name() string {
return "get_webpage"
}
func (g *BrowserCrawler) Description() string {
return "Crawl and extract text content from web pages"
}
func (g *BrowserCrawler) Prompt() string {
return `When you need to read content from web pages, use the get_webpage tool. Simply provide the URLs you want to read and I'll fetch their content for you.
For each URL, I'll extract the main text content in a readable format. If you need to discover links within those pages, set extract_links to true. If the user requires the latest information, set livecrawl to true.
Only use this tool when you need to access current web content. Make sure the URLs are valid and accessible. Do not use this tool for:
- Downloading files or media
- Accessing private/authenticated pages
- Scraping data at high volumes
Always check the returned content to ensure it's relevant before using it in your response.`
}
func (g *BrowserCrawler) Schema() map[string]any {
schemaBytes := []byte(`{
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of URLs to crawl and extract content from"
}
},
"required": ["urls"]
}`)
var schema map[string]any
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
return nil
}
return schema
}
func (g *BrowserCrawler) Execute(ctx context.Context, args map[string]any) (*CrawlResponse, error) {
urlsRaw, ok := args["urls"].([]any)
if !ok {
return nil, fmt.Errorf("urls parameter is required and must be an array of strings")
}
urls := make([]string, 0, len(urlsRaw))
for _, u := range urlsRaw {
if urlStr, ok := u.(string); ok {
urls = append(urls, urlStr)
}
}
if len(urls) == 0 {
return nil, fmt.Errorf("at least one URL is required")
}
return g.performWebCrawl(ctx, urls)
}
// performWebCrawl handles the actual HTTP request to ollama.com crawl API
func (g *BrowserCrawler) performWebCrawl(ctx context.Context, urls []string) (*CrawlResponse, error) {
result := &CrawlResponse{Results: make(map[string][]CrawlResult, len(urls))}
for _, targetURL := range urls {
fetchResp, err := performWebFetch(ctx, targetURL)
if err != nil {
return nil, fmt.Errorf("web_fetch failed for %q: %w", targetURL, err)
}
links := make([]CrawlLink, 0, len(fetchResp.Links))
for _, link := range fetchResp.Links {
links = append(links, CrawlLink{URL: link, Href: link})
}
snippet := truncateString(fetchResp.Content, 400)
result.Results[targetURL] = []CrawlResult{{
Title: fetchResp.Title,
URL: targetURL,
Content: CrawlContent{
Snippet: snippet,
FullText: fetchResp.Content,
},
Extras: CrawlExtras{Links: links},
}}
}
return result, nil
}

147
app/tools/browser_test.go Normal file
View File

@@ -0,0 +1,147 @@
//go:build windows || darwin
package tools
import (
"strings"
"testing"
"time"
"github.com/ollama/ollama/app/ui/responses"
)
func makeTestPage(url string) *responses.Page {
return &responses.Page{
URL: url,
Title: "Title " + url,
Text: "Body for " + url,
Lines: []string{"line1", "line2", "line3"},
Links: map[int]string{0: url},
FetchedAt: time.Now(),
}
}
func TestBrowser_Scroll_AppendsOnlyPageStack(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
p1 := makeTestPage("https://example.com/1")
b.savePage(p1)
initialStackLen := len(b.state.Data.PageStack)
initialMapLen := len(b.state.Data.URLToPage)
bo := NewBrowserOpen(b)
// Scroll without id — should push only to PageStack
_, _, err := bo.Execute(t.Context(), map[string]any{"loc": float64(1), "num_lines": float64(1)})
if err != nil {
t.Fatalf("scroll execute failed: %v", err)
}
if got, want := len(b.state.Data.PageStack), initialStackLen+1; got != want {
t.Fatalf("page stack length = %d, want %d", got, want)
}
if got, want := len(b.state.Data.URLToPage), initialMapLen; got != want {
t.Fatalf("url_to_page length changed = %d, want %d", got, want)
}
}
func TestBrowserOpen_UseCacheByURL(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
bo := NewBrowserOpen(b)
p := makeTestPage("https://example.com/cached")
b.state.Data.URLToPage[p.URL] = p
initialStackLen := len(b.state.Data.PageStack)
initialMapLen := len(b.state.Data.URLToPage)
_, _, err := bo.Execute(t.Context(), map[string]any{"id": p.URL})
if err != nil {
t.Fatalf("open cached execute failed: %v", err)
}
if got, want := len(b.state.Data.PageStack), initialStackLen+1; got != want {
t.Fatalf("page stack length = %d, want %d", got, want)
}
if got, want := len(b.state.Data.URLToPage), initialMapLen; got != want {
t.Fatalf("url_to_page length changed = %d, want %d", got, want)
}
}
func TestDisplayPage_InvalidLoc(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
p := makeTestPage("https://example.com/x")
// ensure lines are set
p.Lines = []string{"a", "b"}
_, err := b.displayPage(p, 0, 10, -1)
if err == nil || !strings.Contains(err.Error(), "invalid location") {
t.Fatalf("expected invalid location error, got %v", err)
}
}
func TestBrowserOpen_LinkId_UsesCacheAndAppends(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
// Seed a main page with a link id 0 to a linked URL
main := makeTestPage("https://example.com/main")
linked := makeTestPage("https://example.com/linked")
main.Links = map[int]string{0: linked.URL}
// Save the main page (adds to PageStack and URLToPage)
b.savePage(main)
// Pre-cache the linked page so open by id avoids network
b.state.Data.URLToPage[linked.URL] = linked
initialStackLen := len(b.state.Data.PageStack)
initialMapLen := len(b.state.Data.URLToPage)
bo := NewBrowserOpen(b)
_, _, err := bo.Execute(t.Context(), map[string]any{"id": float64(0)})
if err != nil {
t.Fatalf("open by link id failed: %v", err)
}
if got, want := len(b.state.Data.PageStack), initialStackLen+1; got != want {
t.Fatalf("page stack length = %d, want %d", got, want)
}
if got, want := len(b.state.Data.URLToPage), initialMapLen; got != want {
t.Fatalf("url_to_page length changed = %d, want %d", got, want)
}
if last := b.state.Data.PageStack[len(b.state.Data.PageStack)-1]; last != linked.URL {
t.Fatalf("last page in stack = %s, want %s", last, linked.URL)
}
}
func TestWrapLines_PreserveAndWidth(t *testing.T) {
long := strings.Repeat("word ", 50)
text := "Line1\n\n" + long + "\nLine3"
lines := wrapLines(text, 40)
// Ensure empty line preserved at index 1
if lines[1] != "" {
t.Fatalf("expected preserved empty line at index 1, got %q", lines[1])
}
// All lines should be <= 40 chars
for i, l := range lines {
if len(l) > 40 {
t.Fatalf("line %d exceeds width: %d > 40", i, len(l))
}
}
}
func TestDisplayPage_FormatHeaderAndLines(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
p := &responses.Page{
URL: "https://example.com/x",
Title: "Example",
Lines: []string{"URL: https://example.com/x", "A", "B", "C"},
}
out, err := b.displayPage(p, 3, 0, 2)
if err != nil {
t.Fatalf("displayPage failed: %v", err)
}
if !strings.HasPrefix(out, "[3] Example(") {
t.Fatalf("header not formatted as expected: %q", out)
}
if !strings.Contains(out, "L0:\n") {
t.Fatalf("missing L0 label: %q", out)
}
if !strings.Contains(out, "L1: URL: https://example.com/x\n") || !strings.Contains(out, "L2: A\n") {
t.Fatalf("missing expected line numbers/content: %q", out)
}
}

View File

@@ -0,0 +1,143 @@
//go:build windows || darwin
package tools
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
)
// WebSearchContent represents the content of a search result
type WebSearchContent struct {
Snippet string `json:"snippet"`
FullText string `json:"full_text"`
}
// WebSearchMetadata represents metadata for a search result
type WebSearchMetadata struct {
PublishedDate *time.Time `json:"published_date,omitempty"`
}
// WebSearchResult represents a single search result
type WebSearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content WebSearchContent `json:"content"`
Metadata WebSearchMetadata `json:"metadata"`
}
// WebSearchResponse represents the complete response from the websearch API
type WebSearchResponse struct {
Results map[string][]WebSearchResult `json:"results"`
}
// BrowserWebSearch tool for searching the web using ollama.com search API
type BrowserWebSearch struct{}
func (w *BrowserWebSearch) Name() string {
return "gpt_oss_web_search"
}
func (w *BrowserWebSearch) Description() string {
return "Search the web for real-time information using ollama.com search API."
}
func (w *BrowserWebSearch) Prompt() string {
return `Use the gpt_oss_web_search tool to search the web.
1. Come up with a list of search queries to get comprehensive information (typically 2-3 related queries work well)
2. Use the gpt_oss_web_search tool with multiple queries to get results organized by query
3. Use the search results to provide current up to date, accurate information
Today's date is ` + time.Now().Format("January 2, 2006") + `
Add "` + time.Now().Format("January 2, 2006") + `" for news queries and ` + strconv.Itoa(time.Now().Year()+1) + ` for other queries that need current information.`
}
func (w *BrowserWebSearch) Schema() map[string]any {
schemaBytes := []byte(`{
"type": "object",
"properties": {
"queries": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of search queries to look up"
},
"max_results": {
"type": "integer",
"description": "Maximum number of results to return per query (default: 2) up to 5",
"default": 2
}
},
"required": ["queries"]
}`)
var schema map[string]any
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
return nil
}
return schema
}
func (w *BrowserWebSearch) Execute(ctx context.Context, args map[string]any) (any, error) {
queriesRaw, ok := args["queries"].([]any)
if !ok {
return nil, fmt.Errorf("queries parameter is required and must be an array of strings")
}
queries := make([]string, 0, len(queriesRaw))
for _, q := range queriesRaw {
if query, ok := q.(string); ok {
queries = append(queries, query)
}
}
if len(queries) == 0 {
return nil, fmt.Errorf("at least one query is required")
}
maxResults := 5
if mr, ok := args["max_results"].(int); ok {
maxResults = mr
}
return w.performWebSearch(ctx, queries, maxResults)
}
// performWebSearch handles the actual HTTP request to ollama.com search API
func (w *BrowserWebSearch) performWebSearch(ctx context.Context, queries []string, maxResults int) (*WebSearchResponse, error) {
response := &WebSearchResponse{Results: make(map[string][]WebSearchResult, len(queries))}
for _, query := range queries {
searchResp, err := performWebSearch(ctx, query, maxResults)
if err != nil {
return nil, fmt.Errorf("web_search failed for %q: %w", query, err)
}
converted := make([]WebSearchResult, 0, len(searchResp.Results))
for _, item := range searchResp.Results {
converted = append(converted, WebSearchResult{
Title: item.Title,
URL: item.URL,
Content: WebSearchContent{
Snippet: truncateString(item.Content, 400),
FullText: item.Content,
},
Metadata: WebSearchMetadata{},
})
}
response.Results[query] = converted
}
return response, nil
}
func truncateString(input string, limit int) string {
if limit <= 0 || len(input) <= limit {
return input
}
return input[:limit]
}

122
app/tools/tools.go Normal file
View File

@@ -0,0 +1,122 @@
//go:build windows || darwin
package tools
import (
"context"
"encoding/json"
"fmt"
)
// Tool defines the interface that all tools must implement
type Tool interface {
// Name returns the unique identifier for the tool
Name() string
// Description returns a human-readable description of what the tool does
Description() string
// Schema returns the JSON schema for the tool's parameters
Schema() map[string]any
// Execute runs the tool with the given arguments and returns result to store in db, and a string result for the model
Execute(ctx context.Context, args map[string]any) (any, string, error)
// Prompt returns a prompt for the tool
Prompt() string
}
// Registry manages the available tools and their execution
type Registry struct {
tools map[string]Tool
workingDir string // Working directory for all tool operations
}
// NewRegistry creates a new tool registry with no tools
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
}
}
// Register adds a tool to the registry
func (r *Registry) Register(tool Tool) {
r.tools[tool.Name()] = tool
}
// Get retrieves a tool by name
func (r *Registry) Get(name string) (Tool, bool) {
tool, exists := r.tools[name]
return tool, exists
}
// List returns all available tools
func (r *Registry) List() []Tool {
tools := make([]Tool, 0, len(r.tools))
for _, tool := range r.tools {
tools = append(tools, tool)
}
return tools
}
// SetWorkingDir sets the working directory for all tool operations
func (r *Registry) SetWorkingDir(dir string) {
r.workingDir = dir
}
// Execute runs a tool with the given name and arguments
func (r *Registry) Execute(ctx context.Context, name string, args map[string]any) (any, string, error) {
tool, ok := r.tools[name]
if !ok {
return nil, "", fmt.Errorf("unknown tool: %s", name)
}
result, text, err := tool.Execute(ctx, args)
if err != nil {
return nil, "", err
}
return result, text, nil
}
// ToolCall represents a request to execute a tool
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
// ToolFunction represents the function call details
type ToolFunction struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
ToolCallID string `json:"tool_call_id"`
Content any `json:"content"`
Error string `json:"error,omitempty"`
}
// ToolSchemas returns all tools as schema maps suitable for API calls
func (r *Registry) AvailableTools() []map[string]any {
schemas := make([]map[string]any, 0, len(r.tools))
for _, tool := range r.tools {
schema := map[string]any{
"name": tool.Name(),
"description": tool.Description(),
"schema": tool.Schema(),
}
schemas = append(schemas, schema)
}
return schemas
}
// ToolNames returns a list of all tool names
func (r *Registry) ToolNames() []string {
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
return names
}

128
app/tools/web_fetch.go Normal file
View File

@@ -0,0 +1,128 @@
//go:build windows || darwin
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/auth"
)
type WebFetch struct{}
type FetchRequest struct {
URL string `json:"url"`
}
type FetchResponse struct {
Title string `json:"title"`
Content string `json:"content"`
Links []string `json:"links"`
}
func (w *WebFetch) Name() string {
return "web_fetch"
}
func (w *WebFetch) Description() string {
return "Crawl and extract text content from web pages"
}
func (g *WebFetch) Schema() map[string]any {
schemaBytes := []byte(`{
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to crawl and extract content from"
}
},
"required": ["url"]
}`)
var schema map[string]any
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
return nil
}
return schema
}
func (w *WebFetch) Prompt() string {
return ""
}
func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, string, error) {
urlRaw, ok := args["url"]
if !ok {
return nil, "", fmt.Errorf("url parameter is required")
}
urlStr, ok := urlRaw.(string)
if !ok || strings.TrimSpace(urlStr) == "" {
return nil, "", fmt.Errorf("url must be a non-empty string")
}
result, err := performWebFetch(ctx, urlStr)
if err != nil {
return nil, "", err
}
return result, "", nil
}
func performWebFetch(ctx context.Context, targetURL string) (*FetchResponse, error) {
reqBody := FetchRequest{URL: targetURL}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
crawlURL, err := url.Parse("https://ollama.com/api/web_fetch")
if err != nil {
return nil, fmt.Errorf("failed to parse fetch URL: %w", err)
}
query := crawlURL.Query()
query.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
crawlURL.RawQuery = query.Encode()
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, crawlURL.RequestURI())
signature, err := auth.Sign(ctx, data)
if err != nil {
return nil, fmt.Errorf("failed to sign request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, crawlURL.String(), bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if signature != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature))
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute fetch request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch API error (status %d)", resp.StatusCode)
}
var result FetchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}

145
app/tools/web_search.go Normal file
View File

@@ -0,0 +1,145 @@
//go:build windows || darwin
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/auth"
)
type WebSearch struct{}
type SearchRequest struct {
Query string `json:"query"`
MaxResults int `json:"max_results,omitempty"`
}
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
}
type SearchResponse struct {
Results []SearchResult `json:"results"`
}
func (w *WebSearch) Name() string {
return "web_search"
}
func (w *WebSearch) Description() string {
return "Search the web for real-time information using ollama.com web search API."
}
func (w *WebSearch) Prompt() string {
return ""
}
func (g *WebSearch) Schema() map[string]any {
schemaBytes := []byte(`{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to execute"
},
"max_results": {
"type": "integer",
"description": "Maximum number of search results to return",
"default": 3
}
},
"required": ["query"]
}`)
var schema map[string]any
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
return nil
}
return schema
}
func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, string, error) {
rawQuery, ok := args["query"]
if !ok {
return nil, "", fmt.Errorf("query parameter is required")
}
queryStr, ok := rawQuery.(string)
if !ok || strings.TrimSpace(queryStr) == "" {
return nil, "", fmt.Errorf("query must be a non-empty string")
}
maxResults := 5
if v, ok := args["max_results"].(float64); ok && int(v) > 0 {
maxResults = int(v)
}
result, err := performWebSearch(ctx, queryStr, maxResults)
if err != nil {
return nil, "", err
}
return result, "", nil
}
func performWebSearch(ctx context.Context, query string, maxResults int) (*SearchResponse, error) {
reqBody := SearchRequest{Query: query, MaxResults: maxResults}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
searchURL, err := url.Parse("https://ollama.com/api/web_search")
if err != nil {
return nil, fmt.Errorf("failed to parse search URL: %w", err)
}
q := searchURL.Query()
q.Add("ts", strconv.FormatInt(time.Now().Unix(), 10))
searchURL.RawQuery = q.Encode()
data := fmt.Appendf(nil, "%s,%s", http.MethodPost, searchURL.RequestURI())
signature, err := auth.Sign(ctx, data)
if err != nil {
return nil, fmt.Errorf("failed to sign request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, searchURL.String(), bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if signature != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signature))
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute search request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("search API error (status %d)", resp.StatusCode)
}
var result SearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}

View File

@@ -1,24 +0,0 @@
package commontray
var (
Title = "Ollama"
ToolTip = "Ollama"
UpdateIconName = "tray_upgrade"
IconName = "tray"
)
type Callbacks struct {
Quit chan struct{}
Update chan struct{}
DoFirstUse chan struct{}
ShowLogs chan struct{}
}
type OllamaTray interface {
GetCallbacks() Callbacks
Run()
UpdateAvailable(ver string) error
DisplayFirstUseNotification() error
Quit()
}

View File

@@ -1,28 +0,0 @@
package tray
import (
"fmt"
"runtime"
"github.com/ollama/ollama/app/assets"
"github.com/ollama/ollama/app/tray/commontray"
)
func NewTray() (commontray.OllamaTray, error) {
extension := ".png"
if runtime.GOOS == "windows" {
extension = ".ico"
}
iconName := commontray.UpdateIconName + extension
updateIcon, err := assets.GetIcon(iconName)
if err != nil {
return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err)
}
iconName = commontray.IconName + extension
icon, err := assets.GetIcon(iconName)
if err != nil {
return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err)
}
return InitPlatformTray(icon, updateIcon)
}

View File

@@ -1,13 +0,0 @@
//go:build !windows
package tray
import (
"errors"
"github.com/ollama/ollama/app/tray/commontray"
)
func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) {
return nil, errors.New("not implemented")
}

View File

@@ -1,10 +0,0 @@
package tray
import (
"github.com/ollama/ollama/app/tray/commontray"
"github.com/ollama/ollama/app/tray/wintray"
)
func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) {
return wintray.InitTray(icon, updateIcon)
}

View File

@@ -1,181 +0,0 @@
//go:build windows
package wintray
import (
"fmt"
"log/slog"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
var quitOnce sync.Once
func (t *winTray) Run() {
nativeLoop()
}
func nativeLoop() {
// Main message pump.
slog.Debug("starting event handling loop")
m := &struct {
WindowHandle windows.Handle
Message uint32
Wparam uintptr
Lparam uintptr
Time uint32
Pt point
LPrivate uint32
}{}
for {
ret, _, err := pGetMessage.Call(uintptr(unsafe.Pointer(m)), 0, 0, 0)
// If the function retrieves a message other than WM_QUIT, the return value is nonzero.
// If the function retrieves the WM_QUIT message, the return value is zero.
// If there is an error, the return value is -1
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms644936(v=vs.85).aspx
switch int32(ret) {
case -1:
slog.Error(fmt.Sprintf("get message failure: %v", err))
return
case 0:
return
default:
pTranslateMessage.Call(uintptr(unsafe.Pointer(m))) //nolint:errcheck
pDispatchMessage.Call(uintptr(unsafe.Pointer(m))) //nolint:errcheck
}
}
}
// WindowProc callback function that processes messages sent to a window.
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms633573(v=vs.85).aspx
func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam uintptr) (lResult uintptr) {
const (
WM_RBUTTONUP = 0x0205
WM_LBUTTONUP = 0x0202
WM_COMMAND = 0x0111
WM_ENDSESSION = 0x0016
WM_CLOSE = 0x0010
WM_DESTROY = 0x0002
WM_MOUSEMOVE = 0x0200
WM_LBUTTONDOWN = 0x0201
)
switch message {
case WM_COMMAND:
menuItemId := int32(wParam)
// https://docs.microsoft.com/en-us/windows/win32/menurc/wm-command#menus
switch menuItemId {
case quitMenuID:
select {
case t.callbacks.Quit <- struct{}{}:
// should not happen but in case not listening
default:
slog.Error("no listener on Quit")
}
case updateMenuID:
select {
case t.callbacks.Update <- struct{}{}:
// should not happen but in case not listening
default:
slog.Error("no listener on Update")
}
case diagLogsMenuID:
select {
case t.callbacks.ShowLogs <- struct{}{}:
// should not happen but in case not listening
default:
slog.Error("no listener on ShowLogs")
}
default:
slog.Debug(fmt.Sprintf("Unexpected menu item id: %d", menuItemId))
}
case WM_CLOSE:
boolRet, _, err := pDestroyWindow.Call(uintptr(t.window))
if boolRet == 0 {
slog.Error(fmt.Sprintf("failed to destroy window: %s", err))
}
err = t.wcex.unregister()
if err != nil {
slog.Error(fmt.Sprintf("failed to unregister window %s", err))
}
case WM_DESTROY:
// same as WM_ENDSESSION, but throws 0 exit code after all
defer pPostQuitMessage.Call(uintptr(int32(0))) //nolint:errcheck
fallthrough
case WM_ENDSESSION:
t.muNID.Lock()
if t.nid != nil {
err := t.nid.delete()
if err != nil {
slog.Error(fmt.Sprintf("failed to delete nid: %s", err))
}
}
t.muNID.Unlock()
case t.wmSystrayMessage:
switch lParam {
case WM_MOUSEMOVE, WM_LBUTTONDOWN:
// Ignore these...
case WM_RBUTTONUP, WM_LBUTTONUP:
err := t.showMenu()
if err != nil {
slog.Error(fmt.Sprintf("failed to show menu: %s", err))
}
case 0x405: // TODO - how is this magic value derived for the notification left click
if t.pendingUpdate {
select {
case t.callbacks.Update <- struct{}{}:
// should not happen but in case not listening
default:
slog.Error("no listener on Update")
}
} else {
select {
case t.callbacks.DoFirstUse <- struct{}{}:
// should not happen but in case not listening
default:
slog.Error("no listener on DoFirstUse")
}
}
case 0x404: // Middle click or close notification
// slog.Debug("doing nothing on close of first time notification")
default:
// 0x402 also seems common - what is it?
slog.Debug(fmt.Sprintf("unmanaged app message, lParm: 0x%x", lParam))
}
case t.wmTaskbarCreated: // on explorer.exe restarts
t.muNID.Lock()
err := t.nid.add()
if err != nil {
slog.Error(fmt.Sprintf("failed to refresh the taskbar on explorer restart: %s", err))
}
t.muNID.Unlock()
default:
// Calls the default window procedure to provide default processing for any window messages that an application does not process.
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms633572(v=vs.85).aspx
lResult, _, _ = pDefWindowProc.Call(
uintptr(hWnd),
uintptr(message),
wParam,
lParam,
)
}
return
}
func (t *winTray) Quit() {
quitOnce.Do(quit)
}
func quit() {
boolRet, _, err := pPostMessage.Call(
uintptr(wt.window),
WM_CLOSE,
0,
0,
)
if boolRet == 0 {
slog.Error(fmt.Sprintf("failed to post close message on shutdown %s", err))
}
}

28
app/types/not/found.go Normal file
View File

@@ -0,0 +1,28 @@
//go:build windows || darwin
package not
import (
"errors"
)
// Found is an error that indicates that a value was not found. It
// may be used by low-level packages to signal to higher-level
// packages that a value was not found.
//
// It exists to avoid using errors.New("not found") in multiple
// packages to mean the same thing.
//
// Found should not be used directly. Instead it should be wrapped
// or joined using errors.Join or fmt.Errorf, etc.
//
// Errors wrapping Found should provide additional context, e.g.
// fmt.Errorf("%w: %s", not.Found, key)
//
//lint:ignore ST1012 This is a sentinel error intended to be read like not.Found.
var Found = errors.New("not found")
// Available is an error that indicates that a value is not available.
//
//lint:ignore ST1012 This is a sentinel error intended to be read like not.Available.
var Available = errors.New("not available")

55
app/types/not/valids.go Normal file
View File

@@ -0,0 +1,55 @@
//go:build windows || darwin
package not
import (
"fmt"
)
type ValidError struct {
name string
msg string
args []any
}
// Valid returns a new validation error with the given name and message.
func Valid(name, message string, args ...any) error {
return ValidError{name, message, args}
}
// Message returns the formatted message for the validation error.
func (e *ValidError) Message() string {
return fmt.Sprintf(e.msg, e.args...)
}
// Error implements the error interface.
func (e ValidError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.name, e.Message())
}
func (e ValidError) Field() string {
return e.name
}
// Valids is for building a list of validation errors.
type Valids []ValidError
// Addf adds a validation error to the list with a formatted message using fmt.Sprintf.
func (b *Valids) Add(name, message string, args ...any) {
*b = append(*b, ValidError{name, message, args})
}
func (b Valids) Error() string {
if len(b) == 0 {
return ""
}
var result string
for i, err := range b {
if i > 0 {
result += "; "
}
result += err.Error()
}
return result
}

View File

@@ -0,0 +1,43 @@
//go:build windows || darwin
package not_test
import (
"errors"
"fmt"
"github.com/ollama/ollama/app/types/not"
)
func ExampleValids() {
// This example demonstrates how to use the Valids type to create
// a list of validation errors.
//
// The Valids type is a slice of ValidError values. Each ValidError
// value represents a validation error.
//
// The Valids type has an Error method that returns a single error
// value that represents all of the validation errors in the list.
//
// The Valids type is useful for collecting multiple validation errors
// and returning them as a single error value.
validate := func() error {
var b not.Valids
b.Add("name", "must be a valid name")
b.Add("email", "%q: must be a valid email address", "invalid.email")
return b
}
err := validate()
var nv not.Valids
if errors.As(err, &nv) {
for _, v := range nv {
fmt.Println(v)
}
}
// Output:
// invalid name: must be a valid name
// invalid email: "invalid.email": must be a valid email address
}

44
app/ui/app.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build windows || darwin
package ui
import (
"bytes"
"embed"
"errors"
"io/fs"
"net/http"
"strings"
"time"
)
//go:embed app/dist
var appFS embed.FS
// appHandler returns an HTTP handler that serves the React SPA.
// It tries to serve real files first, then falls back to index.html for React Router.
func (s *Server) appHandler() http.Handler {
// Strip the dist prefix so URLs look clean
fsys, _ := fs.Sub(appFS, "app/dist")
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if _, err := fsys.Open(p); err == nil {
// Serve the file directly
fileServer.ServeHTTP(w, r)
return
}
// Fallback serve index.html for unknown paths so React Router works
data, err := fs.ReadFile(fsys, "index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(data))
})
}

30
app/ui/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite/
.claude/
*storybook.log
storybook-static

View File

@@ -0,0 +1 @@
*.gen.ts

6
app/ui/app/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "all",
"semi": true,
"singleQuote": false,
"printWidth": 80
}

View File

@@ -0,0 +1,611 @@
/* Do not change, this code is generated from Golang structs */
export class ChatInfo {
id: string;
title: string;
userExcerpt: string;
createdAt: Date;
updatedAt: Date;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.userExcerpt = source["userExcerpt"];
this.createdAt = new Date(source["createdAt"]);
this.updatedAt = new Date(source["updatedAt"]);
}
}
export class ChatsResponse {
chatInfos: ChatInfo[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.chatInfos = this.convertValues(source["chatInfos"], ChatInfo);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Time {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class ToolFunction {
name: string;
arguments: string;
result?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.arguments = source["arguments"];
this.result = source["result"];
}
}
export class ToolCall {
type: string;
function: ToolFunction;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.function = this.convertValues(source["function"], ToolFunction);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class File {
filename: string;
data: number[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.data = source["data"];
}
}
export class Message {
role: string;
content: string;
thinking: string;
stream: boolean;
model?: string;
attachments?: File[];
tool_calls?: ToolCall[];
tool_call?: ToolCall;
tool_name?: string;
tool_result?: number[];
created_at: Time;
updated_at: Time;
thinkingTimeStart?: Date | undefined;
thinkingTimeEnd?: Date | undefined;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.role = source["role"];
this.content = source["content"];
this.thinking = source["thinking"];
this.stream = source["stream"];
this.model = source["model"];
this.attachments = this.convertValues(source["attachments"], File);
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
this.tool_call = this.convertValues(source["tool_call"], ToolCall);
this.tool_name = source["tool_name"];
this.tool_result = source["tool_result"];
this.created_at = this.convertValues(source["created_at"], Time);
this.updated_at = this.convertValues(source["updated_at"], Time);
this.thinkingTimeStart = source["thinkingTimeStart"] && new Date(source["thinkingTimeStart"]);
this.thinkingTimeEnd = source["thinkingTimeEnd"] && new Date(source["thinkingTimeEnd"]);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Chat {
id: string;
messages: Message[];
title: string;
created_at: Time;
browser_state?: BrowserStateData;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.messages = this.convertValues(source["messages"], Message);
this.title = source["title"];
this.created_at = this.convertValues(source["created_at"], Time);
this.browser_state = source["browser_state"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ChatResponse {
chat: Chat;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.chat = this.convertValues(source["chat"], Chat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Model {
model: string;
digest?: string;
modified_at?: Time;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.model = source["model"];
this.digest = source["digest"];
this.modified_at = this.convertValues(source["modified_at"], Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ModelsResponse {
models: Model[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.models = this.convertValues(source["models"], Model);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class InferenceCompute {
library: string;
variant: string;
compute: string;
driver: string;
name: string;
vram: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.library = source["library"];
this.variant = source["variant"];
this.compute = source["compute"];
this.driver = source["driver"];
this.name = source["name"];
this.vram = source["vram"];
}
}
export class InferenceComputeResponse {
inferenceComputes: InferenceCompute[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.inferenceComputes = this.convertValues(source["inferenceComputes"], InferenceCompute);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ModelCapabilitiesResponse {
capabilities: string[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.capabilities = source["capabilities"];
}
}
export class ChatEvent {
eventName: "chat" | "thinking" | "assistant_with_tools" | "tool_call" | "tool" | "tool_result" | "done" | "chat_created";
content?: string;
thinking?: string;
thinkingTimeStart?: Date | undefined;
thinkingTimeEnd?: Date | undefined;
toolCalls?: ToolCall[];
toolCall?: ToolCall;
toolName?: string;
toolResult?: boolean;
toolResultData?: any;
chatId?: string;
toolState?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.content = source["content"];
this.thinking = source["thinking"];
this.thinkingTimeStart = source["thinkingTimeStart"] && new Date(source["thinkingTimeStart"]);
this.thinkingTimeEnd = source["thinkingTimeEnd"] && new Date(source["thinkingTimeEnd"]);
this.toolCalls = this.convertValues(source["toolCalls"], ToolCall);
this.toolCall = this.convertValues(source["toolCall"], ToolCall);
this.toolName = source["toolName"];
this.toolResult = source["toolResult"];
this.toolResultData = source["toolResultData"];
this.chatId = source["chatId"];
this.toolState = source["toolState"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class DownloadEvent {
eventName: "download";
total: number;
completed: number;
done: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.total = source["total"];
this.completed = source["completed"];
this.done = source["done"];
}
}
export class ErrorEvent {
eventName: "error";
error: string;
code?: string;
details?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.error = source["error"];
this.code = source["code"];
this.details = source["details"];
}
}
export class Settings {
Expose: boolean;
Browser: boolean;
Survey: boolean;
Models: string;
Agent: boolean;
Tools: boolean;
WorkingDir: string;
ContextLength: number;
AirplaneMode: boolean;
TurboEnabled: boolean;
WebSearchEnabled: boolean;
ThinkEnabled: boolean;
ThinkLevel: string;
SelectedModel: string;
SidebarOpen: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Expose = source["Expose"];
this.Browser = source["Browser"];
this.Survey = source["Survey"];
this.Models = source["Models"];
this.Agent = source["Agent"];
this.Tools = source["Tools"];
this.WorkingDir = source["WorkingDir"];
this.ContextLength = source["ContextLength"];
this.AirplaneMode = source["AirplaneMode"];
this.TurboEnabled = source["TurboEnabled"];
this.WebSearchEnabled = source["WebSearchEnabled"];
this.ThinkEnabled = source["ThinkEnabled"];
this.ThinkLevel = source["ThinkLevel"];
this.SelectedModel = source["SelectedModel"];
this.SidebarOpen = source["SidebarOpen"];
}
}
export class SettingsResponse {
settings: Settings;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.settings = this.convertValues(source["settings"], Settings);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class HealthResponse {
healthy: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.healthy = source["healthy"];
}
}
export class User {
id: string;
name: string;
email: string;
avatarURL: string;
plan: string;
bio: string;
firstName: string;
lastName: string;
overThreshold: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.email = source["email"];
this.avatarURL = source["avatarURL"];
this.plan = source["plan"];
this.bio = source["bio"];
this.firstName = source["firstName"];
this.lastName = source["lastName"];
this.overThreshold = source["overThreshold"];
}
}
export class Attachment {
filename: string;
data?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.data = source["data"];
}
}
export class ChatRequest {
model: string;
prompt: string;
index?: number;
attachments?: Attachment[];
web_search?: boolean;
file_tools?: boolean;
forceUpdate?: boolean;
think?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.model = source["model"];
this.prompt = source["prompt"];
this.index = source["index"];
this.attachments = this.convertValues(source["attachments"], Attachment);
this.web_search = source["web_search"];
this.file_tools = source["file_tools"];
this.forceUpdate = source["forceUpdate"];
this.think = source["think"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Error {
error: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.error = source["error"];
}
}
export class ModelUpstreamResponse {
digest?: string;
pushTime: number;
error?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.digest = source["digest"];
this.pushTime = source["pushTime"];
this.error = source["error"];
}
}
export class Page {
url: string;
title: string;
text: string;
lines: string[];
links?: Record<number, string>;
fetched_at: Time;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.url = source["url"];
this.title = source["title"];
this.text = source["text"];
this.lines = source["lines"];
this.links = source["links"];
this.fetched_at = this.convertValues(source["fetched_at"], Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class BrowserStateData {
page_stack: string[];
view_tokens: number;
url_to_page: {[key: string]: Page};
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.page_stack = source["page_stack"];
this.view_tokens = source["view_tokens"];
this.url_to_page = source["url_to_page"];
}
}

View File

@@ -0,0 +1,32 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
storybook.configs["flat/recommended"],
);

189
app/ui/app/index.html Normal file
View File

@@ -0,0 +1,189 @@
<!doctype html>
<html lang="en" style="overflow: hidden">
<head>
<meta charset="UTF-8" />
<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">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Add selectFiles method if available
if (typeof window.selectFiles === "function") {
window.webview = window.webview || {};
// Single file selection (returns first file or null)
window.webview.selectFile = function () {
return new Promise((resolve) => {
window.__selectFilesCallback = (data) => {
window.__selectFilesCallback = null;
// For single file, return first file or null
resolve(data && data.length > 0 ? data[0] : null);
};
window.selectFiles();
});
};
// Multiple file selection (returns array or null)
window.webview.selectMultipleFiles = function () {
return new Promise((resolve) => {
window.__selectFilesCallback = (data) => {
window.__selectFilesCallback = null;
resolve(data); // Returns array of files or null if cancelled
};
window.selectFiles();
});
};
}
// Add directory selection methods if available
if (typeof window.selectModelsDirectory === "function") {
window.webview = window.webview || {};
window.webview.selectModelsDirectory = function () {
return new Promise((resolve) => {
window.__selectModelsDirectoryCallback = (path) => {
window.__selectModelsDirectoryCallback = null;
resolve(path); // Returns directory path or null if cancelled
};
window.selectModelsDirectory();
});
};
}
if (typeof window.selectWorkingDirectory === "function") {
window.webview = window.webview || {};
window.webview.selectWorkingDirectory = function () {
return new Promise((resolve) => {
window.__selectWorkingDirectoryCallback = (path) => {
window.__selectWorkingDirectoryCallback = null;
resolve(path); // Returns directory path or null if cancelled
};
window.selectWorkingDirectory();
});
};
}
if (typeof window.ready === "function") {
const callReady = () => setTimeout(window.ready, 100);
if (document.readyState === "complete") {
callReady();
} else {
window.addEventListener("load", callReady);
}
}
if (typeof window.resize === "function") {
window.addEventListener("resize", function () {
window.resize(window.innerWidth, window.innerHeight);
});
}
document.addEventListener("keydown", function (e) {
if (
e.key === "Backspace" &&
!e.target.matches("input, textarea, [contenteditable], select")
) {
e.preventDefault();
}
// Only prevent navigation shortcuts when not in editable fields
if (!e.target.matches("input, textarea, [contenteditable], select")) {
// Prevent Cmd/Ctrl + Left/Right arrow navigation
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "ArrowLeft" || e.key === "ArrowRight")
) {
e.preventDefault();
return false;
}
// Prevent Alt + Left/Right arrow navigation (Windows/Linux)
if (e.altKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
return false;
}
}
// Always prevent F5 refresh
if (e.key === "F5") {
e.preventDefault();
return false;
}
// Always prevent Ctrl/Cmd + Shift + R (hard refresh)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "r") {
e.preventDefault();
return false;
}
});
// Prevent mouse button navigation (back/forward buttons)
document.addEventListener("mousedown", function (e) {
// Mouse button 3 is back, button 4 is forward
if (e.button === 3 || e.button === 4) {
e.preventDefault();
return false;
}
});
// Prevent drag and drop navigation
document.addEventListener("dragover", function (e) {
e.preventDefault();
return false;
});
document.addEventListener("drop", function (e) {
e.preventDefault();
return false;
});
// TODO (jmorganca): this is a way for different components to elect
// to show custom context menu items on top of the default one
// we should integrate this better since it's confusing to follow
document.addEventListener(
"contextmenu",
function (e) {
window.setContextMenuItems([]);
let target = e.target;
while (target && target !== document) {
if (
target.classList &&
target.classList.contains("allow-context-menu")
) {
return true;
}
target = target.parentElement;
}
e.preventDefault();
return false;
},
true,
);
let pendingMenuItems = [];
let menuPromiseResolve = null;
let menuPromiseReject = null;
window.menu = function (items) {
return new Promise((resolve, reject) => {
pendingMenuItems = items;
menuPromiseResolve = resolve;
menuPromiseReject = reject;
window.setContextMenuItems(items);
});
};
window.handleContextMenuResult = function (selected) {
if (menuPromiseResolve) {
menuPromiseResolve(selected);
menuPromiseResolve = null;
menuPromiseReject = null;
}
pendingMenuItems = [];
};
</script>
</body>
</html>

11876
app/ui/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

81
app/ui/app/package.json Normal file
View File

@@ -0,0 +1,81 @@
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"prettier": "prettier --write .",
"prettier:check": "prettier --check .",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-router-devtools": "^1.120.20",
"clsx": "^2.1.1",
"framer-motion": "^12.17.0",
"katex": "^0.16.22",
"micromark-extension-llm-math": "^3.1.0",
"ollama": "^0.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rehype-katex": "^7.0.1",
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-math": "^6.0.0",
"unist-builder": "^4.0.0",
"unist-util-parents": "^3.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@eslint/js": "^9.25.0",
"@storybook/addon-a11y": "^9.0.14",
"@storybook/addon-docs": "^9.0.14",
"@storybook/addon-onboarding": "^9.0.14",
"@storybook/addon-vitest": "^9.0.14",
"@storybook/react-vite": "^9.0.14",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/router-plugin": "^1.120.20",
"@types/node": "^24.7.2",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-storybook": "^9.0.14",
"globals": "^16.0.0",
"playwright": "^1.53.2",
"postcss-preset-env": "^10.2.4",
"react-markdown": "^10.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
"storybook": "^9.0.14",
"tailwindcss": "^4.1.9",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
}

BIN
app/ui/app/public/hello.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show More