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>
167
.github/workflows/release.yaml
vendored
@@ -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"
|
||||
|
||||
77
.github/workflows/test.yaml
vendored
@@ -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:
|
||||
|
||||
@@ -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": [
|
||||
|
||||
14
Dockerfile
@@ -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
@@ -1 +1,11 @@
|
||||
ollama.syso
|
||||
*.crt
|
||||
*.exe
|
||||
/app/app
|
||||
/app/squirrel
|
||||
ollama
|
||||
*cover*
|
||||
.vscode
|
||||
.env
|
||||
.DS_Store
|
||||
.claude
|
||||
|
||||
107
app/README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
|
||||
BIN
app/assets/background.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 116 KiB |
26
app/auth/connect.go
Normal 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
|
||||
}
|
||||
7
app/cmd/app/AppDelegate.h
Normal 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
@@ -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
@@ -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
@@ -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
439
app/cmd/app/app_windows.go
Normal 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
@@ -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
@@ -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)))
|
||||
}
|
||||
40
app/cmd/squirrel/Info.plist
Normal 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>
|
||||
51
app/darwin/Ollama.app/Contents/Info.plist
Normal 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>
|
||||
@@ -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>
|
||||
BIN
app/darwin/Ollama.app/Contents/Resources/icon.icns
Normal file
BIN
app/darwin/Ollama.app/Contents/Resources/ollama.png
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollama@2x.png
Normal file
|
After Width: | Height: | Size: 661 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaDark.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaDark@2x.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaUpdate.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaUpdate@2x.png
Normal file
|
After Width: | Height: | Size: 648 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaUpdateDark.png
Normal file
|
After Width: | Height: | Size: 412 B |
BIN
app/darwin/Ollama.app/Contents/Resources/ollamaUpdateDark@2x.png
Normal file
|
After Width: | Height: | Size: 771 B |
15
app/dialog/LICENSE
Normal 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
@@ -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
@@ -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
|
||||
183
app/dialog/cocoa/dlg_darwin.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package lifecycle
|
||||
|
||||
import "errors"
|
||||
|
||||
func GetStarted() error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package lifecycle
|
||||
|
||||
import "log/slog"
|
||||
|
||||
func ShowLogs() {
|
||||
slog.Warn("not implemented")
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
45
app/logrotate/logrotate.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/logrotate/logrotate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/main.go
@@ -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()
|
||||
}
|
||||
202
app/ollama.iss
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
149
app/server/server_windows.go
Normal 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
407
app/store/database_test.go
Normal 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, ¬Null, &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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
143
app/tools/browser_websearch.go
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
43
app/types/not/valids_test.go
Normal 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
@@ -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
@@ -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
|
||||
1
app/ui/app/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*.gen.ts
|
||||
6
app/ui/app/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
611
app/ui/app/codegen/gotypes.gen.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
32
app/ui/app/eslint.config.js
Normal 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
@@ -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
81
app/ui/app/package.json
Normal 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
|
After Width: | Height: | Size: 21 KiB |