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>
|
|
@ -15,44 +15,56 @@ jobs:
|
||||||
environment: release
|
environment: release
|
||||||
outputs:
|
outputs:
|
||||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||||
|
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set environment
|
- name: Set environment
|
||||||
id: goflags
|
id: goflags
|
||||||
run: |
|
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 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:
|
darwin-build:
|
||||||
runs-on: macos-13-xlarge
|
runs-on: macos-14-xlarge
|
||||||
environment: release
|
environment: release
|
||||||
needs: setup-environment
|
needs: setup-environment
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [darwin]
|
|
||||||
arch: [amd64, arm64]
|
|
||||||
env:
|
env:
|
||||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- run: |
|
- run: |
|
||||||
go build -o dist/ .
|
./scripts/build_darwin.sh
|
||||||
env:
|
- name: Log build results
|
||||||
GOOS: ${{ matrix.os }}
|
|
||||||
GOARCH: ${{ matrix.arch }}
|
|
||||||
CGO_ENABLED: 1
|
|
||||||
CGO_CPPFLAGS: '-mmacosx-version-min=11.3'
|
|
||||||
- if: matrix.arch == 'amd64'
|
|
||||||
run: |
|
run: |
|
||||||
cmake --preset CPU -DCMAKE_OSX_DEPLOYMENT_TARGET=11.3 -DCMAKE_SYSTEM_PROCESSOR=x86_64 -DCMAKE_OSX_ARCHITECTURES=x86_64
|
ls -l dist/
|
||||||
cmake --build --parallel --preset CPU
|
|
||||||
cmake --install build --component CPU --strip --parallel 8
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
name: bundles-darwin
|
||||||
path: dist/*
|
path: |
|
||||||
|
dist/*.tgz
|
||||||
|
dist/*.zip
|
||||||
|
dist/*.dmg
|
||||||
|
|
||||||
windows-depends:
|
windows-depends:
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -72,7 +84,6 @@ jobs:
|
||||||
- '"cublas_dev"'
|
- '"cublas_dev"'
|
||||||
cuda-version: '12.8'
|
cuda-version: '12.8'
|
||||||
flags: ''
|
flags: ''
|
||||||
runner_dir: 'cuda_v12'
|
|
||||||
- os: windows
|
- os: windows
|
||||||
arch: amd64
|
arch: amd64
|
||||||
preset: 'CUDA 13'
|
preset: 'CUDA 13'
|
||||||
|
|
@ -87,14 +98,12 @@ jobs:
|
||||||
- '"nvptxcompiler"'
|
- '"nvptxcompiler"'
|
||||||
cuda-version: '13.0'
|
cuda-version: '13.0'
|
||||||
flags: ''
|
flags: ''
|
||||||
runner_dir: 'cuda_v13'
|
|
||||||
- os: windows
|
- os: windows
|
||||||
arch: amd64
|
arch: amd64
|
||||||
preset: 'ROCm 6'
|
preset: 'ROCm 6'
|
||||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||||
rocm-version: '6.2'
|
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"'
|
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 }}
|
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||||
environment: release
|
environment: release
|
||||||
env:
|
env:
|
||||||
|
|
@ -160,12 +169,15 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
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'
|
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 --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||||
cmake --build --parallel --preset "${{ matrix.preset }}"
|
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
|
||||||
cmake --install build --component "${{ startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || 'CPU' }}" --strip --parallel 8
|
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
|
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||||
env:
|
env:
|
||||||
CMAKE_GENERATOR: Ninja
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||||
|
|
@ -188,6 +200,7 @@ jobs:
|
||||||
needs: [setup-environment]
|
needs: [setup-environment]
|
||||||
env:
|
env:
|
||||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||||
|
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Install ARM64 system dependencies
|
- name: Install ARM64 system dependencies
|
||||||
if: matrix.arch == 'arm64'
|
if: matrix.arch == 'arm64'
|
||||||
|
|
@ -198,6 +211,9 @@ jobs:
|
||||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
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
|
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
|
choco install -y --no-progress git gzip
|
||||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
- name: Install clang and gcc-compat
|
- name: Install clang and gcc-compat
|
||||||
|
|
@ -223,13 +239,72 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
$ErrorActionPreference='Stop'
|
$ErrorActionPreference='Stop'
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
- run: |
|
- 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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||||
path: |
|
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:
|
linux-build:
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -288,7 +363,7 @@ jobs:
|
||||||
done
|
done
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: dist-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
|
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
|
||||||
path: |
|
path: |
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
|
|
@ -344,7 +419,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.preset }}
|
||||||
build-args: ${{ matrix.build-args }}
|
build-args: ${{ matrix.build-args }}
|
||||||
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
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
|
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 }}
|
docker buildx imagetools inspect ${{ vars.DOCKER_REPO }}:${{ steps.metadata.outputs.version }}
|
||||||
working-directory: ${{ runner.temp }}
|
working-directory: ${{ runner.temp }}
|
||||||
|
|
||||||
# Trigger downstream release process
|
# Final release process
|
||||||
trigger:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: release
|
environment: release
|
||||||
needs: [darwin-build, windows-build, windows-depends, linux-build]
|
needs: [darwin-build, windows-app, linux-build]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Create or update Release for tag
|
||||||
run: |
|
run: |
|
||||||
RELEASE_VERSION="$(echo ${GITHUB_REF_NAME} | cut -f1 -d-)"
|
RELEASE_VERSION="$(echo ${GITHUB_REF_NAME} | cut -f1 -d-)"
|
||||||
|
|
@ -420,12 +506,17 @@ jobs:
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
--prerelease
|
--prerelease
|
||||||
fi
|
fi
|
||||||
- name: Trigger downstream release process
|
- name: Upload release artifacts
|
||||||
run: |
|
run: |
|
||||||
curl -L \
|
pids=()
|
||||||
-X POST \
|
for payload in dist/*.txt dist/*.zip dist/*.tgz dist/*.exe dist/*.dmg ; do
|
||||||
-H "Accept: application/vnd.github+json" \
|
echo "Uploading $payload"
|
||||||
-H "Authorization: Bearer ${{ secrets.RELEASE_TOKEN }}" \
|
gh release upload ${GITHUB_REF_NAME} $payload --clobber &
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
pids[$!]=$!
|
||||||
https://api.github.com/repos/ollama/${{ vars.RELEASE_REPO }}/dispatches \
|
sleep 1
|
||||||
-d "{\"event_type\": \"trigger-workflow\", \"client_payload\": {\"run_id\": \"${GITHUB_RUN_ID}\", \"version\": \"${GITHUB_REF_NAME#v}\", \"origin\": \"${GITHUB_REPOSITORY}\", \"publish\": \"1\"}}"
|
done
|
||||||
|
echo "Waiting for uploads to complete"
|
||||||
|
for pid in "${pids[*]}"; do
|
||||||
|
wait $pid
|
||||||
|
done
|
||||||
|
echo "done"
|
||||||
|
|
|
||||||
|
|
@ -200,51 +200,26 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: '1'
|
CGO_ENABLED: '1'
|
||||||
GOEXPERIMENT: 'synctest'
|
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
|
- uses: actions/setup-go@v5
|
||||||
|
|
||||||
- name: cache restore
|
|
||||||
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
|
||||||
with:
|
with:
|
||||||
# Note: unlike the other setups, this is only grabbing the mod download
|
go-version-file: 'go.mod'
|
||||||
# cache, rather than the whole mod directory, as the download cache
|
- uses: actions/setup-node@v4
|
||||||
# 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
|
|
||||||
with:
|
with:
|
||||||
# The caching strategy of setup-go is less than ideal, and wastes
|
node-version: '20'
|
||||||
# time by not saving artifacts due to small failures like the linter
|
- name: Install UI dependencies
|
||||||
# complaining, etc. This means subsequent have to rebuild their world
|
working-directory: ./app/ui/app
|
||||||
# again until all checks pass. For instance, if you mispell a word,
|
run: npm ci
|
||||||
# you're punished until you fix it. This is more hostile than
|
- name: Install tscriptify
|
||||||
# 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()
|
|
||||||
run: |
|
run: |
|
||||||
go generate ./...
|
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
|
||||||
git diff --name-only --exit-code || (echo "Please run 'go generate ./...'." && exit 1)
|
- 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
|
- name: go test
|
||||||
if: always()
|
if: always()
|
||||||
|
|
@ -257,26 +232,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
args: --timeout 10m0s -v
|
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:
|
patches:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@
|
||||||
"inherits": [ "CUDA" ],
|
"inherits": [ "CUDA" ],
|
||||||
"cacheVariables": {
|
"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_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" ],
|
"inherits": [ "CUDA" ],
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
"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" ],
|
"inherits": [ "CUDA" ],
|
||||||
"cacheVariables": {
|
"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_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",
|
"name": "JetPack 5",
|
||||||
"inherits": [ "CUDA" ],
|
"inherits": [ "CUDA" ],
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_CUDA_ARCHITECTURES": "72;87"
|
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||||
|
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "JetPack 6",
|
"name": "JetPack 6",
|
||||||
"inherits": [ "CUDA" ],
|
"inherits": [ "CUDA" ],
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_CUDA_ARCHITECTURES": "87"
|
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||||
|
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -68,12 +73,16 @@
|
||||||
"inherits": [ "ROCm" ],
|
"inherits": [ "ROCm" ],
|
||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
"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",
|
"name": "Vulkan",
|
||||||
"inherits": [ "Default" ]
|
"inherits": [ "Default" ],
|
||||||
|
"cacheVariables": {
|
||||||
|
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buildPresets": [
|
"buildPresets": [
|
||||||
|
|
|
||||||
14
Dockerfile
|
|
@ -58,7 +58,7 @@ RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'CUDA 11' \
|
||||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
&& 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
|
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'CUDA 12' \
|
||||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
&& 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
|
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'CUDA 13' \
|
||||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
&& 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
|
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'ROCm 6' \
|
||||||
&& cmake --install build --component HIP --strip --parallel ${PARALLEL}
|
&& cmake --install build --component HIP --strip --parallel ${PARALLEL}
|
||||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
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
|
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'JetPack 5' \
|
||||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
&& 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
|
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||||
ARG PARALLEL
|
ARG PARALLEL
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
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 --build --parallel ${PARALLEL} --preset 'JetPack 6' \
|
||||||
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
&& cmake --install build --component CUDA --strip --parallel ${PARALLEL}
|
||||||
|
|
||||||
FROM base AS vulkan
|
FROM base AS vulkan
|
||||||
RUN --mount=type=cache,target=/root/.ccache \
|
RUN --mount=type=cache,target=/root/.ccache \
|
||||||
cmake --preset 'Vulkan' -DOLLAMA_RUNNER_DIR="vulkan" \
|
cmake --preset 'Vulkan' \
|
||||||
&& cmake --build --parallel --preset 'Vulkan' \
|
&& cmake --build --parallel --preset 'Vulkan' \
|
||||||
&& cmake --install build --component Vulkan --strip --parallel 8
|
&& cmake --install build --component Vulkan --strip --parallel 8
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,11 @@
|
||||||
ollama.syso
|
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
|
- https://jrsoftware.org/isinfo.php
|
||||||
|
|
||||||
|
|
||||||
In the top directory of this repo, run the following powershell script
|
**Dependencies** - either build a local copy of ollama, or use a github release
|
||||||
to build the ollama CLI, ollama app, and ollama installer.
|
|
||||||
|
|
||||||
```powershell
|
```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
|
package assets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
|
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 |
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
|
||||||
|
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||||
|
|
||||||
|
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 661 B |
|
After Width: | Height: | Size: 363 B |
|
After Width: | Height: | Size: 745 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 648 B |
|
After Width: | Height: | Size: 412 B |
|
After Width: | Height: | Size: 771 B |
|
|
@ -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.
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package dialog
|
||||||
|
|
||||||
|
func firstOf(args ...string) string {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg != "" {
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
OutputBaseFilename="OllamaSetup"
|
||||||
SetupIconFile={#MyIcon}
|
SetupIconFile={#MyIcon}
|
||||||
UninstallDisplayIcon={uninstallexe}
|
UninstallDisplayIcon={uninstallexe}
|
||||||
Compression=lzma2
|
Compression=lzma2/ultra64
|
||||||
SolidCompression=no
|
LZMAUseSeparateProcess=yes
|
||||||
|
LZMANumBlockThreads=8
|
||||||
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
ChangesEnvironment=yes
|
ChangesEnvironment=yes
|
||||||
OutputDir=..\dist\
|
OutputDir=..\dist\
|
||||||
|
|
@ -46,7 +48,7 @@ OutputDir=..\dist\
|
||||||
; Disable logging once everything's battle tested
|
; Disable logging once everything's battle tested
|
||||||
; Filename will be %TEMP%\Setup Log*.txt
|
; Filename will be %TEMP%\Setup Log*.txt
|
||||||
SetupLogging=yes
|
SetupLogging=yes
|
||||||
CloseApplications=yes
|
CloseApplications=no
|
||||||
RestartApplications=no
|
RestartApplications=no
|
||||||
RestartIfNeededByRun=no
|
RestartIfNeededByRun=no
|
||||||
|
|
||||||
|
|
@ -68,7 +70,6 @@ DisableFinishedPage=yes
|
||||||
DisableReadyMemo=yes
|
DisableReadyMemo=yes
|
||||||
DisableReadyPage=yes
|
DisableReadyPage=yes
|
||||||
DisableStartupPrompt=yes
|
DisableStartupPrompt=yes
|
||||||
DisableWelcomePage=yes
|
|
||||||
|
|
||||||
; TODO - percentage can't be set less than 100, so how to make it shorter?
|
; TODO - percentage can't be set less than 100, so how to make it shorter?
|
||||||
; WizardSizePercent=100,80
|
; WizardSizePercent=100,80
|
||||||
|
|
@ -87,30 +88,42 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||||
DialogFontSize=12
|
DialogFontSize=12
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
#if DirExists("..\dist\windows-amd64")
|
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||||
Source: "..\dist\windows-amd64-app.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit
|
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit
|
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
|
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if DirExists("..\dist\windows-arm64")
|
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
#if FileExists("..\dist\windows-ollama-app-arm64.exe")
|
||||||
Source: "..\dist\windows-arm64-app.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: IsArm64(); Flags: ignoreversion 64bit
|
Source: "..\dist\windows-ollama-app-arm64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit
|
#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
|
#endif
|
||||||
|
|
||||||
Source: "..\dist\ollama_welcome.ps1"; DestDir: "{app}"; Flags: ignoreversion
|
|
||||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
|
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"
|
Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app.ico"
|
||||||
|
|
||||||
|
[InstallDelete]
|
||||||
|
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
#if DirExists("..\dist\windows-arm64")
|
#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
|
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||||
#endif
|
#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
|
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||||
|
|
||||||
[UninstallRun]
|
[UninstallRun]
|
||||||
|
|
@ -126,13 +139,13 @@ Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||||
Type: filesandordirs; Name: "{%TEMP}\ollama*"
|
Type: filesandordirs; Name: "{%TEMP}\ollama*"
|
||||||
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Ollama"
|
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Ollama"
|
||||||
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama"
|
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama"
|
||||||
Type: filesandordirs; Name: "{%USERPROFILE}\.ollama\models"
|
|
||||||
Type: filesandordirs; Name: "{%USERPROFILE}\.ollama\history"
|
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
|
; NOTE: if the user has a custom OLLAMA_MODELS it will be preserved
|
||||||
|
|
||||||
[InstallDelete]
|
[InstallDelete]
|
||||||
Type: filesandordirs; Name: "{%TEMP}\ollama*"
|
Type: filesandordirs; Name: "{%TEMP}\ollama*"
|
||||||
Type: filesandordirs; Name: "{%LOCALAPPDATA}\Programs\Ollama"
|
Type: filesandordirs; Name: "{app}\lib\ollama"
|
||||||
|
|
||||||
[Messages]
|
[Messages]
|
||||||
WizardReady=Ollama
|
WizardReady=Ollama
|
||||||
|
|
@ -148,6 +161,10 @@ SetupAppRunningError=Another Ollama installer is running.%n%nPlease cancel or fi
|
||||||
Root: HKCU; Subkey: "Environment"; \
|
Root: HKCU; Subkey: "Environment"; \
|
||||||
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
|
ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \
|
||||||
Check: NeedsAddPath('{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]
|
[Code]
|
||||||
|
|
||||||
|
|
@ -182,7 +199,11 @@ var
|
||||||
v3: Cardinal;
|
v3: Cardinal;
|
||||||
v4: Cardinal;
|
v4: Cardinal;
|
||||||
begin
|
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
|
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||||
|
|
@ -202,3 +223,152 @@ begin
|
||||||
else
|
else
|
||||||
Result := TRUE;
|
Result := TRUE;
|
||||||
end;
|
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 ""
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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 {
|
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"`
|
ID string `json:"id"`
|
||||||
FirstTimeRun bool `json:"first-time-run"`
|
FirstTimeRun bool `json:"first-time-run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
func (s *Store) ensureDB() error {
|
||||||
lock sync.Mutex
|
// Fast path: check if db is already initialized
|
||||||
store Store
|
if s.db != nil {
|
||||||
)
|
return nil
|
||||||
|
|
||||||
func GetID() string {
|
|
||||||
lock.Lock()
|
|
||||||
defer lock.Unlock()
|
|
||||||
if store.ID == "" {
|
|
||||||
initStore()
|
|
||||||
}
|
}
|
||||||
return store.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFirstTimeRun() bool {
|
// Slow path: initialize database with lock
|
||||||
lock.Lock()
|
s.dbMu.Lock()
|
||||||
defer lock.Unlock()
|
defer s.dbMu.Unlock()
|
||||||
if store.ID == "" {
|
|
||||||
initStore()
|
// Double-check after acquiring lock
|
||||||
|
if s.db != nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return store.FirstTimeRun
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetFirstTimeRun(val bool) {
|
dbPath := s.DBPath
|
||||||
lock.Lock()
|
if dbPath == "" {
|
||||||
defer lock.Unlock()
|
dbPath = defaultDBPath
|
||||||
if store.FirstTimeRun == val {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
store.FirstTimeRun = val
|
|
||||||
writeStore(getStorePath())
|
|
||||||
}
|
|
||||||
|
|
||||||
// lock must be held
|
// Ensure directory exists
|
||||||
func initStore() {
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||||
storeFile, err := os.Open(getStorePath())
|
return fmt.Errorf("create db directory: %w", err)
|
||||||
if err == nil {
|
}
|
||||||
defer storeFile.Close()
|
|
||||||
err = json.NewDecoder(storeFile).Decode(&store)
|
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 {
|
if err == nil {
|
||||||
slog.Debug(fmt.Sprintf("loaded existing store %s - ID: %s", getStorePath(), store.ID))
|
database.setID(u.String())
|
||||||
return
|
|
||||||
}
|
}
|
||||||
} 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()
|
s.db = database
|
||||||
writeStore(getStorePath())
|
|
||||||
|
// 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) {
|
// migrateFromConfig attempts to migrate ID and FirstTimeRun from config.json
|
||||||
ollamaDir := filepath.Dir(storeFilename)
|
func (s *Store) migrateFromConfig(database *database) error {
|
||||||
_, err := os.Stat(ollamaDir)
|
configPath := legacyConfigPath
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
if err := os.MkdirAll(ollamaDir, 0o755); err != nil {
|
// Check if config.json exists
|
||||||
slog.Error(fmt.Sprintf("create ollama dir %s: %v", ollamaDir, err))
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
return
|
// 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 {
|
return settings, nil
|
||||||
slog.Error(fmt.Sprintf("failed to marshal store: %s", err))
|
}
|
||||||
return
|
|
||||||
}
|
func (s *Store) SetSettings(settings Settings) error {
|
||||||
fp, err := os.OpenFile(storeFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
if err := s.ensureDB(); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
slog.Error(fmt.Sprintf("write store payload %s: %v", storeFilename, err))
|
}
|
||||||
return
|
|
||||||
}
|
return s.db.setSettings(settings)
|
||||||
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))
|
func (s *Store) Chats() ([]Chat, error) {
|
||||||
return
|
if err := s.ensureDB(); err != nil {
|
||||||
}
|
return nil, err
|
||||||
slog.Debug("Store contents: " + string(payload))
|
}
|
||||||
slog.Info(fmt.Sprintf("wrote store: %s", storeFilename))
|
|
||||||
|
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")
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
*.gen.ts
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"],
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 21 KiB |