Compare commits

..
Author SHA1 Message Date
Parth Sareen 83ed7d9965 app: harden Codex desktop proxy handling (#18244) 2026-09-04 17:48:55 -07:00
Parth Sareen b043d891c2 openai: preserve images through response compaction (#18245) 2026-09-04 17:48:45 -07:00
Parth Sareen 43e667004a app: route Codex auto review through selected model (#18246) 2026-09-04 17:48:37 -07:00
Daniel Hiltgen d3efc63263 ci: bound and cache the macOS MLX payload build (#18240)
Avoid unbounded parallel builds on small runners, and cache the payloads for repeated runs.
2026-09-04 14:38:05 -07:00
Eva H 87b9f9e95a app: add Ollama to ChatGPT Desktop (#18236) 2026-09-04 14:04:48 -07:00
Parth Sareen 3f77cb6dfb openai: add Codex compaction support (#18224) 2026-09-04 13:07:33 -07:00
Parth Sareen cf8b605b06 openai: add client tool search support (#18223) 2026-09-04 13:07:23 -07:00
Jesse Gross b68365a0a4 mlxrunner: compile structured output as xgrammar structural tags
The runner compiled a format as a JSON Schema, the one grammar kind its
xgrammar binding exposed. A structural tag holds a schema as one node of
a larger tree and also expresses what a schema cannot: free text around
constrained spans, a thinking region that closes before constrained
content, tool calls pinned to their schemas.

The runner now compiles structural tags only; its client wraps the API's
formats into one, which compiles to the same grammar as before. The JSON
token and vocabulary caps go with it: neither bounds compile cost, which
follows the grammar's state count. The byte and nesting caps stay.
2026-09-03 15:49:34 -07:00
Jesse Gross 4986e92379 mlxrunner: enable speculative decoding under structured output
A structured-output request could not use a model's draft head: it
decoded one token at a time, at roughly half the speculative throughput
on a dense 27B MTP model.

The grammar is enforced during verification instead: each draft
position's logits are masked before rejection sampling, so an invalid
draft is never accepted and every emitted token obeys the grammar.
Drafts stay unconstrained; constraining the draft chain would stall its
pipelined forwards.

Speculative steps also now dispatch the drafts before the host builds
the verification graph, worth 4-8% end to end at a fixed draft depth on
MTP models, with or without a grammar.
2026-09-03 15:49:34 -07:00
Daniel Hiltgen 59fe23d85c ci: rebuild MLX macOS test payloads the release can't supply (#18219)
* ci: rebuild MLX macOS test payloads the release can't supply

The MLX unit test dependency payload can go stale when a PR changes the
xgrammar wrapper (release dylib lacks the new symbols) or bumps MLX pins (no
matching release, so MLX tests silently skipped). Compare the matched tag's
build rules and wrapper sources against the checkout, rebuilding only
libollama_xgrammar.dylib on drift; with no matching release, build one Metal
variant at the platform default.

* address comments
2026-09-03 14:17:21 -07:00
Jesse Gross b79067b0db gemma4: image and audio input support
Safetensors gemma4 imports served by the MLX engine now answer image
and audio chats. Images run through both vision architectures: the
transformer tower (26B, 31B, e-series) and the 12B's encoder-free
unified embedder. Audio arrives through the same intake the ollama
API already accepts for gemma4 GGUFs — WAV bytes in the images field,
OpenAI input_audio parts, and /v1/audio/transcriptions uploads — with
the e2b/e4b checkpoints running clips through their conformer audio
encoder and the 12b unified checkpoint embedding the raw waveform
directly. Clips longer than 30 seconds are split evenly into chunks
of at most 30 seconds, cut at pauses, and encoded independently.

Each modality serves only checkpoints that carry it: 26B/31B have no
audio config and reject audio input, and checkpoints with an
unrecognized vision architecture still load as text-only models and
reject image requests.

The server previously hid the vision and audio capabilities for
gemma4 safetensors because the engine served neither. Both
suppressions are removed, and existing imports start advertising the
capabilities without re-importing since import already records them.
2026-09-02 15:07:28 -07:00
Jesse Gross 3ffc9a682a mlxrunner: shared audio decoding and chunking for audio models
Audio-capable models need mono PCM at their expected sample rate
before model-specific feature extraction. Like the image decoder set
in base, the supported audio containers are decided once here so
every model accepts the same formats: WAV, covering integer and float
PCM, extensible headers, and multi-channel downmix. Anything else is
rejected as unrecognized; supporting another container later means
one new decoder here, with no model or runner changes.

Input at other sample rates is resampled through a band-limiting
filter, so mismatched rates degrade gracefully instead of aliasing.
Decoded clips are capped at ten minutes: the declared rate comes from
an untrusted header, and the cap is what keeps a small file claiming
an absurdly low rate from resampling into an enormous allocation.

Models whose encoder takes clips only up to a fixed length split
longer clips with Split. Chunks are sized evenly, and each cut moves
to the quietest point within a few seconds of its even share, so a
boundary lands on a pause where the clip has one instead of severing
a word between two independently encoded chunks. The even sizing
bounds the search so no chunk can end up over the limit.
2026-09-02 15:07:28 -07:00
Jesse Gross ba064c3662 mlxrunner: check every mlx-c call in the MLX bindings
The bindings captured MLX error messages but checked almost no calls,
so a failure continued with a null output and surfaced later as zero
results, skipped evals, or an unrelated crash.

Wrap every call in mlxCheck. Paths that return an error or disable a
GPU kernel backend use mlxError instead, and the lookups where a
non-zero status means a miss read the buffer first and then treat the
status as data.

Also free the string handles behind Array.String and the log values
after the call that fills them; they were freed before it and leaked.
2026-09-02 15:05:54 -07:00
Jesse Gross c36adebc20 mlxrunner: capture MLX errors in a single buffer read after every call
MLX runs on one goroutine locked to its OS thread, so the thread-local
error buffers and closure-based check helpers defended against a
calling pattern that is already invalid.

Replace them with a single buffer that the handler fills and Go reads
after every call. mlxError returns the captured message; mlxCheck
panics on it and passes the call's result through, so a checked call
is one expression. Only an int status carries a failure signal, which
lets a message next to a zero status be reported as an earlier
unchecked call.

Fix two tests that relied on errors being dropped: the laguna
mixed-precision fixture used an unsupported quantization group size,
and the compile callback test expected the callback's own panic.
2026-09-02 15:05:54 -07:00
Daniel Hiltgen 882387a57b mlx: fix mtp_test unit test (#18203) 2026-09-02 11:17:16 -07:00
Daniel Hiltgen b1d1ccc957 llama.cpp: version bump b10760 (#18199) 2026-09-02 09:46:04 -07:00
Daniel Hiltgen 855f4bf989 Report cached prompt tokens (#17943)
* Report cached prompt tokens

Add prompt_eval_cached_count to native responses and expose equivalent cached-token fields through the OpenAI- and Anthropic-compatible APIs. Keep prompt_eval_count as the logical input total while excluding cache hits from CLI and benchmark prefill rates. Surface processed and cached prompt counts in benchmark output.

Collect cache counts from llama-server and MLX, preserve coherent metrics across two-pass structured generation.

Fixes #8008

Related to #15758

* review comments
2026-09-02 09:30:44 -07:00
Daniel Hiltgen e5e4377115 ci: wire up MLX unit tests for PR runs (#17022)
* ci: wire up MLX unit tests for PR runs

Download the latest Darwin release payload matching the current MLX and MLX-C revisions so macOS PR tests can exercise MLX without rebuilding it. If no matching release exists after a pin bump, leave MLX tests skipped until the next release.

Add whole-tree race coverage and smoke-run committed benchmarks. Verify generated UI types, and stabilize tests exposed by the broader CI coverage.

* review comments

* mlx: run tests on one pinned worker

Keep MLX tests and benchmarks on a shared pinned thread while preserving Fatal, Skip, and Cleanup semantics. Also clean stale CI payloads and ensure updater workers shut down cleanly.

* addres comments
2026-09-01 16:59:36 -07:00
Daniel Hiltgen 5ec5804360 llama.cpp: version bump b10729 (#18160)
* llama.cpp: version bump b10729

Regenerate the compat hooks patch for b10729: upstream removed the
whole-tensor load_data_for read (last consumer was llama-quantize,
which now reads slabs via load_data_range). Keep the existing hook
surface (constructor, skip loops, load_all_data, mtmd/clip) unchanged
and add maybe_load_text_tensor_range, which materializes a text load
op's output once per tensor and serves the new (offset, size) slab
reads from that cache.

* address comments
2026-09-01 16:24:15 -07:00
Daniel Hiltgen 3ba380d0be MLX, MLX-C: version bump (#18080)
* MLX, MLX-C: version bump

* resolve comments

* address comments
2026-09-01 16:24:04 -07:00
Daniel Hiltgen f348c7e3f5 Honor model generation defaults (#16471)
* Honor model generation defaults

Model-authored sampler defaults from GGUF metadata and HF generation_config.json were ignored, so built-in Ollama defaults could override model intent unless parameters were set in the Modelfile or request. The fix parses those defaults into model config and applies them before Modelfile/request options, preserving the expected precedence order.

* review comments

* address comments
2026-09-01 14:52:07 -07:00
Daniel Hiltgen 205a042690 build: go deps (#18161)
* build: go deps

Gather go dep licenses

* address comments
2026-09-01 12:59:48 -07:00
Daniel Hiltgen ef117cfcc0 mlx: dedup dependency files (#18159)
Put license files in lib/ollama so runner dirs don't duplicate them, and
package in the base package(s).
2026-08-31 17:12:46 -07:00
Marcel PetrickandPatrick Devine e37a00a8fa fix(docs): correct typos found during code review (#17579)
* fix(docs): correct typos found during code review

Non-functional changes only:
- Fixed minor spelling mistakes in comments
- Corrected typos in user-facing strings
- No variables, logic, or functional code was modified.

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>

* fix additional typos and shell-unsafe example in docs

---------

Co-authored-by: Patrick Devine <patrick@ollama.com>
2026-08-31 08:59:55 -07:00
Daniel Hiltgen f96e7aa051 lint fix (#18081) 2026-08-27 17:01:54 -07:00
Daniel Hiltgen 68793119df Clean up dead code (#17381)
Largely from the llama-server work.
2026-08-27 14:33:51 -07:00
Eva H f4025ed1fe app: list account cloud models for Claude (#18077) 2026-08-27 13:22:11 -07:00
Parth Sareen 39f7f91563 app: synchronize macOS app handoff (#18056) 2026-08-26 23:15:54 -07:00
Parth Sareen d366f4868a proxy: continue requests when the model catalog changes (#18058) 2026-08-26 19:39:05 -07:00
Eva H a67fe8c537 app: restore system dark mode (#18049) 2026-08-26 22:34:51 -04:00
Parth Sareen 13f2fb8c99 app: consume Claude recommendation mappings (#18042) 2026-08-26 15:55:01 -07:00
Jesse Gross 3b96a8972a mlx: remove the text-only gemma3 model
The MLX gemma3 port implements only the text stack, while gemma3 as GGUF
runs on llama-server with vision. Once MLX takes priority for
architectures both engines support, a registered gemma3 would route the
model to the engine that cannot serve images. No gemma3 safetensors
manifests were ever published, so removing the architecture affects no
existing installs and keeps gemma3 on llama-server.
2026-08-26 14:01:50 -07:00
Daniel Hiltgen 91cf995996 launch: respect OLLAMA_HOST when configuring Pi (#18039)
Pi's Edit() only set baseUrl when creating a new ollama provider entry.
On subsequent launches it preserved whatever baseUrl was already in
~/.pi/agent/models.json, so switching OLLAMA_HOST to a remote server had
no effect — Pi would still connect to localhost.

Edit() now ensures baseUrl reflects the current OLLAMA_HOST. Models()
returns nil when the stored baseUrl no longer matches, so the launcher
only calls Edit() when the host has actually drifted. User-customized api
and apiKey fields are still preserved.
2026-08-26 13:24:05 -07:00
Eva H a773b7affa app: prevent Claude model picker scroll jumps (#18041) 2026-08-26 11:15:56 -07:00
Daniel Hiltgen d3a8af4d9b linux: fix llama.cpp docker build (#18040)
Build context was missing the new cmake common utility.
2026-08-26 10:21:21 -07:00
Daniel Hiltgen 18ea9de0fe MLX: Qwen3.8 Flash Next support (#18032)
* MLX: Qwen3.8 Flash Next support

* review comments
2026-08-26 08:05:59 -07:00
Parth Sareen e2c6c7e894 docs: claude docs (#18006) 2026-08-25 19:34:13 -07:00
Parth Sareen 9dbf139133 docs: document Claude Desktop integration (#18004) 2026-08-25 19:12:21 -07:00
Jesse Gross 77e3b0ac7a mlxrunner: avoid Metal GPU timeouts when loading models from slow storage
Model load code eagerly evaluated every weight fold (expert stacking,
gather transposes, gate/up fusing) as it was built, with the folds
running on the GPU against lazily loaded tensors: Metal committed
command buffers that waited on file reads, and macOS kills command
buffers that stall too long, so loading a large model from a slow
volume aborted with "Command buffer execution failed". The eager evals
also kept every layer's fold sources alive until the post-load sweep,
transiently holding roughly twice the expert weights on MoE models.

Build the folds lazily and let the runner's weight eval run them, and
on Metal materialize the loaded tensors with CPU reads before any
weight graph exists: no command buffer is ever committed waiting on
file data, at any storage speed, and fold sources free as their folds
execute. CUDA loads read at dispatch and skip the pre-pass. Models no
longer evaluate weights at load; on Metal, tensors the model does not
retain are now read before the sweep frees them.

Measured on an M5 Max, warm page cache, greedy outputs bit-identical:

                                    before           after
  nemotron-3.5-lightning:30b-mlx    1.9s  39.7GiB    1.45s    24.7GiB
  qwen3.6:35b-mlx                   1.27s 22.5GiB    1.1-1.2s 22.4GiB
  nemotron, reads at ~60MB/s        aborts in 6s     loads in 346s

Fixes #17902
2026-08-25 17:14:56 -07:00
Jesse Gross 147509c0c5 mlxrunner: add structured output support
The MLX runner accepted the API's format field but did not enforce it:
requests asking for JSON or a JSON Schema got unconstrained text, and
clients had no way to tell.

Enforce format with xgrammar: each sampling step masks the logits to
the tokens the grammar allows, so every emitted token and the end of
generation are valid under the constraint. Sampling, penalties, and
logprobs see the constrained distribution, and "json" yields a JSON
object, as the API documents and the llama-server path already
enforces. Only sampling waits on the mask; the forward pass is
dispatched before it, so constrained decoding stays pipelined.

The grammar engine is a dynamic library alongside MLX; when it is
missing, plain inference is unaffected and structured requests fail
with an explicit error. Constrained requests decode without
speculative decoding for now.

Decoding 256 tokens of a book-list schema on qwen3.8:27b-mlx (M5 Max,
seed 42, thinking off); pre-decode is the request time spent before
the first token:

    unconstrained              ~65 tok/s   pre-decode ~70 ms
    unconstrained, no draft    ~32 tok/s   pre-decode ~70 ms
    JSON schema                ~32 tok/s   pre-decode ~70 ms

Schema and draft-less decoding are equal to within 0.1 tok/s in
paired adjacent requests, and a cold grammar compile adds nothing
measurable to pre-decode. The gap to unconstrained decoding is the
disabled draft model.

Fixes #16563
Co-authored-by: Daniel Hiltgen <daniel@ollama.com>
2026-08-25 17:14:23 -07:00
Jesse Gross 7623501fc2 mlx: return exact types and evaluate arrays in the value readers
Token ids are int32 throughout the runner, so every caller reading ids
out of an int32 array narrowed the widened value right back. Make Int
and Ints return int32 and Float return float32, matching Floats, and
require the exact dtype instead of accepting and widening every
integer and float width: no caller read anything through those paths
but int32 tokens.

Ints and Floats also copied out of the array's buffer without
evaluating it first, so reading an array still in flight after an
async dispatch could return unwritten data, and correctness depended
on every call site remembering an explicit Eval. Evaluate in every
reader, matching the scalar readers, which already wait through item.
An available array costs a status check and an in-flight one waits
for its event; only a never-dispatched array evaluates a graph.
2026-08-25 17:14:23 -07:00
Jesse Gross 7027546ccf llm: remove the unused Grammar completion-request field
Nothing has set Grammar since the CGO engine removal took its writers
out; it survived as a read-only pass-through on the llama-server path
and a comment claiming it is set before dispatch. Remove the field and
the dead pass-through. llama-server keeps its wire-level grammar field,
which the "json" format conversion still uses.
2026-08-25 17:14:23 -07:00
Daniel Hiltgen 3d86a552b8 llama.cpp: version bump b10630 (#18003) 2026-08-25 17:11:26 -07:00
Daniel Hiltgen d465dc7ca1 MLX: version bump (#17955) 2026-08-25 17:11:13 -07:00
PhilippandCodex ad94d52965 cmake: make external compat patches idempotent (#17948)
Co-authored-by: Codex <noreply@openai.com>
2026-08-25 17:10:59 -07:00
Parth Sareen ebf200f952 proxy: preserve string content during image fallback (#18002) 2026-08-25 15:03:57 -07:00
Eva H 075aa7e147 app: reset Claude Desktop models to defaults (#18000) 2026-08-25 17:43:21 -04:00
Eva H 377ef091dc app: keep Claude toggle busy while connecting (#17997) 2026-08-25 12:27:12 -07:00
Parth Sareen 6e19e916c7 app: reject browser origins on Claude Desktop gateway (#17989) 2026-08-25 09:48:44 -07:00
Eva H f6c59d8703 app: add Claude Desktop model mappings (#17979) 2026-08-24 22:43:47 -04:00
Parth Sareen 82ad9fa38b app: add Claude Desktop Auto mode setting (#17975) 2026-08-24 18:20:59 -07:00
Parth Sareen 60d83f8b0e app: make integrations list scrollable (#17977) 2026-08-24 17:46:45 -07:00
Eva H e2e82903fa app: improve desktop integration responsiveness (#17973)
* app: improve desktop integration responsiveness

* app: reconcile delayed Claude connection results

* app: preserve delayed Claude action errors
2026-08-24 19:25:24 -04:00
Eva H 939425152e app: fix desktop interaction regressions (#17970)
* app: fix desktop interaction regressions

* app: serialize settings reset updates
2026-08-24 19:13:48 -04:00
Anas Khan 02dc3ea4c3 cmd: guard empty editor before indexing fields (#17067)
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
2026-08-24 09:50:40 -07:00
Parth Sareen fb30760996 app: prevent Apps title bar overlap (#17925) 2026-08-21 18:48:22 -07:00
Devon Rifkin add1f92bdd launch: disable claude code token countdown to preserve KV cache (#17918)
Claude Code adds a "tokens left" system message after every tool
result. Since ollama moves system messages to the front of the prompt,
this breaks the KV cache on every request.
2026-08-21 17:44:44 -07:00
Parth Sareen 124e9af9d2 app: sign model recommendation endpoint (#17919) 2026-08-21 17:21:00 -07:00
Parth Sareen 2d9622a4d4 app: claude model management (#17915) 2026-08-21 15:04:58 -07:00
Eva H 30019c87c4 app: add Connect your apps experience (#17900) 2026-08-21 12:56:51 -07:00
Jesse Gross c44575ef14 mlxrunner: keep prefill snapshots when a request is cancelled mid-prompt
A long prompt records restore points during prefill, but they only
reached the prefix trie when the prefill completed; a cancelled request
closed and released everything it had captured. Agent clients routinely
cancel long prefills — their timeouts are shorter than the minutes a
40k-token prompt takes — so every retry started the whole prompt over
and never got further than the timeout allowed, which presents as the
model hanging forever.

Closing a session now attaches every snapshot the prefill crossed, so a
retry resumes from the last one and makes progress across timeouts.
Scenario tests cover retries resuming exactly where a cancelled attempt
stopped and cancellations on divergent conversation variants.

Fixes #17839
2026-08-21 09:58:33 -07:00
Jesse Gross 81f9a394e9 mlxrunner: grow the prefix trie by whole child nodes so restore points survive resumed prefills
A prefill that resumes partway into cached history — routine once
client timeouts interrupt long prompts — used to attach its captures
onto a node extended in place, so the stored snapshot spanned only the
tokens the prefill evaluated while the node's edge reached further
back. Restores walk node by node and trust each snapshot to cover its
node's edge; the short snapshot stranded the caches at mismatched
offsets and, on models with recurrent layers, ended up freeing all
cache state — a request matching 46k of a 47k-token prompt reprocessed
from zero.

Growth now never extends a node underneath its snapshots. New tokens
become a child node that carries exactly its own captures, and the
path stays compressed because non-user segments merge back into their
parent through the caches' snapshot Merge. Close already pages out
what it records, so every merge combines adjacent covered snapshots
and every stored snapshot spans exactly its node's edge.
2026-08-21 09:58:33 -07:00
Jesse Gross 30e2891808 mlxrunner: page out generated tokens when close records them
When a session closes, every cache rests exactly at the end of the
segment the trie is about to record. That is the one moment the
segment's state can be captured for every layer, so close now pages
the new segment out itself instead of recording it without snapshots
and leaving the capture to a later path switch.

Path switching then has nothing left to capture and only rewinds and
pages in. The whole-state entry taken at close is released when the
next request grows past the segment; sliding-window layers pay the
same window copy a scheduled capture already costs.
2026-08-21 09:58:33 -07:00
Jesse Gross b315b3ee97 mlxrunner: clip prefill captures to their trie node's edge
Page-in restores a path node by node and trusts each stored snapshot to
cover its node's whole edge. A capture taken during prefill spans from
the previous capture or the prefill base, which need not line up with
the node it lands on: when a prefill resumes partway into cached
history, a capture can reach back before its node's start, and a
capture landing on a node that already has snapshots replaced them
with a shorter span that page-in then could not serve.

Clip each capture to its node's edge on attach, and keep the snapshots
the node already has instead of replacing them.
2026-08-21 09:58:33 -07:00
Jesse Gross c01eafa552 mlxrunner: settle the draft caches when a prefill is cancelled
A prefill settles the drafter with the seed token after its last chunk,
leveling the draft caches with the targets; a cancelled prefill
returned before that, leaving the targets one token past the draft
caches and the recorded keys. The next request then had to move every
cache, and models with recurrent layers, which cannot rewind, fell back
to the last snapshot: a retry after a client timeout lost up to a full
snapshot interval of the prompt it had just evaluated.

Settle with the next prompt token on the cancelled path too. The caches
then rest level with the recorded keys, and a retry resumes exactly
where the prefill stopped.
2026-08-21 09:58:33 -07:00
Parth Sareen 8f912415e8 launch: fall back to npx for DeepSeek Harness (#17758) 2026-08-20 14:04:44 -07:00
Eva H 5ad1681cf1 polish onboarding layout and disable zoom (#17885) 2026-08-20 17:03:58 -04:00
Parth Sareen 30546d1fd4 app: add claude desktop app (#17899) 2026-08-20 14:03:43 -07:00
Daniel Hiltgen 6bba484f1a lint fixes (#17897) 2026-08-20 10:23:25 -07:00
Daniel Hiltgen e92b7855f6 mlx update (#17886) 2026-08-20 10:02:41 -07:00
Daniel Hiltgen 4e13421378 mlx: fix mac assumptions on linux/windows (#17898)
The default packaging was broken due to mac assumptions
leaking into windows
2026-08-20 09:50:10 -07:00
Eva H b7871fc0d1 app: add desktop onboarding flow (#17853) 2026-08-19 15:40:16 -07:00
Daniel Hiltgen e0c95a5ffd server: don't wedge chat and generate on a mid-stream parser error (#17883)
When a builtin parser rejects model output, the completion callback wrote the
error to an unbuffered channel and returned. The callback cannot stop
generation -- it has no error return -- so the next chunk re-entered the
callback, hit the same parse error and blocked writing to a channel the
consumer had already stopped reading after emitting its 500. The completion
never returned, the goroutine leaked and the runner request was never
released, so retrying the same prompt hung with no log output until the client
gave up.

Record the parse error, cancel the completion, and report it once the
completion has returned. Parse failures landing on the final chunk were
already terminal, which is why non-thinking requests and the direct
qwen3-coder parser path failed cleanly and only thinking mode wedged.

ChatHandler and GenerateHandler share the defect: both run the same parser in
the same shape of callback behind a consumer that stops reading at the first
error. GenerateHandler had no cancel func at all, so one is added there.

Fixes #17825
2026-08-19 15:14:50 -07:00
Parth Sareen b8a6272440 qwen3.8: normalize system messages (#17855) 2026-08-19 13:08:09 -07:00
Daniel Hiltgen d1bd15ccce ci: plumb temporary MLX patch through to docker stages (#17874)
Follow up to #17850
2026-08-19 08:33:53 -07:00
Daniel Hiltgen 0bb0925920 mlx update (#17850)
Temporarily carry https://github.com/ml-explore/mlx-c/pull/127
2026-08-19 07:15:11 -07:00
Gaurav Garg a5165c53ac Add a model metadata cache to reduce Ollama’s per-request overhead (#17752) 2026-08-18 12:22:51 -07:00
Daniel Hiltgen cd37044093 llama.cpp update (#17851) 2026-08-18 11:53:00 -07:00
Daniel Hiltgen d67ad83426 mlx update (#17761) 2026-08-15 11:56:40 -07:00
Daniel Hiltgen e5a81899d0 llama.cpp update (#17760) 2026-08-14 19:03:20 -07:00
Parth Sareen 78e818e3ce docs: register DeepSeek Harness (#17751) 2026-08-14 14:30:21 -07:00
Daniel Hiltgen 87abaa019e renderers/qwen: tolerate non-leading system messages (#17757)
Coding clients may insert runtime system messages after the initial user turn. The shared Qwen renderer rejected these transcripts before rendering, turning a potentially usable non-standard request into an HTTP 500.

Pass non-leading system turns through the existing raw ChatML path and warn when qwen3.8 encounters one. Extend the Anthropic tool-route integration scenario to cover this message pattern and remove the obsolete rejection test.
2026-08-14 14:12:50 -07:00
Daniel Hiltgen f427fa0753 llm: transcode WebP images for llama-server (#17755)
llama-server does not currently support WebP image payloads. Detect WebP media before forwarding, and transcode it to PNG. Pass all other media through unchanged.

Replace an existing vision integration image with a lossless WebP version so we now have coverage of JPG/PNG/WebP formats.

Fixes #17753
2026-08-14 13:21:11 -07:00
Daniel Hiltgen 0f25c31bd5 qwen3.8: support developer instructions (#17749)
* qwen3.8: support developer instructions

Qwen3.8 does not define a developer role, while OpenAI-compatible coding agents commonly send developer instructions before user messages. Fold the leading system/developer instruction prefix into a single system turn before Qwen3.8 validation, preserving instruction precedence without changing Qwen3.5 or other renderer behavior.

Add streaming tool-call integration coverage for the native Ollama, OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages request shapes. Each case exercises prior assistant tool calls, tool results, follow-up rendering, and parsed tool-call output. Add Qwen3.8 to the release tools sweep.

Removes an unnecessary unit test that should not have been included in the original 3.8 PR.

* review comments
2026-08-14 11:30:27 -07:00
Daniel Hiltgen 5512797527 qwen3.8: add renderer and MLX import support (#17745)
Qwen3.8 keeps the Qwen3.5 model architecture and parser, but its chat template adds reasoning-effort and preserved-thinking semantics. Detect those template markers during safetensors import, select the qwen3.8 renderer, and cover thinking, tools, continuation, and malformed parser input.

Make indexed safetensors imports use the weight map's shard names instead of independently filtering files by the model-* convention. Reject unsafe shard paths, ignore unindexed tensors, and fail when an indexed weight is missing or stored in a different shard. Retain the conservative model-* scan when no index is present.

Treat Classification.Quantize as the effective tensor format and pass it to the manifest writer. This records file_type for automatic block-FP8-to-MXFP8 conversion and recognized prequantized inputs, preserves requested quantization and base-plus-draft behavior, and avoids claiming one type for mixed or unknown formats.

Normalize both supported convolution weight layouts with an explicit reshape. Add focused unit coverage for renderer selection, parser behavior, shard inventory, manifest metadata, and convolution layout; heavyweight reference-forward and release integration checks remain bring-up artifacts.
2026-08-14 09:31:09 -07:00
Parth Sareen 39df91c982 launch: add DeepSeek Harness integration (#17733) 2026-08-13 15:19:02 -07:00
Daniel Hiltgen 7ce88bd686 model/renderers: match Muse Glimmer reasoning template (#17732)
Updates Muse Glimmer Jinja reference template to the latest publisher version and mirror its explicit-system reasoning handling in the Go renderer.

Explicit system prompts now normalize "Reasoning effort" to "Reasoning strength" and skip adding a renderer-provided reasoning line when the prompt already contains one. This prevents duplicate or conflicting reasoning directives while preserving the default-system behavior.

Add reference tests for both normalization and deduplication, including Jinja-backed validation.
2026-08-13 14:35:50 -07:00
Daniel Hiltgen 01d04d50f8 launch: add Muse Code integration (#17594)
* launch: add Muse Code integration

Add `ollama launch muse` for Meta's Muse Code CLI.

Muse only takes a model catalog from settings.json (normally it fetches one from its provider and refuses to start otherwise), and that file's endpoint_transport is a global provider switch. So the integration writes a settings file under its own config root (~/.ollama/launch/muse-config via XDG_CONFIG_HOME), leaving a Meta-backed muse install untouched, and re-seeds it from muse's own persisted copy on later runs.

The launched model is preloaded so its catalog row carries the context length the server actually allocated, not the trained maximum; the loaded-context helpers move from cmd/agent_tui.go into cmd/launch for reuse.

Muse sends reasoning efforts outside Ollama's scale (minimal, xhigh, ultra), which were hard 400s; clamp them to the nearest tier in one helper shared by the chat and responses converters.

The registry entry stays Hidden (alias "muse-code"), like kimi and vscode.

* review comments

* skip muse test on windows (unsupported platform)
2026-08-13 13:10:29 -07:00
Parth Sareen 9a56a0e845 agent: allow multiple edits per edit tool call (#17711) 2026-08-12 16:45:53 -07:00
Daniel Hiltgen 88313499e0 mlx: avoid pulling MLX models when MLX is missing (#17710)
As we look to bring Linux and Windows MLX support online, instead of blocking
downloads at the registry to avoid users wasting time downloading a model they
can't run, shift the logic to the local side which knows if MLX is present or not.
2026-08-12 14:42:17 -07:00
Jesse Gross 2b4a99376c nn: speed up prefill on double-scale nvfp4 models
ModelOpt checkpoints apply a float32 global scale to every projection
output on top of the per-group quantization scales. Running the
multiply and the cast back to the activation dtype as separate eager
ops costs an extra kernel launch and a materialized intermediate per
projection.

Compile the multiply and cast into one kernel. On an M5 Max (medians
of order-swapped A/B runs against main; greedy outputs byte-identical):

    qwen3.6:27b        prefill  703 -> 769 t/s  +7.9%
    muse-glimmer:30b   prefill  790 -> 843 t/s  +6.7%

Speculative decode is unchanged within noise on both models. Only
checkpoints with a global scale are affected; single-scale nvfp4,
mxfp8, and affine checkpoints take the unchanged path.
2026-08-12 13:25:33 -07:00
Daniel Hiltgen e922bc7125 llama.cpp bump (#17702) 2026-08-12 12:10:18 -07:00
Daniel Hiltgen 950dd9ac67 MLX update (#17704) 2026-08-12 12:09:50 -07:00
Parth Sareen b6b1b258c3 openai: support web search in Responses API (#17686) 2026-08-12 11:51:54 -07:00
VigneshandPatrick Devine 4138e853d5 server/images: prevent skipVerify map collision with duplicate digests (#15504)
When a manifest contains a config and layer with the same digest, the
skipVerify map entry was overwritten by the config's cache-hit value
(true), replacing the layer's non-cache-hit value (false). This caused
verifyBlob to be skipped for the freshly downloaded blob.

A rogue OCI registry could exploit this by serving a manifest with
duplicate digests and redirecting blob downloads to internal endpoints.
The SSRF response would be written to disk, hash verification would be
skipped due to the map collision, and the blob would persist.

The fix uses logical AND when updating skipVerify: once any download of
a digest was not a cache hit, verification is always performed.

Fixes #15485

---------

Co-authored-by: Patrick Devine <patrick@ollama.com>
2026-08-12 11:44:32 -07:00
Daniel Hiltgen 641df5e5ad mlx: enable CUDA backend in CUDA builds (#17688) 2026-08-12 07:31:00 -07:00
Jesse Gross 6a261db7d8 api: stop applying repeat_penalty 1.1 to models that don't set one
Request options are the model's published parameters and the request's
own options layered over the server defaults, so the default
repeat_penalty of 1.1 reaches every model whose parameters leave it
unset. No maker of the library's current models recommends 1.1: their
generation configs either omit the penalty, meaning 1.0, or pin 1.05.
llama.cpp dropped the same 1.1 default in 2024; vLLM, SGLang, and
transformers apply no penalty. An always-on penalty also distorts
output that legitimately repeats tokens, such as code, JSON, and long
reasoning traces.

The penalty is especially costly for speculative decoding, where
drafts are proposed without it: the penalized target rejects drafted
tokens and the depth controller backs off. On muse-glimmer 30B (DFlash
on M5 Max, HumanEval) the 1.1 default costs 13-16% of end-to-end
throughput at greedy and temperature 1 alike, and drops prose
acceptance at temperature 0.8 from 0.44 to 0.30. On qwen3.6-35B it
cuts the mean accepted draft length from 4.3 to 3.5 tokens and makes
the controller stop speculating on prose.

Defaulting to 1.0 disables the penalty unless a model's parameters or
the request set one. Across the library:

- qwen3, qwen3.6, and qwen3-coder pin their own values (1.0, 1.0, and
  Qwen's recommended 1.05) and are unchanged.
- Everything else local now matches its maker's no-penalty
  recommendation, including gemma2 through gemma4, muse-glimmer, both
  laguna 2.1 models, qwen3.5 (previously 1.1 stacked on its
  presence_penalty of 1.5), gpt-oss, deepseek-r1 and v3.1, the
  nemotron family, granite4, the mistral and llama3/llama4 families,
  phi4, glm4, llava, and devstral.
- qwen2.5 recommends 1.05 but ships no parameters, so it moves from
  1.1 to 1.0 and still needs a parameters layer to conform.
- Cloud models (kimi-k3, deepseek-v4-flash) never receive these
  defaults.

Small older models may repeat themselves more without the penalty
masking it; the remedy is a per-model parameter, not a penalty applied
to every model.
2026-08-11 21:47:51 -07:00
Eva H 948f69330a docs: fix broken links (#17676) 2026-08-11 14:36:39 -07:00
Daniel Hiltgen 96fb6d2fa9 nemotron_h: support the Nemotron 3.5 prompt layout (#17672)
Select the 3.5 parser and renderer from its checkpoint template, preserve its prompt semantics, and map medium reasoning effort to the final-user annotation expected by the reference template.

Exercise parser and renderer registration, create-time metadata inference, and exact Jinja parity so created models cannot silently fall back to the Nemotron 3 renderer.
2026-08-11 06:18:51 -07:00
Daniel Hiltgen 400164d47c parsers: recover boundary tokens fumbled into glimmer ATEM invoke names (#17664)
The model occasionally emits a <|message|> boundary token in the invoke
name region, echoing the header form `to=read<|message|>`. The existing
recovery handled the tag inside a terminated name (`name="read<|message|>">`)
but not the fleet-observed shape where the tag replaces the `">` terminator
itself (`name="read<|message|><atem:parameter ...`), which failed the call
with "malformed ATEM parameter".

Replace the strip-after-cut recovery with a single name scan shared by
parseGlimmerATEM and the content fallback: the name ends at the first `">`,
boundary tokens before it are dropped, and a parameter element immediately
after a dropped token means the token replaced the terminator. Well-formed
calls are unaffected — a boundary token is never legitimate before the
terminator, and parameter values (where the literal text is preserved) only
appear after it. Murkier garbles still fail loudly, the recipient
cross-check still applies, and the recovery WARN is retained.
2026-08-10 21:48:17 -07:00
Daniel Hiltgen bb7bba885e mlx: implement Nemotron 3 Nano Omni (#17060)
Add MLX support for Nemotron 3 Nano Omni, including the model implementation, Mamba2/recurrent pieces, MoE routing, and quantized NVFP4/MXFP8 expert paths.

Use a shared mapped MoE GatherQMM fast path under the generic moe_gather_qmm_mapped naming, with Metal-optimized NVFP4/MXFP8 block-mapped kernels and generic fallbacks for unsupported backends.

Serve the model's multi-token prediction head as a self-draft speculator, so speculative decoding needs no separate draft model.

Render the Nemotron prompt from the published chat template. The template the renderer was based on had drifted from the current reference; refreshing it surfaced five mismatches: stray leading newlines, the wrong turn separator and a trailing newline before the generation prompt; /think and /no_think toggles left in user turns; a trimmed system message the template leaves intact; a user block opened by a leading tool message; and Go scalar syntax for schema extras where the template applies Python str(), sending true/false/<nil> in place of True/False/None. Reference tests now render every case through the template itself.

Also harden the Nemotron parser path shared by both backends: while collecting thinking, preserve whitespace before partial </think>, <think>, and <tool_call> fakeouts, with streaming tests covering those cases.
2026-08-10 21:42:34 -07:00
Daniel Hiltgen 4f066a6fb0 llama.cpp update (#17659) 2026-08-10 15:48:32 -07:00
Eva H a836eb8c3c docs: require VS Code 1.127 (#17655) 2026-08-10 11:21:26 -07:00
Eva H 1a9e4235ac docs: add VS Code context length guidance (#17610) 2026-08-10 09:46:25 -07:00
Daniel Hiltgen 43f4eda808 Release v0.32.7 (#17646)
* glimmer: implement the Muse Glimmer model

MLX model (language + vision encoder) with DFlash draft wiring, llama-server DFlash support and rope-interleave fix, renderer and parser, tokenizer fixes, and the import quantization policy.

* mlxrunner: report committed prefill chunks after the sweep and eval

The drafter's flush evaluates its report, and an eval that runs while the chunk's construction handles are still live cannot free any intermediate buffer. On media chunks that retention keeps the whole vision tower resident and grinds the Metal allocator at its limit until the request dies. Pin the report's inputs across the sweep, report after the chunk materializes, and release media items after the report so a drafter can still capture the rows its deferred flush embeds.

* ci: retry CUDA pre-release download
2026-08-10 04:04:56 -07:00
Daniel Hiltgen acdf81510d MLX: version bump (#17637)
Also bring back version tagging the MLX library with our git hash which was
accidentally dropped when imagegen was removed.  Without this, the version
claimed to be the official tagged version, but we're typically using a git hash
with different content.
2026-08-09 10:38:49 -07:00
Jesse Gross 1e85fe8e9a qwen3_5: image input support
One vision path serves every qwen3.5/qwen3.6 registration, dense and
MoE. Rope positions are precomputed at prepare time as the request's
layout — the family uses interleaved M-RoPE — while text-only requests
keep the fused 1D rope path, which is numerically identical for
uniform channels. Image expansions are causal for this family, so
prefill chunks split them. The MTP head embeds prompt tokens, so it
scatters the delivered image features and applies the same position
tables, keeping speculative decoding working on image prompts. The
merger's exact erf GELU adds an Erf op to the MLX bindings.

A checkpoint whose config declares vision must ship its tower: missing
vision weights or a deepstack_visual_indexes request fail the load
rather than silently serving text-only or skipping the injections.
Text-only checkpoints, which carry no vision_config, load as before.

Verified tensor-by-tensor against HF transformers for all eight family
members and live on every published -mlx tag; published towers are
already bf16, so no re-import is needed.
2026-08-09 10:37:05 -07:00
Jesse Gross 5fcf71b8b8 mlxrunner: feed media features to the model during prefill
Each media item's features are encoded lazily when a prefill chunk
first overlaps its expansion and stay pinned until the expansion is
fully evaluated. A chunk never ends strictly inside an atomic
expansion: a bidirectional run's early rows attend its later keys, so
its first evaluation must cover the whole run in one forward. Items
marked Causal are exempt and split at any boundary.

Draft models need the same request state — reference MTP drafters
embed prompt tokens with the image features merged in, and an M-RoPE
drafter cannot compute positions without the request's layout — so the
layout is stamped on every forward, target and draft alike, and the
MTP session holds feature rows across its deferred flush. The dflash
drafter ignores media: its context rows are target hiddens.
2026-08-09 10:37:05 -07:00
Jesse Gross 60bdc23467 mlxrunner: expand image tags into placeholder tokens
A prompt that references media arrives as text containing [img-N] tags
plus the media bytes. Prepare now splits on the tags, tokenizes the text
between them, and hands the model the resulting segments — text runs and
media in stream order — in a single PrepareMedia call. The model returns
the expanded stream with each media segment's placeholder expansion
spliced in place, described per item so the runner can key identity and
schedule encoding, along with any opaque request-scoped layout state it
derives while building the stream. Building the whole stream in one call
is what lets a model derive values that span items, and lets it choose
item granularity (one per image, or one per independently evaluable
tile).

The runner validates the model-authored items before trusting them —
ranges ordered, non-overlapping, in bounds, and covering every media
segment, since prefix-cache identity is keyed on them.

Unknown tag IDs fail the request, media the prompt never references is
ignored with a warning, and duplicate references are allowed, matching
the previous engine. A media request still produces no image output:
nothing feeds the features to the model yet, and no model implements
the media interface.
2026-08-09 10:37:05 -07:00
Jesse Gross 694487c65b mlxrunner: fold media identity into prefix-trie keys
Media placeholders repeat one token ID, so two prompts with different
images would produce identical trie keys and falsely share cached state.
Substitute a per-item hash of the media bytes and preprocessing shape
across each item's expansion range at the key layer; the model still
sees real token IDs. Fold values carry a bit no token ID has, so a media
stream can never alias text, and the bigram packing for draft caches
composes unchanged, so draft restore points inherit the same identity.

Text-only prompts key exactly as before. Nothing records media items yet;
the change is inert until the prompt preparation wires them.
2026-08-09 10:37:05 -07:00
Jesse Gross af5b627672 mlxrunner: reject media requests the model cannot serve
MLX checkpoints that include a vision tower are already tagged with the
vision capability at import, so the server accepts image chats and ships
the image bytes with the completion request. The MLX client dropped the
bytes, and the prompt's image tags were answered as literal text.

Carry the media through to the runner and fail the request with a clear
error when the loaded model has no media support. Nothing implements the
new media interface yet, so every media request now returns the error
rather than a silently wrong answer; later changes build the image path
on top of the same interface.
2026-08-09 10:37:05 -07:00
Jesse Gross 8713570d3c create: keep vision towers at source precision when quantizing
Vision towers are much more sensitive to weight quantization than
language layers: measured against the reference encoder on a real
image, 4-bit types and scale-only mxfp8 distort the projected image
features by 26-34% mean relative error (worst tokens near-orthogonal),
which shows up as degraded image recognition — down to complete
blindness for the small e-series towers under nvfp4. Affine 8-bit was
the only quantized format that matched the bf16 tower.

Keep vision tower tensors at source precision instead, matching the
audio tower's treatment and every vision component Ollama publishes in
GGUF form, including gemma4's own GGUF tags, which ship f16/f32 vision
beside 4-bit language weights. Towers are small and run once per image,
so neither size nor decode bandwidth argues for quantizing. Existing
MLX imports keep their quantized towers until re-imported.
2026-08-09 10:37:05 -07:00
Daniel Hiltgen 5a173edb63 manifests: remove OCI rootfs from the model config (#17619)
rootfs.diff_ids duplicated the manifest's layer digest list into the config blob and nothing ever read it. On per-tensor safetensors models the copy grows past 100KB and create excessively large config blobs with unused redundant data. Model identity is unaffected: it is the digest of the manifest itself, which already commits to every layer hash.
2026-08-08 19:44:57 -07:00
Jesse Gross b880b76c43 laguna: wire the DFlash target side
Add what a DFlash draft borrows from its target: the tapped layer
outputs, the raw embedding lookup, and the undecorated lm_head
projection. The laguna draft architecture (DFlashLagunaForCausalLM) is
registered here, alongside the only wired target.

Matched nvfp4 target+draft pairs, M5 Max, temp 0.8, repeat_penalty 1.1,
adaptive depth; decode tok/s:

                     prose   code   edit
  laguna-xs  plain   139.4  139.7  137.3
             DFlash  142.3  139.2  145.1
  laguna-s   plain    75.4   70.0   72.4
             DFlash   74.6   80.8  115.3
2026-08-07 19:33:35 -07:00
Jesse Gross cf129bbb11 dflash: add the DFlash block-diffusion draft model
Implements the DFlash draft checkpoint format: a few decoder layers
over fused target-layer outputs, which enter every layer as key/value
context while the block being drafted supplies the queries. The draft
has no embedding table or output head of its own; it borrows the
target's.

One model covers the known checkpoints. Attention weights normalize at
load to a q projection plus a fused k|v, stacking split checkpoints and
slicing fused ones, exact for quantized tensors; gate and up fuse the
same way. Optional tensors decide the output gate and per-tap norms,
config decides attention shape, and the architecture name decides only
laguna's context-norm convention.

A manifest can pair any draft with any target, so construction checks
the fit: tap ids inside the target's layers, matching hidden width, and
the target vocabulary covering the mask token. A bad pairing fails at
load.
2026-08-07 19:33:35 -07:00
Jesse Gross c1bf60d7b1 mlxrunner: add a block-diffusion drafting session
A DFlash draft proposes a whole block per forward, which doesn't fit
the MTP session's one-token-per-call chain. Add a second drafting
session for block drafts: committed target features write straight
into the draft's context caches, and each round drafts a block in one
forward and samples it in one batched call, rolling the block's cache
entries back with the same mechanism speculative rounds use on the
target caches. The depth controller's search is capped at the deepest
draft the drafter can produce, since a depth it can never measure
would otherwise always look best.
2026-08-07 19:33:35 -07:00
Jesse Gross 0fcfc99ea0 sample: define multi-row distributions without a draft chain
Distribution aligns its rows with the end of the draft chain, so when
the caller passes no chain, every row sees the slot history unchanged.
That case already worked; only the row-count guard rejected it. The
guard now applies only when a chain is present, which is where more
rows than chain positions would silently drop history. A block drafter
needs the chainless case to sample its whole proposal batch in one
call.
2026-08-07 19:33:35 -07:00
Jesse Gross e7fbd528f7 mlxrunner: let each model declare the cache slots it needs
The runner used to build caches by probing the model for an optional
NewCaches method, with one KV cache per layer as the fallback. A model
with a draft head appended the draft's cache slots to its own list, and
the speculative engine later recovered the two groups by comparing slot
identities, panicking when the lists didn't line up.

NewCaches is now a required method on both the model and the draft, and
each returns only the slots it writes. The runner concatenates the two
lists for the prefix cache and passes them to the speculative engine
separately, so snapshots and rollback apply to the target's slots and
the draft forward receives both groups as arguments. The identity
comparison, its panics, and the per-request rebinding are gone; the two
groups are fixed at load time.
2026-08-07 19:33:35 -07:00
Jesse Gross 2f84872ce0 mlxrunner: return the draft-conditioning state from a model forward
A draft model conditions on state that the target produces during its
own forward pass. For an MTP head or an assistant model that state is
the final hidden state; for a block draft it is the concatenated
outputs of several layers. The choice belongs to the model, so Forward
now returns the conditioning state along with the hidden state to
unembed. Models without a special conditioning state return the final
hidden state for both, and the decode paths hand the value to the
drafter without looking at it.
2026-08-07 19:33:35 -07:00
Parth Sareen f91cb0d6a7 agent/tui: stream thinking traces (#17611) 2026-08-07 13:11:45 -07:00
Eva H 8dd34b77d1 cmd/tui: restore launcher integrations menu (#17595) 2026-08-07 11:11:16 -07:00
Daniel Hiltgen 35f71382de openai: expand namespace tool declarations in the responses API (#17593)
The Responses API groups related tools by domain: a tool with type "namespace" carries the real function definitions in a nested tools array. The conversion dropped that array, leaving the model a single schema-less pseudo-function and making every namespaced call undeclarable.

Expand namespace declarations into their member functions with namespace-qualified names, since api.Tool carries only a flat function name.

Relates to #15921: full Responses API parity also wants the namespace preserved as a separate field on tool calls in the output, which needs new api surface and is not addressed here.
2026-08-07 09:52:37 -07:00
Jesse Gross 144893850f mlxrunner: stop cache rewind refills from corrupting later lazy snapshots
A lazy KV snapshot indexes into the cache's live buffer instead of owning
a copy, so it must be copied out before an append overwrites the slots it
names. appendKV checked for that only on the first append after a rewind,
and only against that append's own range: a still-lazy snapshot further
ahead in the buffer was overwritten without a copy when a later append
reached it. This happens when a request reuses a short prefix of a longer
cached conversation and prefills past one of the old conversation's
snapshots; restoring that snapshot later silently serves the new request's
KV in place of the old conversation's.

Scan every append instead. The overlap test already limits copies to
snapshots the current write clobbers, and appends outside a rewind refill
sit above every snapshot, so the steady-state scan walks a short list and
finds nothing. This restores the invariant Restore's lazy fast path relies
on: a snapshot still in its lazy state has never been overwritten.
2026-08-05 16:31:56 -07:00
Daniel Hiltgen 26936bea45 ci: fix race in darwin build (#17578)
Do vendoring work once at the top level build to avoid 2 nested builds fighting
with eachother.
2026-08-05 11:10:18 -07:00
Daniel Hiltgen 43983edf18 progress: fix data races on ticker, states, spinner, and bar state (#17445)
* progress: fix data races on ticker, states, spinner, and bar state

NewProgress spawned start() which wrote p.ticker while stop() read and
cleared it with no synchronization; stop() and StopAndClear() also read
p.states and p.pos outside p.mu, Spinner's start() goroutine raced
Stop() and String() on s.value/s.stopped/s.ticker, and Bar.Set raced
Bar.String on currentValue/stopped/buckets (callback goroutine vs the
render goroutine). Detected by go test -race across cmd and cmd/launch
(~20 warnings; the Bar race is latent — never flagged because tests
don't interleave it, but real in production pull/push progress).

Create tickers before spawning the render goroutines and pass the
channel in, guard Progress internals with p.mu throughout stop() (via a
renderLocked core), and give Spinner and Bar their own mutexes.

* use a more idiomatic channel based done signal
2026-08-04 15:06:15 -07:00
Daniel Hiltgen c82ebbd5bf llama.cpp update (#17545) 2026-08-04 09:51:52 -07:00
Bruce MacDonald 8edecb5c69 openai: match openai's streaming wire format for chat completions (#17485)
Reworked our /v1/chat/completions streaming to match what api.openai.com actually sends,
chunk-for-chunk, based on captures I took of real OpenAI traffic.

What changed:
 - finish_reason now goes on its own chunk with an empty delta {}, instead of riding on the last content
   chunk. Precedence is length > tool_calls > the response's done reason > stop.
 - role is only sent on the first chunk of a stream, not on every chunk.
 - With stream_options.include_usage, usage goes out on its own chunk with choices: [] after the finish
   chunk.
 - A truncated response keeps finish_reason: "length" even when tool calls were streamed — it used to get
   overwritten with "tool_calls". Fixed in both streaming and non-streaming paths.
 - The metrics-only trailer response (empty message at end of stream) no longer produces a stray
   delta:{"content":""} chunk before the finish chunk. A wholly empty completion still opens with a role
   chunk.
 - Every chunk in a stream shares one timestamp, from the response's CreatedAt.
2026-08-03 15:36:57 -07:00
Devon Rifkin 8d8c701d6a Merge pull request #17483 from ollama/drifkin/suggest-cloud
cmd: suggest :cloud when a model has no default tag
2026-07-31 14:28:04 -07:00
Daniel Hiltgen b63eed94b6 app/updater: drain background update-check goroutine before returning (#17446)
DownloadNewRelease spawned a background checkForUpdate loop that read
package-level knobs (UpdateCheckInterval et al.) and returned without
waiting for it, so under -race the next test rewrote those globals while
the orphaned goroutine was still reading them. waitDownloadIdle (from

Cancel and WaitGroup-drain the loop before DownloadNewRelease returns,
and have TestCancelOngoingDownload join its download goroutine so the
drain is observable before the test exits.
2026-07-31 10:42:40 -07:00
Jesse Gross 4f9d09ef52 qwen3_5: load and run the MTP head as a speculative draft
Load the MTP head from the mtp.* tensors instead of freeing them and implement
Draft to propose one token per step, gated solely on the tensors being
present; a model whose head ships inline is its own draft via base.SelfDraft.
The runtime keeps sole ownership of the +1 RMSNorm shift (conversion passes
tensors through verbatim), and the head's norms shift under the same
original-format detection as the main stack, so nothing shifts twice.
2026-07-31 10:18:54 -07:00
Jesse Gross ba8f2a324d nn/recurrent: run the gated-delta step in one launch
Decode-length scans spend more time in launch gaps than math: the q/k
norms, decay gate, and recurrence each dispatched separately per layer.
Fuse the step into one Metal kernel over the activated conv output,
with per-token boundary states available from the same pass. The graph
implementation remains as the fallback and contract-miss path, and pins
the kernel bit-for-bit in the parity test.
2026-07-31 10:18:54 -07:00
Jesse Gross 721f05049d nn/recurrent: activate the conv output in CausalConv1D
The activation belongs to the conv stage: downstream consumers see
activated values however the conv is computed. WithConvSiLU routes to a
fused depthwise conv+SiLU kernel when the conv fits its contract and
the same computation as graph ops otherwise; cached conv state is the
raw input tail, unaffected by activation placement.
2026-07-31 10:18:54 -07:00
Jesse Gross accd6d656a mlx: factor custom GPU kernel scaffolding into helpers
Each custom kernel repeated the same host-side creation and launch
boilerplate plus a CUDA-then-Metal-then-graph dispatch at every call
site. gpuKernel declares the sources (either backend may be absent) and
a graph fallback; run executes the first that works.
2026-07-31 10:18:54 -07:00
Jesse Gross bd3f22e2f7 qwen3_5: pack GDN input projections into one layout at load
Split checkpoints ran four input projections per recurrent layer, and
native combined checkpoints paid a per-forward slice-and-concat to
rebuild the contiguous qkv rows the causal conv consumes. Normalize
both at load to packed [q|k|v|z] and [beta|alpha] rows: split tensors
concatenate, native interleaved tensors permute once. The forward keeps
a single projection path, and the packed rows are the layout a fused
scan can consume directly.

Pairs with mismatched quantization dequantize before packing rather
than keeping a split fallback path alive.
2026-07-31 10:18:54 -07:00
Jesse Gross 5db07cad71 mlx: apply global scales in Dequantize
The C-level dequantize accepts a global_scale argument but rejects it
on the Metal backend, so dequantize-fallback sites hand-rolled the
same post-multiply. Take the scale in the Go wrapper and apply it on
top of the op, cast back to the output dtype. The quantized embedding
passes its scale; laguna's expert paths keep their own multiplies,
which shape per-expert scales and differ on result dtype.
2026-07-31 10:18:54 -07:00
Jesse Gross acf96e7ab7 mlx: read scalar items at the array's element width
mlx item<T> reinterprets without checking the dtype, so Array.Int's
8-byte read of int32 scalars took in neighboring pool bytes — masked by
Metal's zeroed allocations, corrupting token IDs on CUDA's warm pool.
Read at the element's width.
2026-07-31 10:18:54 -07:00
Daniel Hiltgen a199313eb3 mlx update (#17476) 2026-07-30 10:16:29 -07:00
Daniel Hiltgen b205993ed4 CI: enable lint on the whole tree (#17457)
golangci-lint ran with only-new-issues, which filters findings down to the
lines a PR adds. That silently drops any issue a diff introduces at a
distance, where the report anchors to a line the diff never touched.
CI now is enabled to scan all files.  This PR also fixes the last few
straggler lint glitches outside of integration, which I'll tackle
in a follow up PR.
2026-07-29 16:25:38 -07:00
Daniel Hiltgen 9ea503f505 lint: clean up current tree (#17456) 2026-07-29 15:33:28 -07:00
Jesse Gross 3ff2dcb649 mlxrunner: count every speculative round and log stats at info
The per-request stats are the main diagnostic for speculative
throughput, so log them at info; the controller line stays debug.
Recording chosen depths at the next beginRound dropped rounds with no
successor, so record at endRound and count resume as a depth-0 round.
2026-07-29 14:55:19 -07:00
Jeffrey Morgan 4713800b08 imagegen: remove MLX image generation code (#16615)
Remove the x/imagegen tree (MLX image generation engine, Flux2/zimage
models, cache, C bindings) and all imagegen integration points:

- server: drop imagegen routes, scheduling, and generate handling
- api/cmd/docs: remove image generation API surface and docs
- middleware/openai: remove image endpoint support
- integration: remove imagegen test suites
- x/create: adopt the rewritten create pipeline from main; drop
  imagegen create path (CreateImageGenModel, IsTensorModelDir,
  model_index.json detection, Flux2KleinPipeline vision hack)
- retain x/imagegen/manifest (Ollama-store safetensors manifest
  loader), still used by x/mlxrunner and x/create/client
- fix Windows MLX dl.dll install, MLX CMake version path, and the
  show command after removing safetensors models
2026-07-28 15:35:28 -07:00
Parth Sareen 0e2e34aa86 cmd/tui: improve prompt debug rendering (#17334) 2026-07-27 17:29:18 -07:00
Parth Sareen 76929b0a8a agent: accept file mentions on Enter (#17384) 2026-07-27 17:28:55 -07:00
Parth Sareen bf7be180e3 tui: avoid table detection for pipe prose (#17424) 2026-07-27 13:42:08 -07:00
Daniel Hiltgen eec8e0b945 ci: on release builds dont fail fast (#17413)
If we have one flake, don't stop other jobs that will most likely work so when
we re-run failed jobs, only the flake and dependents need to be run.  This should
help reduce the time it takes to get past a flake and finish a release build.
2026-07-27 08:01:04 -07:00
Daniel Hiltgen be7572e2cf mlx update (#17397) 2026-07-26 16:59:47 -07:00
Daniel Hiltgen 64ee2f9847 model: add Laguna MLX support (#17237)
* model: add Laguna MLX support

Add Laguna XS 2, XS 2.1, and S 2.1 support to the MLX model and create paths.

Read the source config to apply one quantization policy across dense and routed MoE layers. Keep the tied output head and router at source precision, quantize supported attention and expert projections, selectively promote sensitive expert down projections, and emit per-tensor metadata for mixed quantization blobs.

Correct dense expert loading, BF16 source-layout handling, expert global-scale shapes and dtypes, routing-score scaling, and mixed-precision expert dispatch. Gate/up and down projections select quantized or dense execution independently so promoted BF16 down projections do not force quantized gate/up weights through the dense fallback.

Optimize the forward pass with compatible gate/up fusion, sorted standard GatherMM and GatherQMM operations for larger prefills, model-local mlx.Compile closures for elementwise MoE work, and cache-backed 512-token prefill chunks. This keeps the implementation on maintained MLX operations without custom kernels.

Add focused tests for Laguna configuration variants, quantization policy and metadata, dense and routed expert loading, mixed-precision dispatch, compiled-versus-eager parity, fused projections, routing, and prefill chunking.

* review comments and S 2.1 performance fixes

Address renderer/parser selection and mixed-precision expert quantization review feedback.

Keep Laguna weights resident on Metal to prevent repeated paging of its large, sparsely accessed expert buffers. Scope this policy to Laguna GPU execution.

Remove obsolete 512-token prefill chunking now that the runner's 2048-token path is faster.

* review comments addressed

* fix create
2026-07-24 18:24:53 -07:00
Jesse Gross 132e0ca25d x/create: quantize a draft model's output head at the requested type
Draft token embeddings were kept at source precision. A draft that
reuses its embedding as the output projection (the gemma4 assistant)
then reads the whole 537MB bf16 tensor on every draft step — about half
the step's cost. Draft quality only affects how many drafts are
accepted, so the output head now takes the requested type instead of the
8-bit type that protects a target's output quality.

gemma4:26b-mlx, M5 Max: MTP code decode 148 -> 157 tok/s (+26% -> +37%
over plain); prose goes from roughly zero to +2-5%; acceptance unchanged.
2026-07-24 17:26:58 -07:00
Daniel Hiltgen 9eef4a7195 mlx: keep loaded model memory resident (#17367)
Configure Metal residency after the MLX runner materializes model weights.

Wire up to the smaller of active model memory and the recommended working set, leaving pageable headroom for KV caches and request allocations. If residency setup fails, warn and continue with pageable memory.

Expose recoverable MLX C API errors and verify that an oversized wired limit preserves the previous state and leaves subsequent evaluation usable.
2026-07-24 15:34:32 -07:00
Parth Sareen 3f07e022ac cmd/tui: agent system prompt command (#17296) 2026-07-24 14:44:25 -07:00
Parth Sareen 551809688b agent: permission skill loading (#17304) 2026-07-24 14:37:21 -07:00
Jesse Gross 08edcb8f2c qwen3_5: gather packed gate_up experts in one launch
Gathering gate and up separately cost a third expert gather per MoE
layer. Keep gate_up packed as one tensor, joining it at load when the
checkpoint ships the halves separately, and split the gather's output
instead.

Output is byte-identical; decode is 4% faster (7.89 -> 7.58 ms/token on
M5 Max) and prefill 9% faster.
2026-07-24 14:34:56 -07:00
Jesse Gross d6f69da04d qwen3_5: decode each expert tensor with its own quantization format
The expert matmuls decoded with the model-wide format, so models whose
experts are quantized differently from the rest of the weights could
not run.
2026-07-24 14:34:56 -07:00
Daniel Hiltgen 6cd40001a9 server: fix ps data race on scheduler loaded map (#17376)
PsHandler iterated sched.loaded without holding loadedMu, racing with
scheduler goroutines that mutate the map. It also read runnerRef fields
(model, llama, expiresAt) that unload() and the expiration path mutate
under refMu, so a concurrently unloading runner could nil model out from
under the handler.

Instead of adding locking in routes.go, give the scheduler a small
snapshot API: loadedModels() copies the runner list under loadedMu, then
captures each runner's reporting fields under its refMu, respecting the
refMu-before-loadedMu lock ordering used by the expiration path. The
zero-expiresAt estimate for still-loading models moves into the
scheduler too, since it exists because of scheduler behavior.

Also remove the dead code Scheduler.GetRunner
2026-07-24 13:23:49 -07:00
Daniel Hiltgen a84b315e7b test: harden flaky updater and transfer unit tests (#17378)
app/updater: TestBackgoundChecker / TestAutoUpdateDisabledSkipsDownload hit 'TempDir RemoveAll cleanup: directory not empty' on macOS because the background checker goroutine keeps writing staged files into UpdateStageDir while t.TempDir cleanup runs. The checker's context is cancelled by the time cleanup runs, and after cancellation a new download cannot reach the filesystem (DownloadNewRelease aborts at its HEAD request before any write), so it suffices to wait for any in-flight download to drain. Add a test-only waitDownloadIdle helper (polls the existing cancelDownload sentinel under its lock) and register it via t.Cleanup so TempDir cleanup runs after staged-file handles close. No production code changes.

x/transfer: TestDownloadParallelism asserted elapsed <= 1s against 50ms-per-blob delays, too tight for Windows hosted runners' ~15ms timer granularity and shared-runner jitter. Each blob costs two server sleeps (resolve GET + body GET), so model the serial baseline from the deterministic request count, raise per-blob latency to 100ms so timer quantization is a small fraction of each delay, and key the budget to 75% of the serial baseline so the check still proves parallelism while tolerating jitter.
2026-07-24 13:23:30 -07:00
Jesse Gross 83d4311ffe x/create: quantize lm_head at 8-bit in the requested family
The lm_head rule was asymmetric: the fp modes kept an untied head at
source precision (even under mxfp8, leaving it the only bf16 matmul in
the model), while int4 quantized it at 4 bits with no promotion. The
tied-embedding overrides (gemma4, cohere2moe) already resolve the head
to the 8-bit family type and hold quality close to bf16.

Apply the same decision to untied heads: the 8-bit type in the
requested family when it fits the shape, source precision otherwise.
int4 now promotes the head to int8, and the fp modes quantize it to
mxfp8 instead of keeping bf16.
2026-07-23 17:46:09 -07:00
Parth Sareen fce745fe5e agent: import skills from coding agents (#17294) 2026-07-22 22:40:25 -07:00
Daniel Hiltgen 1fd1ccf7ad model: align Laguna with upstream llama.cpp (#17335)
Update llama.cpp to pick up upstream Laguna implementation and remove Ollama's local Laguna implementation. Retain a narrow Metal-only scaling workaround for routed-MoE prompt overflow.

Translate older Ollama GGUF attention-gate and SWA metadata names so existing models continue to load.
2026-07-22 17:09:18 -07:00
Michael Yang efb7e3c55e docs: update retirements (#17289) 2026-07-22 14:24:11 -07:00
Daniel Hiltgen b517b9bd01 model/parsers: finalize incomplete GLM tool calls (#17250)
The GLM parser buffered tool calls until it observed </tool_call>, but ignored the terminal done signal. If the model omitted or partially emitted the outer closing tag, Ollama returned a successful empty response instead of a tool call or an actionable error, leaving coding agents unable to continue.

On end-of-stream, finalize only structurally complete calls for declared tools with all required arguments. Complete calls missing only the outer delimiter now proceed through the existing parser, while genuinely truncated calls return an explicit error rather than being silently dropped.

Fixes #16497
2026-07-22 13:53:47 -07:00
Daniel Hiltgen 479664e7aa mlx update (#17332) 2026-07-22 13:36:49 -07:00
Daniel Hiltgen a51df81573 test: revamp integration test entrpoints (#16560)
This refactors the existing integration tests into 3 priumary groups: fast,
release, and library.  It also refines some of the release tests to drop some
of the older models and pick up newer models, while retaining the broad
coverage in the library group.
2026-07-21 16:06:38 -07:00
Daniel Hiltgen a18c230189 model: add Laguna v8 chat support and fix Metal inference (#17291)
Add a laguna-v8 renderer/parser matching the Laguna XS 2.1 template, and fix v2 handling of embedded thinking and structured tool arguments.

Prevent FP16 overflow in Metal's quantized routed-MoE prefill path by scaling the linear branch and folding the inverse into the routing scale. Other backends and token-generation paths are unchanged.

Add comprehensive v2/v8 Jinja parity and parser tests.
2026-07-21 16:06:29 -07:00
Daniel Hiltgen e21d5327b0 CI: fix missing CUDA v13.4 sub-package (#17288)
Needed for cross-compiling WoA
2026-07-21 12:25:10 -07:00
Jhye 4d1b53e6fb server: detect download stalls before the first byte (#17259)
* server: detect download stalls before the first byte

* server: keep stall timeout out of download API
2026-07-21 11:28:19 -07:00
Daniel Hiltgen 6100aca085 win: support CUDA on Windows ARM64 (#16931) 2026-07-21 10:53:30 -07:00
Daniel Hiltgen 72116bafb3 llama: enable dio on linux CUDA/ROCm iGPUs (#17286)
Avoid double memory consumption by enabling direct IO for iGPUs
2026-07-21 10:53:08 -07:00
Patrick Devine e2c2edcc27 docs: add renderer/parser fields to the API docs (#17275) 2026-07-20 16:22:46 -07:00
Daniel Hiltgen de1ce45913 cuda: add CC 10.0 for linux in CUDA v12 (#17025)
Add compute capability 10.0 to the Linux CUDA v12 preset so B200-class devices can use the cuda_v12 backend with drivers that do not meet the CUDA v13 minimum.

Fixes #12583
2026-07-20 13:09:36 -07:00
Daniel Hiltgen 51fc00122b build: bump Linux toolchain to GCC 13 (#17244)
GCC 11 builds broken AMX code which causes the Sapphire Rapids CPU backend to crash.

Fixes #17006
Fixes #17205
2026-07-20 11:54:39 -07:00
Daniel Hiltgen 445284b428 MLX update (#17189) 2026-07-20 11:54:24 -07:00
Parth Sareen e8f7c93a0b launch: update Hermes integration (#17202) 2026-07-20 11:28:01 -07:00
Parth Sareen 0de38190d7 cmd/tui/chat: render bold emphasis consistently across markdown (#17224) 2026-07-20 11:25:43 -07:00
Parth Sareen 681dfaedcc cmd: remove standalone agent command (#17229) 2026-07-20 11:25:31 -07:00
Parth Sareen 9893d39218 cmd: complete slash commands before submitting (#17230) 2026-07-20 11:25:12 -07:00
Parth Sareen 5ba17e6fdf agent/tui: remove redundant context-window refreshes from event loop (#17241) 2026-07-20 11:25:01 -07:00
Parth Sareen 6f3b997dec cmd: route root command server start through checkServerHeartbeat (#17245)
The bare `ollama` command (and `ollama launch` with no integration) used a
bespoke `ensureServerRunning` that forked `ollama serve` directly and polled
its heartbeat forever (no timeout, no platform-aware launch). Every other
subcommand (`ollama run`, `ollama pull`, `ollama launch <integration>`, ...)
goes through `checkServerHeartbeat` -> `startApp`, so the root command behaved
differently and could hang indefinitely.

Route `runInteractiveTUI` through `checkServerHeartbeat(cmd, nil)` — the same
path `ollama launch <thing>` uses — so the root command is consistent and no
longer runs an unbounded server-spawn loop. `ensureServerRunning` and its
`backgroundServerSysProcAttr` helpers (only it referenced them) are removed,
along with the now-unused `os/exec` import.

The platform `startApp`/`waitForServer` paths are unchanged, so behavior on
macOS/Windows is identical to the other subcommands, and on Linux the root
command now errors the same way the subcommands already do when no server is
running.
2026-07-20 11:24:25 -07:00
Daniel Hiltgen cc62676656 llama.cpp update (#17186) 2026-07-20 11:21:09 -07:00
613 changed files with 89089 additions and 41701 deletions

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env bash
# Prepare MLX runtime libraries for macOS CI unit tests.
#
# Building MLX is expensive, so to enable the MLX-specific unit tests this
# helper finds the newest Ollama release whose MLX_VERSION and MLX_C_VERSION
# match the current checkout, downloads that release's ollama-darwin.tgz, and
# extracts only mlx_metal_v* into build/lib/ollama.
#
# The payload also depends on Ollama's payload build rules (cmake glue and
# carried mlx/compat patches) and the xgrammar native wrapper
# (x/mlxrunner/xgrammar/native). Rule drift rebuilds the whole payload from
# source; wrapper-only drift rebuilds just libollama_xgrammar.dylib.
#
# If no release matches the MLX pins (e.g. right after a pin bump), the
# helper builds the minimal MLX payload for this platform: a single Metal
# variant using the superbuild's platform default (metal_v4 on macOS 26.2+
# SDKs, otherwise metal_v3), including a fresh libollama_xgrammar.dylib.
# Built payloads are cached in the release tarball's layout for later runs.
set -euo pipefail
repo="${OLLAMA_MLX_RELEASE_REPO:-ollama/ollama}"
scan_limit="${OLLAMA_MLX_RELEASE_SCAN_LIMIT:-50}"
cache_dir="${OLLAMA_MLX_DARWIN_CACHE:-.cache/mlx-darwin-release}"
target_dir="${OLLAMA_MLX_DARWIN_TARGET:-build/lib/ollama}"
ci_build_dir="${OLLAMA_MLX_CI_BUILD_DIR:-build/mlx-ci}"
tarball="${cache_dir}/ollama-darwin.tgz"
tag_file="${cache_dir}/matched-tag"
pins_file="${cache_dir}/matched-pins"
target_pins_file="${target_dir}/.mlx-release-pins"
# ${tag_file} value for a cached local build.
local_tag="local-build"
tmpdir=""
tmp_tarball=""
cleanup() {
[ -z "${tmpdir}" ] || rm -rf "${tmpdir}"
[ -z "${tmp_tarball}" ] || rm -f "${tmp_tarball}"
}
trap cleanup EXIT
warn() {
if [ -n "${GITHUB_ACTIONS:-}" ]; then
echo "::warning::$*"
else
echo "warning: $*" >&2
fi
}
read_pin() {
tr -d '[:space:]' <"$1"
}
# Native wrapper sources compiled into libollama_xgrammar.dylib — keep in
# sync with the ollama_xgrammar target in cmake/mlx/CMakeLists.txt.
xgrammar_native_dir=x/mlxrunner/xgrammar/native
# Payload build rules beyond the MLX_VERSION/MLX_C_VERSION pins.
payload_rule_files=(
"cmake/local.cmake"
"cmake/apply-git-patches.cmake"
"cmake/mlx/CMakeLists.txt"
"cmake/mlx/CMakePresets.json"
"x/mlxrunner/mlx/CMakeLists.txt"
)
# Build-rule inputs: the rule files plus carried MLX/MLX-C patch content.
rule_inputs() {
local file
for file in "${payload_rule_files[@]}"; do
printf '%s\n' "${file}"
done
if [ -d mlx/compat ]; then
find mlx/compat -type f | sort
fi
}
wrapper_inputs() {
find "${xgrammar_native_dir}" -type f | sort
}
payload_inputs() {
rule_inputs
wrapper_inputs
}
payload_fingerprint() {
local file
{
payload_inputs
while IFS= read -r file; do
cat "${file}" 2>/dev/null || true
done < <(payload_inputs)
} | shasum -a 256 | awk '{print $1}'
}
# True when the tag matches the checkout on all payload inputs.
tag_matches_payload() {
local tag="$1" file
while IFS= read -r file; do
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
return 1
fi
done < <(payload_inputs)
return 0
}
# True when the tag matches the checkout on the build rules.
tag_matches_rules() {
local tag="$1" file
while IFS= read -r file; do
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
return 1
fi
done < <(rule_inputs)
return 0
}
has_payload() {
local variant
for variant in "${target_dir}"/mlx_metal_v*; do
[ -d "${variant}" ] || continue
[ -f "${variant}/libmlx.dylib" ] && [ -f "${variant}/libmlxc.dylib" ] && return 0
done
return 1
}
has_matching_payload() {
[ -f "${target_pins_file}" ] || return 1
[ "$(cat "${target_pins_file}")" = "${current_pins}" ] || return 1
has_payload || return 1
# Every payload variant must carry libollama_xgrammar.dylib.
local variant
for variant in "${target_dir}"/mlx_metal_v*; do
[ -d "${variant}" ] || continue
[ -f "${variant}/libollama_xgrammar.dylib" ] || return 1
done
return 0
}
extract_payload() {
local tag="$1"
tmpdir="$(mktemp -d)"
tar -xzf "${tarball}" -C "${tmpdir}"
mkdir -p "${target_dir}"
rm -rf "${target_dir}"/mlx_metal_v*
local found=false
local src dest
for src in "${tmpdir}"/mlx_metal_v*; do
[ -d "${src}" ] || continue
found=true
dest="${target_dir}/$(basename "${src}")"
rm -rf "${dest}"
cp -R "${src}" "${dest}"
done
if [ "${found}" != true ] || ! has_payload; then
echo "Downloaded ${tarball} did not contain a usable MLX Metal payload" >&2
exit 1
fi
echo "${current_pins}" >"${target_pins_file}"
echo "Prepared MLX Darwin payload from ${repo} ${tag}:"
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name '*.metallib' \) -print
rm -rf "${tmpdir}"
tmpdir=""
}
# Cache the built payload in the release tarball's layout.
save_built_payload() {
local variant
local -a variants=()
for variant in "${target_dir}"/mlx_metal_v*; do
[ -d "${variant}" ] || continue
variants+=("$(basename "${variant}")")
done
tmp_tarball="${tarball}.tmp"
tar -czf "${tmp_tarball}" -C "${target_dir}" "${variants[@]}"
mv "${tmp_tarball}" "${tarball}"
tmp_tarball=""
echo "${local_tag}" >"${tag_file}"
echo "${current_pins}" >"${pins_file}"
echo "Cached the built payload in ${cache_dir}"
}
# Resolve the superbuild's platform-default MLX backend (metal_v3/metal_v4 on
# arm64; empty when the platform has no MLX backend, e.g. x86_64 macOS).
ci_mlx_backend() {
[ -f "${ci_build_dir}/CMakeCache.txt" ] || return 1
sed -n 's/^OLLAMA_MLX_BACKENDS:STRING=//p' "${ci_build_dir}/CMakeCache.txt"
}
# Configure the repo-root superbuild and fetch MLX/MLX-C sources at the
# pinned revisions (only the full payload build needs this).
build_ci_sources() {
cmake -S . -B "${ci_build_dir}" \
-DOLLAMA_LLAMA_BACKENDS= \
-DOLLAMA_PAYLOAD_INSTALL_PREFIX="$(dirname "$(dirname "${target_dir}")")"
cmake --build "${ci_build_dir}" --target ollama-mlx-sources
}
# Rebuild only libollama_xgrammar.dylib into the extracted payload. The
# target depends only on the pinned XGrammar sources and the native wrapper;
# the Metal toolchain and the superbuild are not involved. MLX is fetched
# only because the cmake/mlx project defines it — nothing from it is built.
build_ci_xgrammar() {
local lib variant
local xg_build_dir="${ci_build_dir}/xgrammar"
local -a configure_args=(-S cmake/mlx -B "${xg_build_dir}" -DOLLAMA_SOURCE_DIR="$(pwd)" -DMLX_BUILD_METAL=OFF)
if [ -n "${OLLAMA_XGRAMMAR_SOURCE:-}" ]; then
configure_args+=("-DFETCHCONTENT_SOURCE_DIR_XGRAMMAR=${OLLAMA_XGRAMMAR_SOURCE}")
fi
cmake "${configure_args[@]}"
cmake --build "${xg_build_dir}" --target ollama_xgrammar
lib="${xg_build_dir}/lib/ollama/libollama_xgrammar.dylib"
[ -f "${lib}" ] || {
echo "ollama_xgrammar build produced no library at ${lib}" >&2
exit 1
}
for variant in "${target_dir}"/mlx_metal_v*; do
[ -d "${variant}" ] || continue
cp -f "${lib}" "${variant}/libollama_xgrammar.dylib"
[ -f "${variant}/libollama_xgrammar.dylib" ] || {
echo "failed to install ${variant}/libollama_xgrammar.dylib" >&2
exit 1
}
done
echo "Rebuilt libollama_xgrammar.dylib from source into ${target_dir}"
}
# Build the minimal MLX payload for this platform: one Metal variant,
# whatever the superbuild defaults to here.
build_ci_payload() {
local backend variant
build_ci_sources
backend="$(ci_mlx_backend)"
case "${backend}" in
metal_v3 | metal_v4) ;;
*)
warn "no MLX backend applicable to this platform; MLX unit tests will be skipped"
exit 0
;;
esac
echo "Building the ${backend} payload for unit tests"
rm -rf "${target_dir}"/mlx_metal_v*
cmake --build "${ci_build_dir}" --target "ollama-mlx-${backend}"
for variant in "${target_dir}"/mlx_metal_v*; do
[ -d "${variant}" ] || continue
for lib in libmlx.dylib libmlxc.dylib libollama_xgrammar.dylib; do
[ -f "${variant}/${lib}" ] || {
echo "built payload is missing ${variant}/${lib}" >&2
exit 1
}
done
done
has_payload || {
echo "built payload is incomplete in ${target_dir}" >&2
exit 1
}
echo "${current_pins}" >"${target_pins_file}"
echo "Built MLX payload for unit tests:"
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name 'libollama_xgrammar.dylib' -o -name '*.metallib' \) -print
save_built_payload
}
if [ "$(uname -s)" != "Darwin" ]; then
warn "MLX Darwin payload setup is only supported on macOS"
exit 0
fi
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(sysctl -n hw.ncpu)}"
current_mlx="$(read_pin MLX_VERSION)"
current_mlxc="$(read_pin MLX_C_VERSION)"
# The release tarball only depends on the MLX pins; the extracted payload's
# xgrammar library additionally depends on the tree's XGrammar inputs.
component_pins="${current_mlx} ${current_mlxc}"
current_pins="${component_pins} $(payload_fingerprint)"
if has_matching_payload; then
echo "MLX payload already present in ${target_dir}"
exit 0
fi
mkdir -p "${cache_dir}"
# Release tarballs are keyed on the MLX pins; local builds on the full fingerprint.
cached_pins="$(cat "${pins_file}" 2>/dev/null || true)"
if [ -s "${tarball}" ] && [ -f "${tag_file}" ] && { [ "${cached_pins}" = "${component_pins}" ] || [ "${cached_pins}" = "${current_pins}" ]; }; then
extract_payload "$(cat "${tag_file}")"
else
matched_tag=""
matched_url=""
while read -r tag; do
[ -n "${tag}" ] || continue
if ! tag_mlx="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_VERSION" | tr -d '[:space:]')"; then
continue
fi
if [ "${tag_mlx}" != "${current_mlx}" ]; then
continue
fi
if ! tag_mlxc="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_C_VERSION" | tr -d '[:space:]')"; then
continue
fi
if [ "${tag_mlxc}" != "${current_mlxc}" ]; then
continue
fi
url="https://github.com/${repo}/releases/download/${tag}/ollama-darwin.tgz"
if curl -fsIL "${url}" >/dev/null; then
matched_tag="${tag}"
matched_url="${url}"
break
fi
echo "MLX pins match ${tag}, but ${url} is not available"
done < <(
git ls-remote --tags --refs --sort=-version:refname "https://github.com/${repo}.git" 'v*' |
awk -v limit="${scan_limit}" '{ sub("refs/tags/", "", $2); print $2; if (limit > 0 && NR >= limit) exit }'
)
if [ -z "${matched_tag}" ]; then
echo "No release carries MLX_VERSION=${current_mlx} MLX_C_VERSION=${current_mlxc}"
build_ci_payload
exit 0
fi
tmp_tarball="${tarball}.tmp"
rm -f "${tmp_tarball}"
curl -fL --retry 3 --retry-delay 2 -o "${tmp_tarball}" "${matched_url}"
mv "${tmp_tarball}" "${tarball}"
tmp_tarball=""
echo "${matched_tag}" >"${tag_file}"
echo "${component_pins}" >"${pins_file}"
extract_payload "${matched_tag}"
fi
tag="$(cat "${tag_file}")"
if [ "${tag}" = "${local_tag}" ] || tag_matches_payload "${tag}"; then
exit 0
fi
if [ "$(uname -m)" != "arm64" ]; then
warn "MLX payload builds are only supported on arm64 macOS; MLX unit tests will be skipped"
exit 0
fi
if tag_matches_rules "$(cat "${tag_file}")"; then
# Only the xgrammar wrapper drifted; keep the rest of the release payload.
echo "Rebuilding libollama_xgrammar.dylib from source into ${target_dir}"
rm -f "${target_dir}"/mlx_metal_v*/libollama_xgrammar.dylib
build_ci_xgrammar
exit 0
fi
echo "Release payload build rules do not match this checkout"
build_ci_payload
+49 -1
View File
@@ -77,6 +77,8 @@ jobs:
MLX_VERSION
MLX_C_VERSION
- run: |
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
cmake --build build/go-license --target ollama-go-license
./scripts/build_darwin.sh
- name: Log build results
run: |
@@ -93,6 +95,7 @@ jobs:
windows-depends:
needs: setup-environment
strategy:
fail-fast: false
matrix:
os: [windows]
arch: [amd64]
@@ -124,6 +127,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
@@ -152,6 +171,11 @@ jobs:
- '"cufft_dev"'
- '"nvrtc"'
- '"nvrtc_dev"'
- '"cusolver"'
- '"cusolver_dev"'
- '"cusparse"'
- '"cusparse_dev"'
- '"nvjitlink"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
@@ -198,8 +222,18 @@ jobs:
name: Install CUDA ${{ matrix.cuda-version }}
run: |
$ErrorActionPreference = "Stop"
$ProgressPreference = 'SilentlyContinue'
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
break
} catch {
if ($attempt -eq 3) { throw }
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
Start-Sleep -Seconds 15
}
}
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
}
@@ -419,6 +453,16 @@ jobs:
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- name: Collect Go licenses
shell: bash
run: |
set -euo pipefail
for arch in amd64 arm64; do
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
cmake --build build/go-license --target ollama-go-license
done
- run: |
./scripts/build_windows.ps1 deps sign installer zip
- name: Log contents after build
@@ -434,6 +478,7 @@ jobs:
linux-depends:
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
@@ -515,6 +560,7 @@ jobs:
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
docker-build-push:
strategy:
fail-fast: false
matrix:
include:
- os: linux
@@ -637,6 +683,7 @@ jobs:
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/*_LICENSE|lib/ollama/*_NOTICE) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
@@ -665,6 +712,7 @@ jobs:
# Merge Docker images for the same flavor into a single multi-arch manifest
docker-merge-push:
strategy:
fail-fast: false
matrix:
suffix: ['', '-rocm']
runs-on: linux
+42 -2
View File
@@ -57,7 +57,10 @@ jobs:
MLX_VERSION
MLX_C_VERSION
- name: Build unsigned Darwin runtime
run: ./scripts/build_darwin.sh build package
run: |
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
cmake --build build/go-license --target ollama-go-license
./scripts/build_darwin.sh build package
- name: Log build results
run: ls -l dist/
- uses: actions/upload-artifact@v4
@@ -240,6 +243,7 @@ jobs:
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/*_LICENSE|lib/ollama/*_NOTICE) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
@@ -321,6 +325,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
@@ -365,8 +385,18 @@ jobs:
name: Install CUDA ${{ matrix.cuda-version }}
run: |
$ErrorActionPreference = "Stop"
$ProgressPreference = 'SilentlyContinue'
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
break
} catch {
if ($attempt -eq 3) { throw }
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
Start-Sleep -Seconds 15
}
}
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
}
@@ -551,6 +581,16 @@ jobs:
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- name: Collect Go licenses
shell: bash
run: |
set -euo pipefail
for arch in amd64 arm64; do
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
cmake --build build/go-license --target ollama-go-license
done
- name: Build unsigned Windows installer and zips
run: ./scripts/build_windows.ps1 deps installer zip
- name: Log contents after build
+92 -7
View File
@@ -23,6 +23,7 @@ jobs:
outputs:
changed: ${{ steps.changes.outputs.changed }}
app_changed: ${{ steps.changes.outputs.app_changed }}
go_mod_changed: ${{ steps.changes.outputs.go_mod_changed }}
enginehash: ${{ steps.changes.outputs.enginehash }}
steps:
- uses: actions/checkout@v4
@@ -52,8 +53,11 @@ jobs:
'ml/backend/ggml/ggml/**/*' \
'x/imagegen/mlx/**' \
'x/imagegen/mlx/**/*' \
'x/mlxrunner/xgrammar/native/**' \
'x/mlxrunner/xgrammar/native/**/*' \
'.github/**/*') | tee -a $GITHUB_OUTPUT
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
echo go_mod_changed=$(changed 'go.mod') | tee -a $GITHUB_OUTPUT
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
patches:
@@ -116,7 +120,7 @@ jobs:
superbuild_target: ollama-mlx-cuda_v13
superbuild_dir: build/local-superbuild-mlx-cuda_v13
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so lib/ollama/mlx_cuda_v13/libollama_xgrammar.so
install-go: true
runs-on: linux
container: ${{ matrix.container }}
@@ -158,7 +162,9 @@ jobs:
run: |
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
test -e "${{ matrix.superbuild_dir }}/${{ matrix.expected_payload }}"
for f in ${{ matrix.expected_payload }}; do
test -e "${{ matrix.superbuild_dir }}/$f"
done
- name: Verify local superbuild install
if: matrix.superbuild_target == 'ollama-local'
run: |
@@ -214,7 +220,7 @@ jobs:
superbuild_target: ollama-mlx-cuda_v13
superbuild_dir: build\local-superbuild-mlx-cuda_v13
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll lib\ollama\mlx_cuda_v13\ollama_xgrammar.dll
install-go: true
cuda-components:
- '"cudart"'
@@ -225,6 +231,11 @@ jobs:
- '"cufft_dev"'
- '"nvrtc"'
- '"nvrtc_dev"'
- '"cusolver"'
- '"cusolver_dev"'
- '"cusparse"'
- '"cusparse_dev"'
- '"nvjitlink"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
@@ -334,8 +345,10 @@ jobs:
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
if (!(Test-Path "${{ matrix.superbuild_dir }}\${{ matrix.expected_payload }}")) {
throw "missing ${{ matrix.expected_payload }}"
foreach ($f in "${{ matrix.expected_payload }}".Split(' ')) {
if (!(Test-Path "${{ matrix.superbuild_dir }}\$f")) {
throw "missing $f"
}
}
env:
CMAKE_GENERATOR: Ninja
@@ -362,6 +375,24 @@ jobs:
- name: check that 'go mod tidy' is clean
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
go_license:
needs: [changes]
if: needs.changes.outputs.go_mod_changed == 'True'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify Go dependency licenses
run: |
# See cmake/generate_go_license.cmake for special case handling.
cmake -S . -B build/go-license \
-DOLLAMA_LLAMA_BACKENDS= \
-DOLLAMA_MLX_BACKENDS= \
"-DOLLAMA_GO_LICENSE_TARGETS=linux/amd64;linux/arm64;darwin/amd64;darwin/arm64;windows/amd64;windows/arm64"
cmake --build build/go-license --target ollama-go-license
test:
needs: [changes]
strategy:
@@ -383,6 +414,16 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache MLX Darwin release payload
if: ${{ startsWith(matrix.os, 'macos') }}
uses: actions/cache@v4
with:
path: .cache/mlx-darwin-release
# Key on every payload input so a source-built payload survives pushes.
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
- name: Prepare MLX Darwin release payload
if: ${{ startsWith(matrix.os, 'macos') }}
run: .github/scripts/prepare_mlx_darwin.sh
- name: Install UI dependencies
working-directory: ./app/ui/app
run: npm ci
@@ -407,14 +448,58 @@ jobs:
- name: Run go generate
run: go generate ./...
- name: Verify UI generated types are current
if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: git diff --exit-code -- app/ui/app/codegen/gotypes.gen.ts
- name: go test
if: always()
run: go test -count=1 -benchtime=1x ./...
# Smoke-run each benchmark once to catch panics and bit rot; this does
# not assert timings. -benchtime without -bench is inert.
run: go test -count=1 -bench=. -benchtime=1x ./...
- name: go test app with live updater tag
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
run: go test -count=1 -tags updater_live ./app/...
- uses: golangci/golangci-lint-action@v9
race:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
env:
CGO_ENABLED: '1'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
only-new-issues: true
go-version-file: 'go.mod'
cache-dependency-path: |
go.sum
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- name: Cache MLX Darwin release payload
if: ${{ startsWith(matrix.os, 'macos') }}
uses: actions/cache@v4
with:
path: .cache/mlx-darwin-release
# Key on every payload input so a source-built payload survives pushes.
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
- name: Prepare MLX Darwin release payload
if: ${{ startsWith(matrix.os, 'macos') }}
run: .github/scripts/prepare_mlx_darwin.sh
- uses: actions/setup-node@v4
with:
node-version: '20'
# app/ui embeds app/dist, so the UI has to be built before app/... will
# even compile.
- name: Build UI
working-directory: ./app/ui/app
run: |
npm ci
npm run build
- name: go test -race
run: go test -race -count=1 ./...
+4
View File
@@ -45,6 +45,10 @@ if(APPLE)
set(CMAKE_BUILD_RPATH "@loader_path")
set(CMAKE_INSTALL_RPATH "@loader_path")
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
elseif(UNIX)
set(CMAKE_BUILD_RPATH "$ORIGIN")
set(CMAKE_INSTALL_RPATH "$ORIGIN")
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
endif()
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
+25 -9
View File
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
FROM scratch AS local-mlx-c
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
# install epel-release for ccache
@@ -42,8 +42,8 @@ ENV LDFLAGS=-s
#
FROM base AS cpu-deps
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM base AS cuda-12-deps
ARG CUDA12VERSION=12.8
@@ -74,7 +74,7 @@ RUN ln -s /usr/bin/python3 /usr/bin/python \
ENV VULKAN_SDK=/usr/local
#
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, llama/compat/, or cmake/ changes.
#
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
@@ -84,6 +84,7 @@ FROM cpu-deps AS llama-server-cpu
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset cpu \
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
@@ -91,8 +92,8 @@ RUN --mount=type=cache,target=/root/.ccache \
&& for lib in \
/usr/lib64/libgomp.so* \
/usr/lib64/libomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
done
@@ -103,6 +104,7 @@ FROM cuda-12-deps AS llama-server-cuda_v12
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_v12_linux \
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
@@ -115,6 +117,7 @@ FROM cuda-13-deps AS llama-server-cuda_v13
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_v13_linux \
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
@@ -124,10 +127,11 @@ FROM scratch AS publish-llama-server-cuda_v13
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
FROM rocm-7-deps AS llama-server-rocm_v7_2
ENV CC=clang CXX=clang++
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset rocm_v7_2_linux \
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
@@ -141,6 +145,7 @@ FROM vulkan-deps AS llama-server-vulkan
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset vulkan \
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
@@ -165,6 +170,7 @@ ENV CMAKE_GENERATOR=Ninja
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_jetpack5 \
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
@@ -185,6 +191,7 @@ ENV CMAKE_GENERATOR=Ninja
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
COPY cmake cmake
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_jetpack6 \
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
@@ -213,7 +220,8 @@ ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-
WORKDIR /go/src/github.com/ollama/ollama
COPY CMakeLists.txt CMakePresets.json .
COPY cmake cmake
COPY x/imagegen/mlx x/imagegen/mlx
COPY x/mlxrunner/mlx x/mlxrunner/mlx
COPY x/mlxrunner/xgrammar/native x/mlxrunner/xgrammar/native
COPY go.mod go.sum .
COPY MLX_VERSION MLX_C_VERSION .
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
@@ -253,9 +261,15 @@ ENV CGO_CFLAGS="${CGO_CFLAGS}"
ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -buildmode=pie -o /bin/ollama .
RUN --mount=type=cache,target=/root/.cache/go-build \
cmake -S . -B build/go-license \
-DOLLAMA_LLAMA_BACKENDS= \
-DOLLAMA_MLX_BACKENDS= \
&& cmake --build build/go-license --target ollama-go-license
FROM scratch AS publish-go
COPY --from=build /bin/ollama /bin/ollama
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
#
# Assembly stages — combine llama-server variants + GPU runtime libs
@@ -288,9 +302,11 @@ COPY --from=arm64 /lib/ollama /lib/ollama/
FROM ${TARGETARCH}-archive AS archive
COPY --from=build /bin/ollama /bin/ollama
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
FROM ${FLAVOR} AS image-archive
COPY --from=build /bin/ollama /bin/ollama
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
FROM ubuntu:24.04
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
+1 -1
View File
@@ -1 +1 @@
b9888
b10760
+1 -1
View File
@@ -1 +1 @@
fba4470b89073180056c9ea46c443051375f7399
c74db5307cc8ce122f48d97ef951b30578674e7f
+1 -1
View File
@@ -1 +1 @@
de7b4ed986b6d6f55b8ace5e73c24d1ca0bea89b
37c26e5755da637255d57ea34b4879196a485301
+1 -1
View File
@@ -65,7 +65,7 @@ To launch a specific integration:
ollama launch claude
```
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
### AI assistant
-7
View File
@@ -474,7 +474,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
batch := toolBatchResult{
messages: make([]api.Message, 0, len(calls)),
}
projectedMessages := append([]api.Message(nil), messages...)
// Pre-compute the full-history token estimate once per batch instead of
// re-marshaling the entire history for each tool call. Per-call deltas
// (tool messages already appended this batch) are tracked in batchTokens
@@ -530,7 +529,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
for _, plan := range plans {
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
deniedContent := msg.Content
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
@@ -559,7 +557,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content = msg.Content
if toolOutputFullyOmitted(content) {
@@ -580,7 +577,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
rawContent := fmt.Sprintf("Error: %v", err)
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content := msg.Content
if toolOutputFullyOmitted(content) {
@@ -609,7 +605,6 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
content := msg.Content
@@ -637,7 +632,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
batch := toolBatchResult{
messages: make([]api.Message, 0, len(calls)),
}
projectedMessages := append([]api.Message(nil), messages...)
historyTokens := s.estimateRunPromptTokens(opts, messages)
batchTokens := 0
for _, call := range calls {
@@ -645,7 +639,6 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
args := call.Function.Arguments.ToMap()
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
batch.messages = append(batch.messages, msg)
projectedMessages = append(projectedMessages, msg)
batchTokens += estimateMessagesTokens([]api.Message{msg})
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
return toolBatchResult{}, emitErr
+375
View File
@@ -1,8 +1,10 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
@@ -271,6 +273,354 @@ type skillRoot struct {
path string
}
// SkillImportResult describes one import attempt. Failed skills do not prevent
// other valid skills in the same source root from being imported.
type SkillImportResult struct {
Source string
SourceDir string
Destination string
Imported []string
Existing []string
Failures []SkillImportFailure
}
// SkillImportFailure identifies a source skill that was deliberately skipped.
// The destination is never changed for a failed skill.
type SkillImportFailure struct {
Name string
Err error
}
// ImportSkills imports skills from a conventional coding-agent source into the
// canonical Ollama skills directory. Supported sources are codex, claude, and
// pi. Existing skills are left untouched: an identical directory is reported
// as existing, and a differing one is reported as a conflict.
func ImportSkills(source string) (SkillImportResult, error) {
home, err := os.UserHomeDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
}
destination, err := SkillsDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
}
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
}
func conventionalSkillImportRoots(home string) map[string]string {
return map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
}
}
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
source = strings.ToLower(strings.TrimSpace(source))
sourceDir, ok := roots[source]
if !ok {
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
}
return importSkillsFromDir(source, sourceDir, destination)
}
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
info, err := os.Lstat(sourceDir)
if errors.Is(err, fs.ErrNotExist) {
return result, nil
}
if err != nil {
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
}
if !info.IsDir() {
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
}
entries, err := os.ReadDir(sourceDir)
if err != nil {
return result, fmt.Errorf("read %s skills directory: %w", source, err)
}
for _, entry := range entries {
name := entry.Name()
path := filepath.Join(sourceDir, name)
if entry.Type()&os.ModeSymlink != 0 {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
continue
}
info, err := entry.Info()
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
continue
}
if !info.IsDir() {
continue
}
if !skillName.MatchString(name) {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
continue
}
if err := validateImportSkill(path, name); err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
state, err := importSkillDirectory(path, filepath.Join(destination, name))
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
if state == skillImportExisting {
result.Existing = append(result.Existing, name)
} else {
result.Imported = append(result.Imported, name)
}
}
return result, nil
}
func validateImportSkill(dir, name string) error {
manifest := filepath.Join(dir, skillFilename)
info, err := os.Lstat(manifest)
if err != nil {
return fmt.Errorf("inspect %s: %w", skillFilename, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
}
if _, err := parseSkill(manifest, name); err != nil {
return err
}
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
if info.IsDir() || path == dir {
return nil
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return file.Close()
})
}
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe skill path %q", path)
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks may not be imported: %s", path)
}
info, err := entry.Info()
if err != nil {
return err
}
return visit(path, entry, info)
})
}
type skillImportState int
const (
skillImportCopied skillImportState = iota
skillImportExisting
)
func importSkillDirectory(source, destination string) (skillImportState, error) {
if info, err := os.Lstat(destination); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return 0, errors.New("destination exists but is not a regular directory")
}
same, err := sameImportTree(source, destination)
if err != nil {
return 0, fmt.Errorf("inspect existing destination: %w", err)
}
if same {
return skillImportExisting, nil
}
return 0, errors.New("destination skill already exists with different contents")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination: %w", err)
}
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
return 0, err
}
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
if err != nil {
return 0, fmt.Errorf("create import staging directory: %w", err)
}
defer os.RemoveAll(stage)
if err := copyImportTree(source, stage); err != nil {
return 0, err
}
if _, err := os.Lstat(destination); err == nil {
return 0, errors.New("destination skill was created during import")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination before install: %w", err)
}
if err := os.Rename(stage, destination); err != nil {
return 0, fmt.Errorf("install imported skill: %w", err)
}
return skillImportCopied, nil
}
func ensureImportDestination(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create Ollama skills directory: %w", err)
}
info, err := os.Lstat(dir)
if err != nil {
return fmt.Errorf("inspect Ollama skills directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
}
return nil
}
func copyImportTree(source, destination string) error {
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := destination
if rel != "." {
target = filepath.Join(destination, rel)
}
if info.IsDir() {
if rel == "." {
return nil
}
return os.Mkdir(target, info.Mode().Perm())
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
return copyImportFile(path, target, info.Mode().Perm())
})
}
func copyImportFile(source, destination string, mode fs.FileMode) error {
in, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer in.Close()
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return fmt.Errorf("create %s: %w", destination, err)
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return fmt.Errorf("copy %s: %w", source, copyErr)
}
if closeErr != nil {
return fmt.Errorf("write %s: %w", destination, closeErr)
}
return nil
}
func sameImportTree(source, destination string) (bool, error) {
seen := make(map[string]struct{})
same := true
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
seen[rel] = struct{}{}
other := destination
if rel != "." {
other = filepath.Join(destination, rel)
}
otherInfo, err := os.Lstat(other)
if errors.Is(err, fs.ErrNotExist) {
same = false
return nil
}
if err != nil {
return err
}
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
same = false
return nil
}
if info.Mode().IsRegular() {
equal, err := sameImportFile(path, other)
if err != nil {
return err
}
if !equal {
same = false
}
}
return nil
})
if err != nil || !same {
return same, err
}
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(destination, path)
if err != nil {
return err
}
if _, ok := seen[rel]; !ok {
same = false
}
return nil
})
return same, err
}
func sameImportFile(first, second string) (bool, error) {
a, err := os.Open(first)
if err != nil {
return false, err
}
defer a.Close()
b, err := os.Open(second)
if err != nil {
return false, err
}
defer b.Close()
left := make([]byte, 32*1024)
right := make([]byte, len(left))
for {
n, errA := a.Read(left)
m, errB := b.Read(right)
if n != m || !bytes.Equal(left[:n], right[:m]) {
return false, nil
}
if errA == io.EOF && errB == io.EOF {
return true, nil
}
if errA != nil && errA != io.EOF {
return false, errA
}
if errB != nil && errB != io.EOF {
return false, errB
}
if errA == io.EOF || errB == io.EOF {
return false, nil
}
}
}
// defaultSkillRoots returns skill directories ordered lowest- to
// highest-precedence. Non-existent directories are scanned harmlessly
// (DiscoverSkills skips them).
@@ -325,6 +675,31 @@ func (c *SkillCatalog) Diagnostics() []error {
return append([]error(nil), c.diagnostics...)
}
// ExcludeNames removes skills whose names are reserved by a caller. It returns
// the excluded names in sorted order.
func (c *SkillCatalog) ExcludeNames(names []string) []string {
if c == nil {
return nil
}
reserved := make(map[string]struct{}, len(names))
for _, name := range names {
name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
if name != "" {
reserved[name] = struct{}{}
}
}
var excluded []string
for name := range c.skills {
if _, ok := reserved[name]; !ok {
continue
}
delete(c.skills, name)
excluded = append(excluded, name)
}
sort.Strings(excluded)
return excluded
}
func (c *SkillCatalog) Load(name string) (Skill, error) {
name = strings.TrimSpace(name)
if !skillName.MatchString(name) {
+223
View File
@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
}
}
func writeImportFixtureSkill(t *testing.T, dir string) {
t.Helper()
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "release-notes", skillFilename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, contents, 0o644); err != nil {
t.Fatal(err)
}
}
func TestDiscoverAndLoadSkills(t *testing.T) {
dir := t.TempDir()
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
@@ -257,6 +272,30 @@ func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
}
}
func TestSkillCatalogExcludeNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"release-notes", "system", "exit"} {
writeCatalogSkill(t, dir, name, "instructions")
}
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want {
t.Fatalf("excluded skills = %q, want %q", got, want)
}
if _, err := catalog.Load("system"); err == nil {
t.Fatal("excluded system skill should not load")
}
if _, err := catalog.Load("exit"); err == nil {
t.Fatal("excluded exit skill should not load")
}
if _, err := catalog.Load("release-notes"); err != nil {
t.Fatalf("non-conflicting skill should remain available: %v", err)
}
}
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
root := t.TempDir()
skillDir := filepath.Join(root, "pdf-processing")
@@ -291,3 +330,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
t.Fatalf("content missing resource listing: %q", content)
}
}
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeImportFixtureSkill(t, source)
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
t.Fatalf("imported = %q, want %q", got, want)
}
catalog, err := DiscoverSkills(destination)
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load("release-notes")
if err != nil || skill.Description != "Draft concise release notes." {
t.Fatalf("imported skill = %#v, %v", skill, err)
}
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
}
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
t.Fatalf("imported fixture file %q: %v", file, err)
}
}
result, err = importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
t.Fatalf("existing = %q, want %q", got, want)
}
if len(result.Imported) != 0 {
t.Fatalf("repeated import copied skills: %#v", result.Imported)
}
}
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeCatalogSkill(t, source, "release-notes", "source instructions")
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
writeCatalogSkill(t, source, "nested-link", "safe manifest")
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 {
t.Fatalf("unexpected successful import: %#v", result)
}
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
t.Fatalf("conflicting destination changed: %q, %v", got, err)
}
failed := make(map[string]bool)
for _, failure := range result.Failures {
failed[failure.Name] = true
}
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
if !failed[name] {
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
}
}
}
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
root := t.TempDir()
source := filepath.Join(t.TempDir(), "codex-skills")
if err := os.Symlink(root, source); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
t.Fatalf("symlinked root error = %v", err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("symlinked root result = %#v", result)
}
}
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("missing root result = %#v", result)
}
destination := t.TempDir()
rootBase := t.TempDir()
roots := map[string]string{
"codex": filepath.Join(rootBase, "codex"),
"claude": filepath.Join(rootBase, "claude"),
"pi": filepath.Join(rootBase, "pi"),
}
for _, test := range []struct {
source string
root string
name string
}{
{source: "codex", root: roots["codex"], name: "from-codex"},
{source: "claude", root: roots["claude"], name: "from-claude"},
{source: "pi", root: roots["pi"], name: "from-pi"},
} {
t.Run(test.source, func(t *testing.T) {
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
result, err = importSkillsFromRoots(test.source, roots, destination)
if err != nil {
t.Fatal(err)
}
if result.SourceDir != test.root {
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
}
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
t.Fatalf("conventional source was not imported: %v", err)
}
})
}
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
t.Fatalf("unknown source error = %v", err)
}
}
func TestConventionalSkillImportRoots(t *testing.T) {
home := t.TempDir()
roots := conventionalSkillImportRoots(home)
for source, want := range map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
} {
if got := roots[source]; got != want {
t.Fatalf("%s root = %q, want %q", source, got, want)
}
}
}
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
source := t.TempDir()
writeCatalogSkill(t, source, "private", "do not read")
manifest := filepath.Join(source, "private", skillFilename)
if err := os.Chmod(manifest, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
if _, err := os.ReadFile(manifest); err == nil {
t.Skip("test user can read a mode-000 file")
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
t.Fatalf("failures = %#v", result.Failures)
}
}
+8
View File
@@ -0,0 +1,8 @@
---
name: release-notes
description: Draft concise release notes.
---
# Release notes
Use short bullets.
+183 -30
View File
@@ -2,11 +2,14 @@ package tools
import (
"bufio"
"cmp"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -105,26 +108,37 @@ func (e *Edit) Name() string {
}
func (e *Edit) Description() string {
return "Edit a text file in the current working directory by replacing exact text."
return "Edit a text file in the current working directory by replacing exact text. Pass multiple edits to change separate parts of the file in one call."
}
func (e *Edit) Schema() api.ToolFunction {
editProps := api.NewToolPropertiesMap()
editProps.Set("old_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Exact text for one targeted replacement. Must match the original file exactly once and must not overlap with any other edit's old_text.",
})
editProps.Set("new_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Replacement text for this targeted edit.",
})
props := api.NewToolPropertiesMap()
props.Set("path", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Path to the file to edit, relative to the working directory.",
})
props.Set("old_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Exact text to replace.",
})
props.Set("new_text", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "Replacement text.",
props.Set("edits", api.ToolProperty{
Type: api.PropertyType{"array"},
Items: api.ToolProperty{
Type: api.PropertyType{"object"},
Properties: editProps,
Required: []string{"old_text", "new_text"},
},
Description: "One or more exact-text replacements. Each is matched against the original file, not against the output of earlier edits. Keep old_text as small as possible while still unique in the file; merge changes to the same or adjacent lines into a single edit.",
})
props.Set("replace_all", api.ToolProperty{
Type: api.PropertyType{"boolean"},
Description: "Replace every occurrence. Defaults to false and requires old_text to match exactly once.",
Description: "Replace every occurrence. Defaults to false; only applies when a single edit is provided.",
})
return api.ToolFunction{
Name: e.Name(),
@@ -132,7 +146,7 @@ func (e *Edit) Schema() api.ToolFunction {
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: props,
Required: []string{"path", "old_text", "new_text"},
Required: []string{"path", "edits"},
},
}
}
@@ -148,18 +162,11 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
}
oldText, ok := args["old_text"].(string)
if !ok || oldText == "" {
return agent.ToolResult{}, fmt.Errorf("old_text parameter is required")
edits, replaceAll, err := parseEditArgs(args)
if err != nil {
return agent.ToolResult{}, err
}
newText, ok := args["new_text"].(string)
if !ok {
return agent.ToolResult{}, fmt.Errorf("new_text parameter is required")
}
replaceAll, _ := args["replace_all"].(bool)
if err := rejectFinalSymlink(toolCtx.WorkingDir, path); err != nil {
return agent.ToolResult{}, err
}
@@ -188,19 +195,56 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, err
}
content := string(contentBytes)
matches := strings.Count(content, oldText)
if matches == 0 {
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
}
if matches > 1 && !replaceAll {
return agent.ToolResult{}, fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", matches, path)
}
var updated string
replacements := 0
if replaceAll {
updated = strings.ReplaceAll(content, oldText, newText)
matches := strings.Count(content, edits[0].OldText)
if matches == 0 {
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
}
updated = strings.ReplaceAll(content, edits[0].OldText, edits[0].NewText)
replacements = matches
} else {
updated = strings.Replace(content, oldText, newText, 1)
// Every edit is matched against the original file content rather
// than the output of earlier edits, so each edit must match exactly
// once and edits must target disjoint regions.
matched := make([]editMatch, 0, len(edits))
for i, edit := range edits {
count := strings.Count(content, edit.OldText)
if count == 0 {
return agent.ToolResult{}, editNotFoundError(path, i, len(edits))
}
if count > 1 {
return agent.ToolResult{}, editAmbiguousError(path, i, len(edits), count)
}
matched = append(matched, editMatch{
editIndex: i,
offset: strings.Index(content, edit.OldText),
length: len(edit.OldText),
newText: edit.NewText,
})
replacements++
}
slices.SortFunc(matched, func(a, b editMatch) int { return cmp.Compare(a.offset, b.offset) })
for i := 1; i < len(matched); i++ {
prev, cur := matched[i-1], matched[i]
if prev.offset+prev.length > cur.offset {
return agent.ToolResult{}, fmt.Errorf("edits[%d] and edits[%d] overlap in %s; merge them into one edit or target disjoint text", prev.editIndex, cur.editIndex, path)
}
}
// Apply from the end of the file backwards so earlier offsets stay valid.
updated = content
for i := len(matched) - 1; i >= 0; i-- {
m := matched[i]
updated = updated[:m.offset] + m.newText + updated[m.offset+m.length:]
}
}
if updated == content {
return agent.ToolResult{}, fmt.Errorf("edit produced no changes in %s; replacement text is identical to the original", path)
}
if len(updated) > maxReadBytes {
return agent.ToolResult{}, fmt.Errorf("edited content is too large (%d bytes)", len(updated))
@@ -210,7 +254,116 @@ func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[
return agent.ToolResult{}, err
}
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d replacement%s).", path, matches, plural(matches))}, nil
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d edit%s, %d replacement%s).", path, len(edits), plural(len(edits)), replacements, plural(replacements))}, nil
}
// editReplacement is one targeted replacement within an edit call.
type editReplacement struct {
OldText string
NewText string
}
// editMatch locates one editReplacement within the original file content.
type editMatch struct {
editIndex int
offset int
length int
newText string
}
// parseEditArgs normalizes edit arguments from a tool call into a list of
// replacements. It accepts the `edits` array form and tolerates legacy
// top-level old_text/new_text args as well as stringified JSON, mirroring
// the pi coding agent's argument handling.
func parseEditArgs(args map[string]any) ([]editReplacement, bool, error) {
replaceAll, _ := args["replace_all"].(bool)
var edits []editReplacement
if raw, ok := args["edits"]; ok {
parsed, err := parseEditArray(raw)
if err != nil {
return nil, false, err
}
edits = parsed
}
// Fold a legacy top-level old_text/new_text pair into edits.
if oldText, ok := args["old_text"].(string); ok {
newText, ok := args["new_text"].(string)
if !ok {
return nil, false, fmt.Errorf("new_text parameter is required")
}
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
}
if len(edits) == 0 {
return nil, false, fmt.Errorf("edits parameter is required")
}
for i, edit := range edits {
if edit.OldText == "" {
if len(edits) == 1 {
return nil, false, fmt.Errorf("old_text parameter is required")
}
return nil, false, fmt.Errorf("edits[%d].old_text must not be empty", i)
}
}
if replaceAll && len(edits) != 1 {
return nil, false, fmt.Errorf("replace_all only applies to a single edit")
}
return edits, replaceAll, nil
}
func parseEditArray(raw any) ([]editReplacement, error) {
if s, ok := raw.(string); ok {
// Some models serialize array arguments as a JSON string.
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
}
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
}
edits := make([]editReplacement, 0, len(items))
for i, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
}
oldText, oldOK := editTextArg(entry, "old_text", "oldText")
newText, newOK := editTextArg(entry, "new_text", "newText")
if !oldOK || !newOK {
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
}
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
}
return edits, nil
}
// editTextArg reads the first present string key, tolerating both snake_case
// and camelCase spellings that models emit.
func editTextArg(entry map[string]any, keys ...string) (string, bool) {
for _, key := range keys {
if value, ok := entry[key].(string); ok {
return value, true
}
}
return "", false
}
func editNotFoundError(path string, editIndex, totalEdits int) error {
if totalEdits == 1 {
return fmt.Errorf("old_text was not found in %s", path)
}
return fmt.Errorf("edits[%d].old_text was not found in %s", editIndex, path)
}
func editAmbiguousError(path string, editIndex, totalEdits, occurrences int) error {
if totalEdits == 1 {
return fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", occurrences, path)
}
return fmt.Errorf("edits[%d].old_text matched %d times in %s; each edit must match exactly once, so provide more surrounding context", editIndex, occurrences, path)
}
func cleanRelativePath(path string) (string, error) {
+233
View File
@@ -59,6 +59,239 @@ func TestEditRequiresUniqueMatchByDefault(t *testing.T) {
}
}
func TestEditAppliesMultipleEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("alpha beta gamma delta\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "beta", "new_text": "BETA"},
map[string]any{"old_text": "delta", "new_text": "DELTA"},
},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.Content, "2 edits, 2 replacements") {
t.Fatalf("result = %q", result.Content)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "alpha BETA gamma DELTA\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditMatchesEditsAgainstOriginalContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("abc def\n"), 0o644); err != nil {
t.Fatal(err)
}
// edits[1] must target the original "def", not the one introduced by edits[0].
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "abc", "new_text": "def"},
map[string]any{"old_text": "def", "new_text": "ghi"},
},
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "def ghi\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditRejectsOverlappingEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("abc\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "ab", "new_text": "x"},
map[string]any{"old_text": "bc", "new_text": "y"},
},
})
if err == nil {
t.Fatal("expected overlapping edits to fail")
}
if !strings.Contains(err.Error(), "overlap") {
t.Fatalf("err = %v", err)
}
}
func TestEditMultipleEditsNotFoundIndexed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "missing", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected missing edit to fail")
}
if !strings.Contains(err.Error(), "edits[1]") {
t.Fatalf("err = %v", err)
}
}
func TestEditMultipleEditsAmbiguousIndexed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello same same\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "same", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected ambiguous edit to fail")
}
if !strings.Contains(err.Error(), "edits[1]") || !strings.Contains(err.Error(), "matched 2 times") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsEmptyEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
for name, args := range map[string]map[string]any{
"missing edits": {"path": "note.txt"},
"empty edits": {"path": "note.txt", "edits": []any{}},
} {
if _, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, args); err == nil {
t.Fatalf("%s: expected error", name)
} else if !strings.Contains(err.Error(), "edits parameter is required") {
t.Fatalf("%s: err = %v", name, err)
}
}
}
func TestEditRejectsEmptyOldTextInArray(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": []any{
map[string]any{"old_text": "hello", "new_text": "hi"},
map[string]any{"old_text": "", "new_text": "x"},
},
})
if err == nil {
t.Fatal("expected empty old_text to fail")
}
if !strings.Contains(err.Error(), "edits[1].old_text must not be empty") {
t.Fatalf("err = %v", err)
}
}
func TestEditAcceptsJSONStringEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
t.Fatal(err)
}
// Some models serialize array arguments as a JSON string.
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"edits": `[{"oldText": "hello", "newText": "hi"}, {"oldText": "world", "newText": "earth"}]`,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(content) != "hi earth\n" {
t.Fatalf("content = %q", content)
}
}
func TestEditRejectsReplaceAllWithMultipleEdits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("a b c\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"replace_all": true,
"edits": []any{
map[string]any{"old_text": "a", "new_text": "x"},
map[string]any{"old_text": "b", "new_text": "y"},
},
})
if err == nil {
t.Fatal("expected replace_all with multiple edits to fail")
}
if !strings.Contains(err.Error(), "replace_all") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsNoChange(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
"path": "note.txt",
"old_text": "hello",
"new_text": "hello",
})
if err == nil {
t.Fatal("expected no-change edit to fail")
}
if !strings.Contains(err.Error(), "no changes") {
t.Fatalf("err = %v", err)
}
}
func TestEditRejectsEscapingPath(t *testing.T) {
dir := t.TempDir()
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
+5 -2
View File
@@ -9,8 +9,9 @@ import (
)
// Skill is the model-facing adapter for the core agent skill catalog.
// It only supplies instructions; regular tools retain their own approval
// requirements for filesystem or network access.
// Model-initiated loads require approval because a skill's instructions can
// influence the rest of the run. Explicit user activation is handled by the
// session's synthetic skill call and bypasses this adapter.
type Skill struct{ Catalog *agent.SkillCatalog }
func (t *Skill) Name() string { return "skill" }
@@ -25,6 +26,8 @@ func (t *Skill) Schema() api.ToolFunction {
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
}
func (t *Skill) RequiresApproval(map[string]any) bool { return true }
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
name, ok := args["name"].(string)
if !ok {
+138 -9
View File
@@ -8,9 +8,117 @@ import (
"testing"
"github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
)
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
func TestSkillLoadsCoreCatalogWithApproval(t *testing.T) {
catalog := testSkillCatalog(t)
tool := &Skill{Catalog: catalog}
if !agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
t.Fatal("model-initiated skill loading should require approval")
}
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v, %v", result, err)
}
}
func TestModelSkillLoadRequiresApproval(t *testing.T) {
for _, tt := range []struct {
name string
approval agent.Approval
prompt bool
wantCalls int
wantPrompts int
wantResult string
}{
{name: "rejected", approval: agent.Approval{Reason: "Skill loading denied."}, prompt: true, wantCalls: 1, wantPrompts: 1, wantResult: "Skill loading denied."},
{name: "approved", approval: agent.Approval{Allow: true}, prompt: true, wantCalls: 2, wantPrompts: 1, wantResult: "Use concise bullets."},
{name: "headless denied", wantCalls: 1, wantResult: "Tool execution requires approval"},
} {
t.Run(tt.name, func(t *testing.T) {
catalog := testSkillCatalog(t)
args := api.NewToolCallFunctionArguments()
args.Set("name", "release-notes")
client := &skillTestClient{responses: [][]api.ChatResponse{
{{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
ID: "call_skill_1",
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
}}}}},
{{Message: api.Message{Role: "assistant", Content: "done"}}},
}}
var prompter *skillApprovalPrompter
var approvalPrompter agent.ApprovalPrompter
if tt.prompt {
prompter = &skillApprovalPrompter{result: tt.approval}
approvalPrompter = prompter
}
registry := &agent.Registry{}
registry.Register(&Skill{Catalog: catalog})
result, err := (&agent.Session{
Client: client,
Tools: registry,
ApprovalPrompter: approvalPrompter,
}).Run(context.Background(), agent.RunOptions{
Model: "test",
NewMessages: []api.Message{{Role: "user", Content: "load the release-notes skill"}},
})
if err != nil {
t.Fatal(err)
}
if tt.prompt {
if got := len(prompter.requests); got != tt.wantPrompts {
t.Fatalf("approval prompts = %d, want %d", got, tt.wantPrompts)
}
request := prompter.requests[0]
if len(request.Calls) != 1 || request.Calls[0].ToolName != "skill" || request.Calls[0].ApprovalScope != "skill" || request.Calls[0].Args["name"] != "release-notes" {
t.Fatalf("approval request = %#v", request)
}
}
if got := client.calls; got != tt.wantCalls {
t.Fatalf("model calls = %d, want %d", got, tt.wantCalls)
}
var toolResult string
for _, message := range result.Messages {
if message.Role == "tool" && message.ToolCallID == "call_skill_1" {
toolResult = message.Content
break
}
}
if !strings.Contains(toolResult, tt.wantResult) {
t.Fatalf("skill tool result = %q, want it to contain %q", toolResult, tt.wantResult)
}
})
}
}
func TestExplicitSkillActivationBypassesApproval(t *testing.T) {
catalog := testSkillCatalog(t)
client := &skillTestClient{responses: [][]api.ChatResponse{{{Message: api.Message{Role: "assistant", Content: "done"}}}}}
prompter := &skillApprovalPrompter{result: agent.Approval{}}
result, err := (&agent.Session{
Client: client,
Skills: catalog,
ApprovalPrompter: prompter,
}).Run(context.Background(), agent.RunOptions{
Model: "test",
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
SkillName: "release-notes",
})
if err != nil {
t.Fatal(err)
}
if len(prompter.requests) != 0 {
t.Fatalf("explicit activation prompted for approval: %#v", prompter.requests)
}
if len(result.Messages) != 4 || result.Messages[2].ToolName != "skill" || !strings.Contains(result.Messages[2].Content, "Use concise bullets.") {
t.Fatalf("synthetic skill activation = %#v", result.Messages)
}
}
func testSkillCatalog(t *testing.T) *agent.SkillCatalog {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "release-notes")
if err := os.Mkdir(path, 0o755); err != nil {
@@ -23,12 +131,33 @@ func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tool := &Skill{Catalog: catalog}
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
t.Fatal("loading a skill must not change ordinary tool approval semantics")
}
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v, %v", result, err)
}
return catalog
}
type skillTestClient struct {
responses [][]api.ChatResponse
calls int
}
func (c *skillTestClient) Chat(_ context.Context, _ *api.ChatRequest, fn api.ChatResponseFunc) error {
if c.calls >= len(c.responses) {
return nil
}
for _, response := range c.responses[c.calls] {
if err := fn(response); err != nil {
return err
}
}
c.calls++
return nil
}
type skillApprovalPrompter struct {
requests []agent.ApprovalRequest
result agent.Approval
}
func (p *skillApprovalPrompter) PromptApproval(_ context.Context, request agent.ApprovalRequest) (agent.Approval, error) {
p.requests = append(p.requests, request)
return p.result, nil
}
+47 -19
View File
@@ -217,8 +217,31 @@ type MessagesResponse struct {
// Usage contains token usage information
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
OutputTokens int `json:"output_tokens"`
}
// UsageFromMetrics separates total prompt tokens into uncached and cache-read counts.
func UsageFromMetrics(metrics api.Metrics) Usage {
total := max(0, metrics.PromptEvalCount)
var cached *int
if metrics.PromptEvalCachedCount != nil {
count := min(max(0, *metrics.PromptEvalCachedCount), total)
cached = &count
}
return Usage{
InputTokens: total - intValue(cached),
CacheReadInputTokens: cached,
OutputTokens: metrics.EvalCount,
}
}
func intValue(v *int) int {
if v == nil {
return 0
}
return *v
}
// Streaming event types
@@ -273,8 +296,9 @@ type MessageDelta struct {
// DeltaUsage contains cumulative token usage
type DeltaUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokens int `json:"input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
OutputTokens int `json:"output_tokens"`
}
// MessageStopEvent signals the end of the message
@@ -688,10 +712,7 @@ func ToMessagesResponse(id string, r api.ChatResponse) MessagesResponse {
Model: r.Model,
Content: content,
StopReason: stopReason,
Usage: Usage{
InputTokens: r.Metrics.PromptEvalCount,
OutputTokens: r.Metrics.EvalCount,
},
Usage: UsageFromMetrics(r.Metrics),
}
}
@@ -721,6 +742,7 @@ type StreamConverter struct {
firstWrite bool
contentIndex int
inputTokens int
cacheReadTokens *int
outputTokens int
estimatedInputTokens int // Estimated tokens from request (used when actual metrics are 0)
thinkingStarted bool
@@ -752,8 +774,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
if c.firstWrite {
c.firstWrite = false
// Use actual metrics if available, otherwise use estimate
c.inputTokens = r.Metrics.PromptEvalCount
if c.inputTokens == 0 && c.estimatedInputTokens > 0 {
usage := UsageFromMetrics(r.Metrics)
c.inputTokens = usage.InputTokens
c.cacheReadTokens = usage.CacheReadInputTokens
if c.inputTokens == 0 && intValue(c.cacheReadTokens) == 0 && c.estimatedInputTokens > 0 {
c.inputTokens = c.estimatedInputTokens
}
@@ -768,8 +792,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
Model: c.Model,
Content: []ContentBlock{},
Usage: Usage{
InputTokens: c.inputTokens,
OutputTokens: 0,
InputTokens: c.inputTokens,
CacheReadInputTokens: c.cacheReadTokens,
OutputTokens: 0,
},
},
},
@@ -950,8 +975,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
})
}
c.inputTokens = r.Metrics.PromptEvalCount
c.outputTokens = r.Metrics.EvalCount
usage := UsageFromMetrics(r.Metrics)
c.inputTokens = usage.InputTokens
c.cacheReadTokens = usage.CacheReadInputTokens
c.outputTokens = usage.OutputTokens
stopReason := mapStopReason(r.DoneReason, len(c.toolCallsSent) > 0)
events = append(events, StreamEvent{
@@ -962,8 +989,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
StopReason: stopReason,
},
Usage: DeltaUsage{
InputTokens: c.inputTokens,
OutputTokens: c.outputTokens,
InputTokens: c.inputTokens,
CacheReadInputTokens: c.cacheReadTokens,
OutputTokens: c.outputTokens,
},
},
})
@@ -1075,7 +1103,7 @@ type CountTokensRequest struct {
// EstimateInputTokens estimates input tokens from a MessagesRequest (reuses CountTokensRequest logic)
func EstimateInputTokens(req MessagesRequest) int {
return estimateTokens(CountTokensRequest{
return EstimateCountTokens(CountTokensRequest{
Model: req.Model,
Messages: req.Messages,
System: req.System,
@@ -1089,10 +1117,10 @@ type CountTokensResponse struct {
InputTokens int `json:"input_tokens"`
}
// estimateTokens returns a rough estimate of tokens (len/4).
// EstimateCountTokens returns a rough estimate of tokens (len/4).
// TODO: Replace with actual tokenization via Tokenize API for accuracy.
// Current len/4 heuristic is a rough approximation (~4 chars/token average).
func estimateTokens(req CountTokensRequest) int {
func EstimateCountTokens(req CountTokensRequest) int {
var totalLen int
// Count system prompt
+225 -11
View File
@@ -16,6 +16,10 @@ const (
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
)
func testIntPtr(v int) *int {
return &v
}
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
func textContent(s string) []ContentBlock {
return []ContentBlock{{Type: "text", Text: &s}}
@@ -30,6 +34,61 @@ func makeArgs(kvs ...any) api.ToolCallFunctionArguments {
return args
}
func TestUsageFromMetricsBoundsCacheReads(t *testing.T) {
tests := []struct {
name string
metrics api.Metrics
want Usage
}{
{
name: "negative counts",
metrics: api.Metrics{PromptEvalCount: -1, PromptEvalCachedCount: testIntPtr(-2), EvalCount: 3},
want: Usage{CacheReadInputTokens: testIntPtr(0), OutputTokens: 3},
},
{
name: "cache reads exceed prompt",
metrics: api.Metrics{PromptEvalCount: 3, PromptEvalCachedCount: testIntPtr(5), EvalCount: 2},
want: Usage{CacheReadInputTokens: testIntPtr(3), OutputTokens: 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if diff := cmp.Diff(tt.want, UsageFromMetrics(tt.metrics)); diff != "" {
t.Errorf("usage mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestUsageCacheReadJSON(t *testing.T) {
tests := []struct {
name string
count *int
want string
}{
{name: "unreported", want: `{"input_tokens":10,"output_tokens":2}`},
{name: "zero", count: testIntPtr(0), want: `{"input_tokens":10,"cache_read_input_tokens":0,"output_tokens":2}`},
{name: "positive", count: testIntPtr(4), want: `{"input_tokens":6,"cache_read_input_tokens":4,"output_tokens":2}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(UsageFromMetrics(api.Metrics{
PromptEvalCount: 10,
PromptEvalCachedCount: tt.count,
EvalCount: 2,
}))
if err != nil {
t.Fatal(err)
}
if got := string(data); got != tt.want {
t.Errorf("json = %s, want %s", got, tt.want)
}
})
}
}
func TestFromMessagesRequest_Basic(t *testing.T) {
req := MessagesRequest{
Model: "test-model",
@@ -145,6 +204,118 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
}
}
func TestFromMessagesRequest_ClaudeAutoModeClassifierFixtures(t *testing.T) {
tests := []struct {
name string
request string
model string
maxTokens int
wantStop []string
wantSystem string
wantUser string
}{
{
name: "stage one local model",
request: `{
"model": "qwen3.5:latest",
"max_tokens": 2112,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<transcript>\n"},
{"type": "text", "text": "User: Run the safe test.\n"},
{"type": "text", "text": "Bash go test ./safe\n"},
{"type": "text", "text": "</transcript>\n"},
{"type": "text", "text": "Return only the stage-one block verdict."}
]
}],
"system": [
{
"type": "text",
"text": "Synthetic policy fixture. Evaluate whether the proposed action needs further review.",
"cache_control": {"type": "ephemeral"}
},
{"type": "text", "text": "Synthetic session context."}
],
"stop_sequences": ["</block>"]
}`,
model: "qwen3.5:latest",
maxTokens: 2112,
wantStop: []string{"</block>"},
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action needs further review.Synthetic session context.",
wantUser: "<transcript>\nUser: Run the safe test.\nBash go test ./safe\n</transcript>\nReturn only the stage-one block verdict.",
},
{
name: "stage two cloud model",
request: `{
"model": "glm-5.2:cloud",
"max_tokens": 10240,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<transcript>\n"},
{"type": "text", "text": "User: Send the fixture to an external host.\n"},
{"type": "text", "text": "Bash upload fixture.txt\n"},
{"type": "text", "text": "</transcript>\n"},
{"type": "text", "text": "Return the stage-two block verdict and reason."}
]
}],
"system": [
{
"type": "text",
"text": "Synthetic policy fixture. Evaluate whether the proposed action must be denied.",
"cache_control": {"type": "ephemeral"}
},
{"type": "text", "text": "Synthetic session context."}
]
}`,
model: "glm-5.2:cloud",
maxTokens: 10240,
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action must be denied.Synthetic session context.",
wantUser: "<transcript>\nUser: Send the fixture to an external host.\nBash upload fixture.txt\n</transcript>\nReturn the stage-two block verdict and reason.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var request MessagesRequest
if err := json.Unmarshal([]byte(tt.request), &request); err != nil {
t.Fatal(err)
}
converted, err := FromMessagesRequest(request)
if err != nil {
t.Fatal(err)
}
if converted.Model != tt.model {
t.Fatalf("model = %q, want exact selected model %q", converted.Model, tt.model)
}
if converted.Stream == nil || *converted.Stream {
t.Fatalf("stream = %v, want explicit non-streaming conversion", converted.Stream)
}
if len(converted.Tools) != 0 {
t.Fatalf("tools = %v, want tool-free classifier request", converted.Tools)
}
if got := converted.Options["num_predict"]; got != tt.maxTokens {
t.Fatalf("num_predict = %v, want %d", got, tt.maxTokens)
}
gotStop, _ := converted.Options["stop"].([]string)
if diff := cmp.Diff(tt.wantStop, gotStop); diff != "" {
t.Fatalf("stop sequences mismatch (-want +got):\n%s", diff)
}
if len(converted.Messages) != 2 {
t.Fatalf("messages = %+v, want system and user messages", converted.Messages)
}
if got := converted.Messages[0]; got.Role != "system" || got.Content != tt.wantSystem {
t.Fatalf("system message = %+v", got)
}
if got := converted.Messages[1]; got.Role != "user" || got.Content != tt.wantUser {
t.Fatalf("user message = %+v", got)
}
})
}
}
func TestFromMessagesRequest_WithImage(t *testing.T) {
imgData, _ := base64.StdEncoding.DecodeString(testImage)
@@ -749,8 +920,9 @@ func TestToMessagesResponse_Basic(t *testing.T) {
Done: true,
DoneReason: "stop",
Metrics: api.Metrics{
PromptEvalCount: 10,
EvalCount: 5,
PromptEvalCount: 10,
PromptEvalCachedCount: testIntPtr(4),
EvalCount: 5,
},
}
@@ -774,9 +946,51 @@ func TestToMessagesResponse_Basic(t *testing.T) {
if result.StopReason != "end_turn" {
t.Errorf("expected stop_reason 'end_turn', got %q", result.StopReason)
}
if result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 5 {
if result.Usage.InputTokens != 6 || intValue(result.Usage.CacheReadInputTokens) != 4 || result.Usage.OutputTokens != 5 {
t.Errorf("unexpected usage: %+v", result.Usage)
}
data, err := json.Marshal(result.Usage)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"cache_read_input_tokens":4`) {
t.Errorf("unexpected usage json: %s", data)
}
}
func TestToMessagesResponse_PreservesClaudeAutoClassifierOutput(t *testing.T) {
for _, output := range []string{
"<block>no",
"<block>yes</block><category>Synthetic risk</category><reason>Denied by the synthetic fixture.</reason>",
"malformed classifier output",
} {
t.Run(output, func(t *testing.T) {
result := ToMessagesResponse("msg_classifier", api.ChatResponse{
Model: "qwen3.5:latest",
Message: api.Message{
Role: "assistant",
Content: output,
},
Done: true,
DoneReason: "stop",
Metrics: api.Metrics{
PromptEvalCount: 24644,
EvalCount: 300,
},
})
if result.Model != "qwen3.5:latest" || len(result.Content) != 1 || result.Content[0].Text == nil || *result.Content[0].Text != output {
t.Fatalf("classifier response = %+v, want opaque output on the selected model", result)
}
if result.StopReason != "end_turn" {
t.Fatalf("stop reason = %q, want end_turn", result.StopReason)
}
if result.Usage.InputTokens != 24644 || result.Usage.OutputTokens != 300 {
t.Fatalf("usage = %+v", result.Usage)
}
})
}
}
func TestToMessagesResponse_WithToolCalls(t *testing.T) {
@@ -926,7 +1140,7 @@ func TestStreamConverter_Basic(t *testing.T) {
Role: "assistant",
Content: "Hello",
},
Metrics: api.Metrics{PromptEvalCount: 10},
Metrics: api.Metrics{PromptEvalCount: 10, PromptEvalCachedCount: testIntPtr(4)},
}
events1 := conv.Process(resp1)
@@ -954,7 +1168,7 @@ func TestStreamConverter_Basic(t *testing.T) {
},
Done: true,
DoneReason: "stop",
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
Metrics: api.Metrics{PromptEvalCount: 10, PromptEvalCachedCount: testIntPtr(4), EvalCount: 5},
}
events2 := conv.Process(resp2)
@@ -972,7 +1186,7 @@ func TestStreamConverter_Basic(t *testing.T) {
t.Errorf("unexpected stop reason: %+v", data.Delta.StopReason)
}
if data.Usage.InputTokens != 10 || data.Usage.OutputTokens != 5 {
if data.Usage.InputTokens != 6 || intValue(data.Usage.CacheReadInputTokens) != 4 || data.Usage.OutputTokens != 5 {
t.Errorf("unexpected usage: %+v", data.Usage)
}
} else {
@@ -1546,7 +1760,7 @@ func TestEstimateTokens_SimpleMessage(t *testing.T) {
},
}
tokens := estimateTokens(req)
tokens := EstimateCountTokens(req)
// "user" (4) + "Hello, world!" (13) = 17 chars / 4 = 4 tokens
if tokens < 1 {
@@ -1567,7 +1781,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
},
}
tokens := estimateTokens(req)
tokens := EstimateCountTokens(req)
// System prompt adds to count
if tokens < 5 {
@@ -1590,7 +1804,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
},
}
tokens := estimateTokens(req)
tokens := EstimateCountTokens(req)
// Tools add significant content
if tokens < 10 {
@@ -1619,7 +1833,7 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
},
}
tokens := estimateTokens(req)
tokens := EstimateCountTokens(req)
// Thinking content should be counted
if tokens < 10 {
@@ -1633,7 +1847,7 @@ func TestEstimateTokens_EmptyContent(t *testing.T) {
Messages: []MessageParam{},
}
tokens := estimateTokens(req)
tokens := EstimateCountTokens(req)
if tokens != 0 {
t.Errorf("expected 0 tokens for empty content, got %d", tokens)
-10
View File
@@ -510,13 +510,3 @@ func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) {
}
return &resp, nil
}
// Usage returns the authenticated user's recent activity and included-usage
// limits.
func (c *Client) Usage(ctx context.Context) (*UsageResponse, error) {
var resp UsageResponse
if err := c.do(ctx, http.MethodGet, "/api/usage", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
+59 -26
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -51,32 +52,6 @@ func TestClientFromEnvironment(t *testing.T) {
}
}
func TestClientUsage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/usage" {
t.Fatalf("request = %s %s, want GET /api/usage", r.Method, r.URL.Path)
}
fmt.Fprint(w, `{"activity":{"cost":"0.00709","period":{"type":"last_4_weeks","starting_at":"2026-06-29T00:00:00Z","ending_at":"2026-07-27T00:00:00Z"},"models":[{"name":"qwen3-coder:480b","request_count":1,"cost":"0.00709"}]},"limits":{"session":{"usage":0.006,"models":[]},"weekly":{"usage":0,"models":[]}}}`)
}))
defer ts.Close()
base, err := url.Parse(ts.URL)
if err != nil {
t.Fatal(err)
}
got, err := NewClient(base, ts.Client()).Usage(t.Context())
if err != nil {
t.Fatal(err)
}
if got.Activity.Cost != "0.00709" {
t.Errorf("activity cost = %q, want 0.00709", got.Activity.Cost)
}
if len(got.Activity.Models) != 1 || got.Activity.Models[0].Name != "qwen3-coder:480b" {
t.Errorf("activity models = %#v, want qwen3-coder:480b", got.Activity.Models)
}
}
// testError represents an internal error type with status code and message
// this is used since the error response from the server is not a standard error struct
type testError struct {
@@ -414,6 +389,64 @@ func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
}
}
func TestClientWebSearchExperimentalErrors(t *testing.T) {
tests := []struct {
name string
status int
body string
assertError func(*testing.T, error)
}{
{
name: "unauthorized retains sign in URL",
status: http.StatusUnauthorized,
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
assertError: func(t *testing.T, err error) {
t.Helper()
var authErr AuthorizationError
if !errors.As(err, &authErr) {
t.Fatalf("error = %T, want AuthorizationError", err)
}
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
t.Fatalf("authorization error = %#v", authErr)
}
},
},
{
name: "rate limit retains status",
status: http.StatusTooManyRequests,
body: `{"error":"rate limit exceeded"}`,
assertError: func(t *testing.T, err error) {
t.Helper()
var statusErr StatusError
if !errors.As(err, &statusErr) {
t.Fatalf("error = %T, want StatusError", err)
}
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
t.Fatalf("status error = %#v", statusErr)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer ts.Close()
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
if err == nil {
t.Fatal("expected error")
}
tt.assertError(t, err)
})
}
}
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
var gotPath string
var gotMethod string
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"encoding/json"
"io"
"os"
"strings"
"testing"
"time"
)
func TestMetricsCachedPromptJSON(t *testing.T) {
tests := []struct {
name string
count *int
want string
}{
{name: "unreported", want: `{}`},
{name: "zero", count: testIntPtr(0), want: `{"prompt_eval_cached_count":0}`},
{name: "positive", count: testIntPtr(4), want: `{"prompt_eval_cached_count":4}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(Metrics{PromptEvalCachedCount: tt.count})
if err != nil {
t.Fatal(err)
}
if got := string(data); got != tt.want {
t.Errorf("json = %s, want %s", got, tt.want)
}
var metrics Metrics
if err := json.Unmarshal(data, &metrics); err != nil {
t.Fatal(err)
}
if tt.count == nil {
if metrics.PromptEvalCachedCount != nil {
t.Errorf("cached count = %v, want nil", metrics.PromptEvalCachedCount)
}
} else if metrics.PromptEvalCachedCount == nil || *metrics.PromptEvalCachedCount != *tt.count {
t.Errorf("cached count = %v, want %d", metrics.PromptEvalCachedCount, *tt.count)
}
})
}
}
func TestMetricsSummaryCachedPromptTokens(t *testing.T) {
read, write, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
original := os.Stderr
os.Stderr = write
t.Cleanup(func() { os.Stderr = original })
(&Metrics{
PromptEvalCount: 10,
PromptEvalCachedCount: testIntPtr(4),
PromptEvalDuration: time.Second,
}).Summary()
write.Close()
os.Stderr = original
output, err := io.ReadAll(read)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"prompt eval count: 10 token(s)", "prompt eval cached: 4 token(s)", "prompt eval rate: 6.00 tokens/s"} {
if !strings.Contains(string(output), want) {
t.Errorf("summary missing %q:\n%s", want, output)
}
}
}
+58 -83
View File
@@ -127,20 +127,6 @@ type GenerateRequest struct {
// each with an associated log probability. Only applies when Logprobs is true.
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
TopLogprobs int `json:"top_logprobs,omitempty"`
// Experimental: Image generation fields (may change or be removed)
// Width is the width of the generated image in pixels.
// Only used for image generation models.
Width int32 `json:"width,omitempty"`
// Height is the height of the generated image in pixels.
// Only used for image generation models.
Height int32 `json:"height,omitempty"`
// Steps is the number of diffusion steps for image generation.
// Only used for image generation models.
Steps int32 `json:"steps,omitempty"`
}
// ChatRequest describes a request sent by [Client.Chat].
@@ -569,12 +555,13 @@ type DebugInfo struct {
}
type Metrics struct {
TotalDuration time.Duration `json:"total_duration,omitempty"`
LoadDuration time.Duration `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration time.Duration `json:"eval_duration,omitempty"`
TotalDuration time.Duration `json:"total_duration,omitempty"`
LoadDuration time.Duration `json:"load_duration,omitempty"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
PromptEvalCachedCount *int `json:"prompt_eval_cached_count,omitempty"`
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
EvalCount int `json:"eval_count,omitempty"`
EvalDuration time.Duration `json:"eval_duration,omitempty"`
}
// Options specified in [GenerateRequest]. If you add a new option here, also
@@ -706,8 +693,11 @@ type CreateRequest struct {
// Messages is a list of messages added to the model before chat and generation requests.
Messages []Message `json:"messages,omitempty"`
// Renderer is the name of the renderer used when constructing a request to the model.
Renderer string `json:"renderer,omitempty"`
Parser string `json:"parser,omitempty"`
// Parser is the name of the parser used to parse the output of the request.
Parser string `json:"parser,omitempty"`
// Requires is the minimum version of Ollama required by the model.
Requires string `json:"requires,omitempty"`
@@ -811,17 +801,46 @@ type ListResponse struct {
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
type ModelRecommendationsResponse struct {
Recommendations []ModelRecommendation `json:"recommendations"`
Recommendations []ModelRecommendation `json:"recommendations"`
Mappings *ModelRecommendationMappings `json:"mappings,omitempty"`
}
// ModelRecommendationMapping defines one app-specific route preference.
type ModelRecommendationMapping struct {
Model string `json:"model"`
RequiredPlan string `json:"required_plan,omitempty"`
}
// ModelRecommendationMappings defines the app-specific model routes.
type ModelRecommendationMappings map[string]ModelRecommendationMapping
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
type ModelRecommendation struct {
Model string `json:"model"`
Description string `json:"description"`
ContextLength int `json:"context_length,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
VRAMBytes int64 `json:"vram_bytes,omitempty"`
RequiredPlan string `json:"required_plan,omitempty"`
Model string `json:"model"`
Description string `json:"description"`
ContextLength int `json:"context_length,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
VRAMBytes int64 `json:"vram_bytes,omitempty"`
RequiredPlan string `json:"required_plan,omitempty"`
Thinking *ModelRecommendationThinking `json:"thinking,omitempty"`
}
// ModelRecommendationThinking advertises the exact values accepted by
// Ollama's think field and the model's default. Values may be booleans for
// binary thinking controls or strings for adjustable effort levels.
type ModelRecommendationThinking struct {
Values []any `json:"values,omitempty"`
Default any `json:"default,omitempty"`
}
// Clone returns an independent copy.
func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking {
if t == nil {
return nil
}
clone := *t
clone.Values = append([]any(nil), t.Values...)
return &clone
}
// ProcessResponse is the response from [Client.Process].
@@ -938,20 +957,6 @@ type GenerateResponse struct {
// Logprobs contains log probability information for the generated tokens,
// if requested via the Logprobs parameter.
Logprobs []Logprob `json:"logprobs,omitempty"`
// Experimental: Image generation fields (may change or be removed)
// Image contains a base64-encoded generated image.
// Only present for image generation models.
Image string `json:"image,omitempty"`
// Completed is the number of completed steps in image generation.
// Only present for image generation models during streaming.
Completed int64 `json:"completed,omitempty"`
// Total is the total number of steps for image generation.
// Only present for image generation models during streaming.
Total int64 `json:"total,omitempty"`
}
// ModelDetails provides details about a model.
@@ -978,45 +983,6 @@ type UserResponse struct {
Plan string `json:"plan,omitempty"`
}
// UsageResponse reports recent activity and included-usage limits.
type UsageResponse struct {
Activity UsageActivity `json:"activity"`
Limits UsageLimits `json:"limits"`
}
// UsageActivity reports usage activity over a period.
type UsageActivity struct {
Cost string `json:"cost"`
Period UsagePeriod `json:"period"`
Models []UsageModel `json:"models"`
}
// UsagePeriod describes the time window the usage covers.
type UsagePeriod struct {
Type string `json:"type"`
StartingAt time.Time `json:"starting_at"`
EndingAt time.Time `json:"ending_at"`
}
// UsageLimits reports included usage for the current session and week.
type UsageLimits struct {
Session UsageLimit `json:"session"`
Weekly UsageLimit `json:"weekly"`
}
// UsageLimit reports the consumed fraction of an included-usage limit.
type UsageLimit struct {
Usage float64 `json:"usage"`
Models []UsageModel `json:"models"`
}
// UsageModel reports a model's activity.
type UsageModel struct {
Name string `json:"name"`
RequestCount int `json:"request_count"`
Cost string `json:"cost,omitempty"`
}
// Tensor describes the metadata for a given tensor.
type Tensor struct {
Name string `json:"name"`
@@ -1037,9 +1003,18 @@ func (m *Metrics) Summary() {
fmt.Fprintf(os.Stderr, "prompt eval count: %d token(s)\n", m.PromptEvalCount)
}
cached := 0
if m.PromptEvalCachedCount != nil {
cached = *m.PromptEvalCachedCount
}
if cached > 0 {
fmt.Fprintf(os.Stderr, "prompt eval cached: %d token(s)\n", cached)
}
if m.PromptEvalDuration > 0 {
fmt.Fprintf(os.Stderr, "prompt eval duration: %s\n", m.PromptEvalDuration)
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(m.PromptEvalCount)/m.PromptEvalDuration.Seconds())
uncached := max(0, m.PromptEvalCount-cached)
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(uncached)/m.PromptEvalDuration.Seconds())
}
if m.EvalCount > 0 {
@@ -1169,7 +1144,7 @@ func DefaultOptions() Options {
TopP: 0.9,
TypicalP: 1.0,
RepeatLastN: 64,
RepeatPenalty: 1.1,
RepeatPenalty: 1.0,
PresencePenalty: 0.0,
FrequencyPenalty: 0.0,
Seed: -1,
+19
View File
@@ -4,9 +4,12 @@ import (
"encoding/json"
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"github.com/ollama/ollama/types/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -213,6 +216,22 @@ func TestMainGPUParsingFromJSON(t *testing.T) {
}
}
func TestGenerationDefaultMappingsAreOptions(t *testing.T) {
jsonOpts := make(map[string]struct{})
for _, field := range reflect.VisibleFields(reflect.TypeOf(Options{})) {
jsonTag := strings.Split(field.Tag.Get("json"), ",")[0]
if jsonTag != "" {
jsonOpts[jsonTag] = struct{}{}
}
}
for _, option := range model.GenerationDefaultOptions() {
if _, ok := jsonOpts[option]; !ok {
t.Fatalf("%s should be defined on api.Options", option)
}
}
}
func TestUseMmapFormatParams(t *testing.T) {
tr := true
fa := false
+67 -25
View File
@@ -146,15 +146,10 @@ func main() {
// Do this after logging is set up so we can debug issues
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
if checkAndHandleExistingInstance(urlSchemeRequest) {
// The function will exit if it successfully sends to another instance
// If we reach here, we're the first/only instance
} else {
// No existing instance found, handle the URL scheme in this instance
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
}
// This exits after forwarding the request when another instance is
// running. First-instance requests are handled later by osRun, after the
// Windows UI dependencies are initialized and from the primary thread.
checkAndHandleExistingInstance(urlSchemeRequest)
}
// Detect if this is a first start after an upgrade, in
@@ -180,7 +175,9 @@ func main() {
// Check if another instance is already running
// On Windows, focus the existing instance; on other platforms, kill it
handleExistingInstance(startHidden)
if !handleExistingInstance(startHidden) {
return
}
// on macOS, offer the user to create a symlink
// from /usr/local/bin/ollama to the app bundle
@@ -205,6 +202,12 @@ func main() {
uiServerPort = port
st := &store.Store{}
if devMode {
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
st.DBPath = dbPath
slog.Debug("using development app database", "path", dbPath)
}
}
appStore = st
// Enable CORS in development mode
@@ -324,11 +327,11 @@ func main() {
quit()
}()
if urlSchemeRequest != "" {
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
go func() {
handleURLSchemeInCurrentInstance(urlSchemeRequest)
}()
} else {
} else if urlSchemeRequest == "" {
slog.Debug("no URL scheme request to handle")
}
@@ -343,7 +346,13 @@ func main() {
}
}()
osRun(cancel, hasCompletedFirstRun, startHidden)
settings, settingsErr := st.Settings()
showOnboarding := shouldShowOnboarding(settings, settingsErr)
if settingsErr != nil {
slog.Error("failed to load onboarding state", "error", settingsErr)
}
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
slog.Info("shutting down desktop server")
if err := srv.Close(); err != nil {
@@ -355,6 +364,33 @@ func main() {
<-done
}
func shouldShowOnboarding(settings store.Settings, err error) bool {
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
}
func runInitialWindowsUI(
startHidden bool,
showOnboarding bool,
urlSchemeRequest string,
startHiddenFn func(),
handleURLFn func(string),
showUIFn func(string),
) {
if urlSchemeRequest != "" {
handleURLFn(urlSchemeRequest)
return
}
if startHidden {
startHiddenFn()
return
}
if showOnboarding {
showUIFn("/")
return
}
showUIFn("/connect")
}
func startHiddenTasks() {
// If an upgrade is ready and we're in hidden mode, perform it at startup.
// If we're not in hidden mode, we want to start as fast as possible and not
@@ -375,7 +411,7 @@ func startHiddenTasks() {
return
}
if err := updater.DoUpgradeAtStartup(); err != nil {
if err := updater.DoUpgradeAtStartup(); err != nil { //nolint:staticcheck,nolintlint // DoUpgradeAtStartup may always return non-nil on Windows
slog.Info("unable to perform upgrade at startup", "error", err)
// Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization
UpdateAvailable("")
@@ -432,7 +468,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
func handleConnectURLScheme() {
if checkUserLoggedIn(uiServerPort) {
slog.Info("user is already logged in, opening app instead")
showWindow(wv.webview.Window())
openUI("/")
return
}
@@ -491,17 +527,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
isConnect, err := parseURLScheme(urlSchemeRequest)
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
openUI("/")
})
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
if wv.webview != nil {
showWindow(wv.webview.Window())
}
}
}
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
isConnect, err := parseURLScheme(urlSchemeRequest)
if err != nil {
return err
}
if isConnect {
connect()
} else {
open()
}
return nil
}
+1779 -18
View File
File diff suppressed because it is too large. Load diff
+40 -2
View File
@@ -1,5 +1,7 @@
#import <Cocoa/Cocoa.h>
#import <Security/Security.h>
#include <stddef.h>
#include <stdint.h>
@interface AppDelegate : NSObject <NSApplicationDelegate>
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
@@ -16,8 +18,12 @@ enum AppMove
MoveError,
};
void run(bool firstTimeRun, bool startHidden);
void killOtherInstances();
void run(bool showOnboarding, bool startHidden);
typedef struct {
int pid;
int64_t started_at;
} AppProcessIdentity;
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
enum AppMove askToMoveToApplications();
int createSymlinkWithAuthorization();
int installSymlink(const char *cliPath);
@@ -25,6 +31,7 @@ extern void Restart();
// extern void Quit();
void StartUI(const char *path);
void ShowUI();
bool IsOnboardingActive(void);
void StopUI();
void StartUpdate();
void darwinStartHiddenTasks();
@@ -38,6 +45,37 @@ void setWindowDelegate(void *window);
void showWindow(uintptr_t wndPtr);
void hideWindow(uintptr_t wndPtr);
void styleWindow(uintptr_t wndPtr);
void setWindowResizable(uintptr_t wndPtr, bool resizable);
void drag(uintptr_t wndPtr);
void doubleClick(uintptr_t wndPtr);
void handleConnectURL();
bool SetClaudeGatewayInstalled(bool installed, bool restartClaude);
bool HasUsedClaudeDesktopIntegration(void);
bool RestoreClaudeGatewayForShutdown(void);
bool IsClaudeGatewayConfigured(void);
bool IsClaudeDesktopInstalled(void);
bool IsClaudeDesktopRunning(void);
bool IsCodexDesktopInstalled(void);
bool IsCodexDesktopConnected(void);
bool IsCodexDesktopRunning(void);
unsigned long long CodexDesktopRequestCount(void);
bool SetCodexDesktopConnected(bool connected, bool restartConfirmed);
bool ClaudeGatewayStartFailed(void);
bool ClaudeGatewayPortConflict(void);
char *ClaudeGatewayErrorMessage(void);
int ClaudeGatewayPort(void);
void RefreshClaudeProxyMenu(void);
void updateClaudeProxyMenu(unsigned long long routed);
bool ShowAppsInMenu(void);
void SetShowAppsInMenu(bool visible);
enum ClaudeInstallResult
{
ClaudeInstallCancelled,
ClaudeInstallerOpened,
ClaudeInstallFailed,
};
enum ClaudeInstallResult installClaudeDesktop(void);
enum ClaudeInstallResult installCodexDesktop(void);
char *ClaudeDesktopDownloadRequest(char **authorization);
bool InstallClaudeDesktopArchive(const char *archivePath);
bool InstallCodexDesktopDiskImage(const char *imagePath);
+1439 -78
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+140
View File
@@ -0,0 +1,140 @@
//go:build windows || darwin
package main
import (
"errors"
"testing"
"github.com/ollama/ollama/app/store"
)
func TestShouldShowOnboarding(t *testing.T) {
tests := []struct {
name string
settings store.Settings
err error
want bool
}{
{
name: "fresh install",
settings: store.Settings{OnboardingVersion: 0},
want: true,
},
{
name: "completed onboarding",
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
want: false,
},
{
name: "settings failure",
err: errors.New("settings unavailable"),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
}
})
}
}
func TestDispatchURLSchemeRequest(t *testing.T) {
tests := []struct {
name string
request string
wantConnect bool
wantOpen bool
wantErr bool
}{
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
connected := false
opened := false
err := dispatchURLSchemeRequest(
tt.request,
func() { connected = true },
func() { opened = true },
)
if (err != nil) != tt.wantErr {
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
}
if connected != tt.wantConnect {
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
}
if opened != tt.wantOpen {
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
}
})
}
}
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
hiddenCalls := 0
urlCalls := 0
onboardingCalls := 0
openCalls := 0
runInitialWindowsUI(
false,
true,
"ollama://",
func() { hiddenCalls++ },
func(request string) {
urlCalls++
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
}
},
func(path string) {
onboardingCalls++
},
)
if urlCalls != 1 {
t.Fatalf("URL handled %d times, want 1", urlCalls)
}
if openCalls != 1 {
t.Errorf("app opened %d times, want 1", openCalls)
}
if hiddenCalls != 0 {
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
}
if onboardingCalls != 0 {
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
}
}
func TestRunInitialWindowsUIRoutesInteractiveLaunch(t *testing.T) {
for _, tt := range []struct {
name string
showOnboarding bool
wantPath string
}{
{name: "fresh install preserves onboarding", showOnboarding: true, wantPath: "/"},
{name: "returning launch opens apps", wantPath: "/connect"},
} {
t.Run(tt.name, func(t *testing.T) {
var gotPath string
runInitialWindowsUI(
false,
tt.showOnboarding,
"",
func() { t.Fatal("unexpected hidden startup") },
func(string) { t.Fatal("unexpected URL handling") },
func(path string) { gotPath = path },
)
if gotPath != tt.wantPath {
t.Fatalf("initial UI path = %q, want %q", gotPath, tt.wantPath)
}
})
}
}
+23 -30
View File
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
}
// handleExistingInstance checks for existing instances and optionally focuses them
func handleExistingInstance(startHidden bool) {
func handleExistingInstance(startHidden bool) bool {
if wintray.CheckAndFocusExistingInstance(!startHidden) {
slog.Info("existing instance found, exiting")
os.Exit(0)
}
return true
}
func installSymlink() {}
@@ -95,11 +96,15 @@ func (ac *appCallbacks) UIRun(path string) {
}
func (*appCallbacks) UIShow() {
if wv.webview != nil {
openUI("/")
}
func openUI(path string) {
if wv.IsRunning() && wv.webview != nil {
showWindow(wv.webview.Window())
} else {
wv.Run("/")
return
}
wv.Run(path)
}
func (*appCallbacks) UITerminate() {
@@ -110,6 +115,10 @@ func (*appCallbacks) UIRunning() bool {
return wv.IsRunning()
}
func (*appCallbacks) UIOnboarding() bool {
return wv.OnboardingActive()
}
func (app *appCallbacks) Quit() {
app.t.Quit()
wv.Terminate()
@@ -126,7 +135,7 @@ func (app *appCallbacks) DoUpdate() {
app.shutdown()
if err := updater.DoUpgrade(true); err != nil {
if err := updater.DoUpgrade(true); err != nil { //nolint:staticcheck,nolintlint // DoUpgrade may always return non-nil on Windows
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
}
}
@@ -138,19 +147,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
// handleURLSchemeRequest processes URL scheme requests from other instances
func handleURLSchemeRequest(urlScheme string) {
isConnect, err := parseURLScheme(urlScheme)
if err != nil {
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
return
}
if isConnect {
handleConnectURLScheme()
} else {
if wv.webview != nil {
showWindow(wv.webview.Window())
}
}
handleURLSchemeInCurrentInstance(urlScheme)
}
func UpdateAvailable(ver string) error {
@@ -161,7 +158,7 @@ func UpdateAvailable(ver string) error {
return app.t.UpdateAvailable(ver)
}
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
var err error
app.shutdown = shutdown
app.t, err = wintray.NewTray(app)
@@ -205,10 +202,8 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
}
}
}
if startHidden {
startHiddenTasks()
} else {
ptr := wv.Run("/")
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func(path string) {
ptr := wv.Run(path)
// Set the window icon using the tray icon
if ptr != nil {
@@ -225,7 +220,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
}
centerWindow(ptr)
}
})
if !hasCompletedFirstRun {
// Only create the login shortcut on first start
@@ -408,6 +403,8 @@ func hideWindow(ptr unsafe.Pointer) {
}
}
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
func runInBackground() {
exe, err := os.Executable()
if err != nil {
@@ -432,17 +429,13 @@ func drag(ptr unsafe.Pointer) {}
func doubleClick(ptr unsafe.Pointer) {}
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
func checkAndHandleExistingInstance(urlSchemeRequest string) {
if urlSchemeRequest == "" {
return false
return
}
// Try to send URL to existing instance using wintray messaging
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
os.Exit(0)
return true
}
// No existing instance, we'll handle it ourselves
return false
}
@@ -0,0 +1,98 @@
//go:build darwin
package main
import (
"errors"
"github.com/ollama/ollama/app/webview"
"github.com/ollama/ollama/cmd/launch"
)
func bindClaudeDesktop(wv webview.WebView) {
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
return getClaudeDesktopConnectionStatus()
})
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
return getClaudeDesktopConnectionSummary()
})
wv.Bind("getClaudeDesktopRequestCount", func() uint64 {
return claudeDesktopRequestCount()
})
wv.Bind("setClaudeDesktopConnected", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
err := setClaudeDesktopConnection(enabled, restartConfirmed)
result := claudeDesktopActionResult{
Status: getClaudeDesktopConnectionSummary(),
}
if err != nil {
result.Error = err.Error()
}
return result
})
wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult {
err := prepareClaudeDesktopConnection()
result := claudeDesktopActionResult{
Status: getClaudeDesktopConnectionSummary(),
}
if err != nil {
result.Error = err.Error()
}
return result
})
wv.Bind("openClaudeDesktop", func() string {
if err := openClaudeDesktopApplication(); err != nil {
return err.Error()
}
return ""
})
wv.Bind("installClaudeDesktop", func() claudeDesktopInstallResult {
return requestClaudeDesktopInstall()
})
wv.Bind("applyClaudeDesktopMappings", func(mappings map[string]string, restartConfirmed bool) claudeDesktopActionResult {
applied, err := applyClaudeDesktopMappings(mappings, restartConfirmed)
result := claudeDesktopActionResult{
Status: getClaudeDesktopConnectionStatus(),
MappingsApplied: applied,
}
if err != nil {
result.Error = err.Error()
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
}
return result
})
wv.Bind("resetClaudeDesktopMappings", func(restartConfirmed bool) claudeDesktopActionResult {
applied, err := resetClaudeDesktopMappings(restartConfirmed)
result := claudeDesktopActionResult{
Status: getClaudeDesktopConnectionStatus(),
MappingsApplied: applied,
}
if err != nil {
result.Error = err.Error()
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
}
return result
})
wv.Bind("setClaudeDesktopAutoMode", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
err := setClaudeDesktopAutoMode(enabled, restartConfirmed)
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
if err != nil {
result.Error = err.Error()
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
}
return result
})
wv.Bind("getShowAppsInMenu", func() bool {
return getShowAppsInMenu()
})
wv.Bind("setShowAppsInMenu", func(visible bool) {
setShowAppsInMenu(visible)
})
}
@@ -0,0 +1,7 @@
//go:build windows
package main
import "github.com/ollama/ollama/app/webview"
func bindClaudeDesktop(_ webview.WebView) {}
@@ -0,0 +1,252 @@
//go:build darwin
package main
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
maxClaudeDesktopArchiveBytes = 1 << 30
maxClaudeDesktopExtractBytes = 2 << 30
maxClaudeDesktopArchiveFiles = 100_000
claudeDesktopBundleID = "com.anthropic.claudefordesktop"
claudeDesktopTeamID = "Q6L2SF6YDW"
)
var errClaudeDesktopDestinationExists = errors.New("Claude Desktop installation destination already exists")
func claudeDesktopInstallDestinations() []string {
destinations := []string{"/Applications/Claude.app"}
if home, err := os.UserHomeDir(); err == nil {
destinations = append(destinations, filepath.Join(home, "Applications", "Claude.app"))
}
return destinations
}
func installClaudeDesktopZip(archivePath string, destinations []string, verify func(string) error) (string, error) {
if len(destinations) == 0 {
return "", errors.New("Claude Desktop installation destination is required")
}
if verify == nil {
return "", errors.New("Claude Desktop bundle verifier is required")
}
info, err := os.Stat(archivePath)
if err != nil {
return "", fmt.Errorf("stat Claude Desktop archive: %w", err)
}
if !info.Mode().IsRegular() {
return "", errors.New("Claude Desktop archive is not a regular file")
}
if info.Size() > maxClaudeDesktopArchiveBytes {
return "", fmt.Errorf("Claude Desktop archive exceeds %d bytes", maxClaudeDesktopArchiveBytes)
}
workDir, err := os.MkdirTemp("", "ollama-claude-install-")
if err != nil {
return "", fmt.Errorf("create Claude Desktop installation directory: %w", err)
}
defer os.RemoveAll(workDir)
if err := extractClaudeDesktopZip(archivePath, workDir); err != nil {
return "", err
}
bundlePath := filepath.Join(workDir, "Claude.app")
if err := validateClaudeDesktopBundle(bundlePath); err != nil {
return "", err
}
if err := verify(bundlePath); err != nil {
return "", fmt.Errorf("verify Claude Desktop signature: %w", err)
}
var permissionErr error
for _, destination := range destinations {
if strings.TrimSpace(destination) == "" {
continue
}
if _, err := os.Stat(destination); err == nil {
return "", fmt.Errorf("%w: %s", errClaudeDesktopDestinationExists, destination)
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("check Claude Desktop destination %s: %w", destination, err)
}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
if errors.Is(err, os.ErrPermission) {
permissionErr = err
continue
}
return "", fmt.Errorf("create Claude Desktop destination: %w", err)
}
if err := os.Rename(bundlePath, destination); err != nil {
if errors.Is(err, os.ErrPermission) {
permissionErr = err
continue
}
return "", fmt.Errorf("move Claude Desktop to %s: %w", destination, err)
}
return destination, nil
}
if permissionErr != nil {
return "", fmt.Errorf("install Claude Desktop in Applications: %w", permissionErr)
}
return "", errors.New("Claude Desktop installation destination is required")
}
func extractClaudeDesktopZip(archivePath, destination string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open Claude Desktop archive: %w", err)
}
defer reader.Close()
if len(reader.File) == 0 {
return errors.New("Claude Desktop archive is empty")
}
if len(reader.File) > maxClaudeDesktopArchiveFiles {
return fmt.Errorf("Claude Desktop archive contains more than %d files", maxClaudeDesktopArchiveFiles)
}
var expanded uint64
for _, file := range reader.File {
clean, err := safeClaudeDesktopArchivePath(file.Name)
if err != nil {
return err
}
expanded += file.UncompressedSize64
if expanded > maxClaudeDesktopExtractBytes {
return fmt.Errorf("Claude Desktop archive expands beyond %d bytes", maxClaudeDesktopExtractBytes)
}
path := filepath.Join(destination, filepath.FromSlash(clean))
switch {
case file.FileInfo().IsDir():
if err := os.MkdirAll(path, file.Mode().Perm()); err != nil {
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
}
case file.Mode()&os.ModeSymlink != 0:
target, err := readClaudeDesktopZipFile(file, 16<<10)
if err != nil {
return fmt.Errorf("read Claude Desktop archive symlink: %w", err)
}
if err := validateClaudeDesktopSymlink(clean, string(target)); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
}
if err := os.Symlink(string(target), path); err != nil {
return fmt.Errorf("create Claude Desktop archive symlink: %w", err)
}
case file.Mode().IsRegular():
if err := extractClaudeDesktopZipFile(file, path); err != nil {
return err
}
default:
return fmt.Errorf("Claude Desktop archive contains unsupported file %q", file.Name)
}
}
return nil
}
func safeClaudeDesktopArchivePath(name string) (string, error) {
if strings.ContainsRune(name, '\x00') || filepath.IsAbs(name) {
return "", fmt.Errorf("Claude Desktop archive contains unsafe path %q", name)
}
clean := filepath.ToSlash(filepath.Clean(name))
if clean != "Claude.app" && !strings.HasPrefix(clean, "Claude.app/") {
return "", fmt.Errorf("Claude Desktop archive contains unexpected path %q", name)
}
return clean, nil
}
func validateClaudeDesktopSymlink(name, target string) error {
if target == "" || filepath.IsAbs(target) {
return fmt.Errorf("Claude Desktop archive contains unsafe symlink %q", name)
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(name), target))
resolved = filepath.ToSlash(resolved)
if resolved != "Claude.app" && !strings.HasPrefix(resolved, "Claude.app/") {
return fmt.Errorf("Claude Desktop archive symlink %q escapes Claude.app", name)
}
return nil
}
func extractClaudeDesktopZipFile(file *zip.File, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
}
input, err := file.Open()
if err != nil {
return fmt.Errorf("open Claude Desktop archive file: %w", err)
}
output, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode().Perm())
if err != nil {
input.Close()
return fmt.Errorf("create Claude Desktop archive file: %w", err)
}
_, copyErr := io.Copy(output, input)
inputErr := input.Close()
outputErr := output.Close()
if copyErr != nil {
return fmt.Errorf("extract Claude Desktop archive file: %w", copyErr)
}
if inputErr != nil {
return fmt.Errorf("close Claude Desktop archive file: %w", inputErr)
}
if outputErr != nil {
return fmt.Errorf("close extracted Claude Desktop file: %w", outputErr)
}
return nil
}
func readClaudeDesktopZipFile(file *zip.File, limit int64) ([]byte, error) {
reader, err := file.Open()
if err != nil {
return nil, err
}
defer reader.Close()
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("archive entry exceeds %d bytes", limit)
}
return data, nil
}
func validateClaudeDesktopBundle(bundlePath string) error {
info, err := os.Stat(bundlePath)
if err != nil || !info.IsDir() {
return errors.New("Claude Desktop archive does not contain Claude.app")
}
executable := filepath.Join(bundlePath, "Contents", "MacOS", "Claude")
info, err = os.Stat(executable)
if err != nil {
return fmt.Errorf("Claude Desktop executable is missing: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
return errors.New("Claude Desktop executable is not executable")
}
return nil
}
func verifyClaudeDesktopBundle(bundlePath string) error {
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
}
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
if err != nil {
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
}
details := string(output)
if !strings.Contains(details, "Identifier="+claudeDesktopBundleID) ||
!strings.Contains(details, "TeamIdentifier="+claudeDesktopTeamID) {
return fmt.Errorf("unexpected Claude Desktop signing identity")
}
return nil
}
@@ -0,0 +1,162 @@
//go:build darwin
package main
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallClaudeDesktopZip(t *testing.T) {
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
"Claude.app/": {directory: true},
"Claude.app/Contents/": {directory: true},
"Claude.app/Contents/MacOS/": {directory: true},
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
"Claude.app/Contents/Resources/": {directory: true},
"Claude.app/Contents/Resources/link": {body: "../MacOS/Claude", mode: os.ModeSymlink | 0o777},
})
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
var verified string
installed, err := installClaudeDesktopZip(archive, []string{destination}, func(bundle string) error {
verified = bundle
return nil
})
if err != nil {
t.Fatal(err)
}
if installed != destination || verified == "" {
t.Fatalf("installed = %q, verified = %q", installed, verified)
}
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "Claude"))
if err != nil {
t.Fatal(err)
}
if info.Mode()&0o111 == 0 {
t.Fatal("installed Claude executable is not executable")
}
if target, err := os.Readlink(filepath.Join(installed, "Contents", "Resources", "link")); err != nil || target != "../MacOS/Claude" {
t.Fatalf("symlink target = %q, err = %v", target, err)
}
}
func TestInstallClaudeDesktopZipRejectsUnsafeArchives(t *testing.T) {
for _, test := range []struct {
name string
entries map[string]claudeDesktopTestZipEntry
}{
{name: "path traversal", entries: map[string]claudeDesktopTestZipEntry{"../Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755}}},
{name: "unexpected root", entries: map[string]claudeDesktopTestZipEntry{"README": {body: "nope", mode: 0o644}}},
{name: "escaping symlink", entries: map[string]claudeDesktopTestZipEntry{
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
"Claude.app/escape": {body: "../../outside", mode: os.ModeSymlink | 0o777},
}},
} {
t.Run(test.name, func(t *testing.T) {
archive := writeClaudeDesktopTestZip(t, test.entries)
destination := filepath.Join(t.TempDir(), "Claude.app")
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); err == nil {
t.Fatal("installClaudeDesktopZip succeeded")
}
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unsafe archive created destination: %v", err)
}
})
}
}
func TestInstallClaudeDesktopZipVerifiesBeforeMove(t *testing.T) {
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
})
destination := filepath.Join(t.TempDir(), "Claude.app")
wantErr := errors.New("invalid signature")
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid bundle created destination: %v", err)
}
}
func TestInstallClaudeDesktopZipDoesNotOverwrite(t *testing.T) {
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
})
destination := filepath.Join(t.TempDir(), "Claude.app")
if err := os.MkdirAll(destination, 0o755); err != nil {
t.Fatal(err)
}
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); !errors.Is(err, errClaudeDesktopDestinationExists) {
t.Fatalf("error = %v, want destination exists", err)
}
}
func TestInstallClaudeDesktopZipRealArchive(t *testing.T) {
archive := os.Getenv("OLLAMA_TEST_CLAUDE_DESKTOP_ZIP")
if archive == "" {
t.Skip("set OLLAMA_TEST_CLAUDE_DESKTOP_ZIP to a downloaded Claude Desktop ZIP")
}
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
installed, err := installClaudeDesktopZip(
archive,
[]string{destination},
verifyClaudeDesktopBundle,
)
if err != nil {
t.Fatal(err)
}
if installed != destination {
t.Fatalf("installed = %q, want %q", installed, destination)
}
}
type claudeDesktopTestZipEntry struct {
body string
mode os.FileMode
directory bool
}
func writeClaudeDesktopTestZip(t *testing.T, entries map[string]claudeDesktopTestZipEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "Claude.zip")
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
writer := zip.NewWriter(file)
for name, entry := range entries {
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
if entry.directory {
header.SetMode(os.ModeDir | 0o755)
} else {
header.SetMode(entry.mode)
}
item, err := writer.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := item.Write([]byte(entry.body)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
return path
}
func TestSafeClaudeDesktopArchivePath(t *testing.T) {
for _, name := range []string{"Claude.app", "Claude.app/Contents/MacOS/Claude"} {
if got, err := safeClaudeDesktopArchivePath(name); err != nil || got != strings.TrimSuffix(name, "/") {
t.Fatalf("safeClaudeDesktopArchivePath(%q) = %q, %v", name, got, err)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
//go:build darwin
package main
import "github.com/ollama/ollama/internal/proxy"
type claudeDesktopInstallResult string
const (
claudeDesktopInstallCancelled claudeDesktopInstallResult = "cancelled"
claudeDesktopInstallerOpened claudeDesktopInstallResult = "opened"
claudeDesktopInstallFailed claudeDesktopInstallResult = "failed"
)
type claudeDesktopStatus struct {
Supported bool `json:"supported"`
Used bool `json:"used"`
Installed bool `json:"installed"`
Configured bool `json:"configured"`
Connected bool `json:"connected"`
Running bool `json:"running"`
StartFailed bool `json:"startFailed"`
PortConflict bool `json:"portConflict"`
GatewayPort int `json:"gatewayPort,omitempty"`
RoutedRequests uint64 `json:"routedRequests"`
Error string `json:"error,omitempty"`
AutoMode bool `json:"autoMode"`
ModelSource string `json:"modelSource,omitempty"`
Models []claudeDesktopModelStatus `json:"models,omitempty"`
Mappings []claudeDesktopMappingStatus `json:"mappings,omitempty"`
}
type claudeDesktopMappingStatus struct {
RouteID string `json:"routeId"`
RouteName string `json:"routeName"`
Model string `json:"model,omitempty"`
}
type claudeDesktopModelStatus struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description,omitempty"`
Cloud bool `json:"cloud"`
Selected bool `json:"selected"`
AutoMode bool `json:"autoMode"`
Availability proxy.ClaudeDesktopAvailability `json:"availability"`
Reason proxy.ClaudeDesktopAccessReason `json:"reason,omitempty"`
RequiredPlan string `json:"requiredPlan,omitempty"`
}
type claudeDesktopActionResult struct {
Status claudeDesktopStatus `json:"status"`
Error string `json:"error,omitempty"`
MappingsApplied bool `json:"mappingsApplied,omitempty"`
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
}
+75
View File
@@ -0,0 +1,75 @@
//go:build darwin
package main
import (
"errors"
"log/slog"
"github.com/ollama/ollama/app/webview"
)
func codexDesktopModelRefreshError(settings codexDesktopModelsSettings) string {
if len(settings.Selected) > 0 {
return "Couldnt refresh available models. Your saved models are unchanged."
}
return "Couldnt refresh available models. Try again."
}
func bindCodexDesktop(wv webview.WebView) {
wv.Bind("getCodexDesktopStatus", func() codexDesktopStatus {
return getCodexDesktopStatus()
})
wv.Bind("getCodexDesktopRequestCount", func() uint64 {
return codexDesktop.OllamaRequestCount()
})
wv.Bind("setCodexDesktopConnected", func(enabled, restartConfirmed bool) codexDesktopActionResult {
err := setCodexDesktopConnection(enabled, restartConfirmed)
result := codexDesktopActionResult{Status: getCodexDesktopStatus()}
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
result.RestartConfirmationRequired = true
} else if err != nil {
result.Error = err.Error()
slog.Warn("failed to change ChatGPT integration from Settings", "connected", enabled, "error", err)
}
return result
})
wv.Bind("installCodexDesktop", func() codexDesktopInstallResult {
return requestCodexDesktopInstall()
})
wv.Bind("getCodexDesktopModelsSettings", func() codexDesktopModelsSettingsResult {
settings, err := getCodexDesktopModelsSettings()
result := codexDesktopModelsSettingsResult{Settings: settings}
if err != nil {
result.Warning = codexDesktopModelRefreshError(settings)
slog.Warn("failed to refresh available ChatGPT models", "error", err)
}
return result
})
wv.Bind("applyCodexDesktopModels", func(models []string, restartConfirmed bool) codexDesktopModelsSettingsResult {
err := applyCodexDesktopModels(models, restartConfirmed)
settings, statusErr := getCodexDesktopModelsSettings()
result := codexDesktopModelsSettingsResult{Settings: settings}
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
result.RestartConfirmationRequired = true
} else if err != nil {
result.Error = err.Error()
} else if statusErr != nil {
result.Warning = codexDesktopModelRefreshError(settings)
slog.Warn("failed to refresh available ChatGPT models after applying settings", "error", statusErr)
}
return result
})
wv.Bind("resetCodexDesktopModels", func() codexDesktopModelsSettingsResult {
err := resetCodexDesktopModels()
settings, statusErr := getCodexDesktopModelsSettings()
result := codexDesktopModelsSettingsResult{Settings: settings}
if err != nil {
result.Error = err.Error()
} else if statusErr != nil {
result.Warning = codexDesktopModelRefreshError(settings)
slog.Warn("failed to refresh available ChatGPT models after resetting settings", "error", statusErr)
}
return result
})
}
@@ -0,0 +1,7 @@
//go:build windows
package main
import "github.com/ollama/ollama/app/webview"
func bindCodexDesktop(_ webview.WebView) {}
+979
View File
@@ -0,0 +1,979 @@
//go:build darwin
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/ollama/ollama/api"
appui "github.com/ollama/ollama/app/ui"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/launch"
"github.com/ollama/ollama/internal/modelref"
"github.com/ollama/ollama/internal/proxy"
modelpkg "github.com/ollama/ollama/types/model"
)
const (
codexDesktopIntegrationName = "chatgpt"
codexDesktopMaxModels = 5
codexDesktopRecommendationsMaxBody = 1 << 20
)
var errCodexDesktopRestartConfirmationRequired = launch.ErrCodexAppRestartConfirmationRequired
type codexDesktopController interface {
Installed() bool
OllamaConfigured() bool
Running() bool
OllamaRequestCount() uint64
UseOllamaFromDesktop(string, []launch.LaunchModel, bool) error
UpdateOllamaModelsFromDesktop(string, []launch.LaunchModel, bool) error
RestoreFromDesktop(bool) error
RestartFromDesktop(bool) error
Onboard() error
}
var (
codexDesktop codexDesktopController = &launch.CodexApp{}
codexDesktopClientFactory = api.ClientFromEnvironment
codexDesktopLoadModels = loadCodexDesktopModels
codexDesktopLoadConnectionModels = loadCodexDesktopConnectionModels
codexDesktopCloudModels = loadCodexDesktopAccountCloudModels
codexDesktopRecommendations = loadCodexDesktopRecommendations
codexDesktopAccessState = currentClaudeDesktopAccessState
codexDesktopRecommendationsClient = &http.Client{Timeout: 3 * time.Second}
codexDesktopRecommendationsEndpoint = func() string {
return strings.TrimRight(appui.OllamaDotCom, "/") + "/api/experimental/model-recommendations?app=codex-desktop"
}
codexDesktopModelLoadAttempts = 20
codexDesktopModelRetryWait = 250 * time.Millisecond
codexDesktopMu sync.Mutex
)
type codexDesktopStatus struct {
Supported bool `json:"supported"`
Installed bool `json:"installed"`
Connected bool `json:"connected"`
Running bool `json:"running"`
Model string `json:"model,omitempty"`
Models []string `json:"models,omitempty"`
MaxModels int `json:"maxModels"`
Requests uint64 `json:"requests"`
}
type codexDesktopActionResult struct {
Status codexDesktopStatus `json:"status"`
Error string `json:"error,omitempty"`
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
}
type codexDesktopInstallResult string
const (
codexDesktopInstallCancelled codexDesktopInstallResult = "cancelled"
codexDesktopInstallerOpened codexDesktopInstallResult = "opened"
codexDesktopInstallFailed codexDesktopInstallResult = "failed"
)
type codexDesktopModelsSettings struct {
Supported bool `json:"supported"`
Installed bool `json:"installed"`
Connected bool `json:"connected"`
Running bool `json:"running"`
// UsesDefaults keeps recommendations implicit without overwriting saved choices.
UsesDefaults bool `json:"usesDefaults"`
Selected []string `json:"selected"`
Available []string `json:"available"`
Models []codexDesktopModelStatus `json:"models"`
MaxModels int `json:"maxModels"`
}
type codexDesktopModelStatus struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description,omitempty"`
Recommended bool `json:"recommended,omitempty"`
Selected bool `json:"selected"`
Availability string `json:"availability"`
Reason string `json:"reason,omitempty"`
RequiredPlan string `json:"requiredPlan,omitempty"`
}
type codexDesktopModelsSettingsResult struct {
Settings codexDesktopModelsSettings `json:"settings"`
Error string `json:"error,omitempty"`
Warning string `json:"warning,omitempty"`
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
}
type codexDesktopModelInventory struct {
Available []launch.LaunchModel
Catalog []codexDesktopCatalogModel
Defaults []launch.LaunchModel
DefaultPrimary string
}
type codexDesktopCatalogModel struct {
Model launch.LaunchModel
DisplayName string
Description string
Recommended bool
Availability proxy.ClaudeDesktopAvailability
Reason proxy.ClaudeDesktopAccessReason
RequiredPlan string
}
func getCodexDesktopStatus() codexDesktopStatus {
connected := codexDesktop.OllamaConfigured()
requests := uint64(0)
if connected {
requests = codexDesktop.OllamaRequestCount()
}
var models []string
if saved, err := config.LoadIntegration(codexDesktopIntegrationName); err == nil && len(saved.Models) > 0 {
models = append([]string(nil), saved.Models...)
}
model := ""
if len(models) > 0 {
model = models[0]
}
return codexDesktopStatus{
Supported: true,
Installed: codexDesktop.Installed(),
Connected: connected,
Running: codexDesktop.Running(),
Model: model,
Models: models,
MaxModels: codexDesktopMaxModels,
Requests: requests,
}
}
func setCodexDesktopConnection(enabled, restartConfirmed bool) error {
codexDesktopMu.Lock()
defer codexDesktopMu.Unlock()
if enabled == codexDesktop.OllamaConfigured() {
return nil
}
if !enabled {
if codexDesktop.Running() && !restartConfirmed {
return errCodexDesktopRestartConfirmationRequired
}
return codexDesktop.RestoreFromDesktop(restartConfirmed)
}
if !codexDesktop.Installed() {
return errors.New("ChatGPT is not installed")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
savedSelection := config.IntegrationModels(codexDesktopIntegrationName)
primary, models, err := codexDesktopLoadConnectionModels(ctx, savedSelection)
if err != nil {
return err
}
// Validate before requesting a restart; do not change the profile without consent.
if codexDesktop.Running() && !restartConfirmed {
return errCodexDesktopRestartConfirmationRequired
}
previous := config.IntegrationModels(codexDesktopIntegrationName)
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
return fmt.Errorf("save ChatGPT integration: %w", err)
}
if err := codexDesktop.Onboard(); err != nil {
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
return fmt.Errorf("save ChatGPT integration state: %w", err)
}
if err := codexDesktop.UseOllamaFromDesktop(primary, models, restartConfirmed); err != nil {
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
return err
}
if codexDesktop.OllamaConfigured() {
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr))
}
}
return err
}
return nil
}
func getCodexDesktopModelsSettings() (codexDesktopModelsSettings, error) {
settings := codexDesktopModelsSettings{
Supported: true,
Installed: codexDesktop.Installed(),
Connected: codexDesktop.OllamaConfigured(),
Running: codexDesktop.Running(),
Selected: []string{},
Available: []string{},
Models: []codexDesktopModelStatus{},
MaxModels: codexDesktopMaxModels,
}
// Keep restart available when inventory cannot be loaded.
settings.Selected = config.IntegrationModels(codexDesktopIntegrationName)
settings.UsesDefaults = len(settings.Selected) == 0
if len(settings.Selected) > codexDesktopMaxModels {
settings.Selected = settings.Selected[:codexDesktopMaxModels]
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
inventory, err := loadCodexDesktopModelInventory(ctx)
if err != nil {
return settings, err
}
settings.Available = codexDesktopModelNames(inventory.Available)
if len(settings.Selected) == 0 {
settings.Selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory))
}
settings.Models = codexDesktopModelStatuses(inventory, settings.Selected)
return settings, nil
}
func applyCodexDesktopModels(selected []string, restartConfirmed bool) error {
codexDesktopMu.Lock()
defer codexDesktopMu.Unlock()
return applyCodexDesktopModelsLocked(selected, restartConfirmed, true)
}
func resetCodexDesktopModels() error {
codexDesktopMu.Lock()
defer codexDesktopMu.Unlock()
// Reset preferences without enabling the integration or restarting ChatGPT.
if len(config.IntegrationModels(codexDesktopIntegrationName)) == 0 && !codexDesktop.OllamaConfigured() {
return nil
}
if err := config.SaveIntegration(codexDesktopIntegrationName, nil); err != nil {
return fmt.Errorf("reset ChatGPT models: %w", err)
}
return nil
}
func applyCodexDesktopModelsLocked(selected []string, restartConfirmed, openWhenStopped bool) error {
previous := config.IntegrationModels(codexDesktopIntegrationName)
savedSelection := append([]string(nil), selected...)
wasConfigured := codexDesktop.OllamaConfigured()
selectionUnchanged := slices.Equal(savedSelection, previous)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
primary, models, err := codexDesktopLoadModels(ctx, selected)
if err != nil {
if openWhenStopped && wasConfigured && selectionUnchanged {
return codexDesktop.RestartFromDesktop(restartConfirmed)
}
return err
}
if !wasConfigured && !openWhenStopped {
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
return fmt.Errorf("save ChatGPT models: %w", err)
}
return nil
}
running := codexDesktop.Running()
if running && !restartConfirmed {
return errCodexDesktopRestartConfirmationRequired
}
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
return fmt.Errorf("save ChatGPT models: %w", err)
}
updateModels := codexDesktop.UseOllamaFromDesktop
if !openWhenStopped {
updateModels = codexDesktop.UpdateOllamaModelsFromDesktop
}
if err := updateModels(primary, models, restartConfirmed); err == nil {
return nil
} else if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
return err
} else if !wasConfigured {
if codexDesktop.OllamaConfigured() {
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr))
}
}
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
return fmt.Errorf("start ChatGPT with selected Ollama models: %w", err)
} else {
applyErr := err
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer rollbackCancel()
rollbackPrimary, rollbackModels, rollbackErr := codexDesktopLoadModels(rollbackCtx, previous)
if rollbackErr == nil {
rollbackErr = updateModels(rollbackPrimary, rollbackModels, true)
}
if rollbackErr != nil {
// Restore the original profile if the previous selection is no longer usable.
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
return errors.Join(
fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %w", applyErr, rollbackErr),
fmt.Errorf("restore normal ChatGPT profile: %w", restoreErr),
)
}
return fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %v; restored the normal ChatGPT profile", applyErr, rollbackErr)
}
return fmt.Errorf("apply ChatGPT models: %w", applyErr)
}
}
func loadCodexDesktopModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) {
inventory, err := loadCodexDesktopModelInventory(ctx)
if err != nil {
return "", nil, err
}
if len(selected) == 0 {
selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory))
}
_, models, err := selectCodexDesktopModels(selected, inventory.Available)
if err != nil {
return "", nil, err
}
primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models)
return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil
}
func loadCodexDesktopConnectionModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) {
inventory, err := loadCodexDesktopModelInventory(ctx)
if err != nil {
return "", nil, err
}
defaults := codexDesktopDefaultModels(inventory)
if len(selected) == 0 {
selected = codexDesktopModelNames(defaults)
}
_, models, err := reconcileCodexDesktopModels(selected, inventory.Available, defaults)
if err != nil {
return "", nil, err
}
primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models)
return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil
}
// /api/show supplies capabilities and family metadata without replacing recommended thinking controls.
func hydrateCodexDesktopModelCapabilities(ctx context.Context, models []launch.LaunchModel) []launch.LaunchModel {
client, err := codexDesktopClientFactory()
if err != nil {
return models
}
hydrated := append([]launch.LaunchModel(nil), models...)
for i := range hydrated {
response, err := client.Show(ctx, &api.ShowRequest{Model: hydrated[i].Name})
if err != nil {
continue
}
if len(response.Capabilities) > 0 {
hydrated[i].Capabilities = append([]modelpkg.Capability(nil), response.Capabilities...)
}
if response.Details.Family != "" || len(response.Details.Families) > 0 {
hydrated[i].Details = response.Details
}
}
return hydrated
}
func loadCodexDesktopAvailableModels(ctx context.Context) ([]launch.LaunchModel, error) {
inventory, err := loadCodexDesktopModelInventory(ctx)
return inventory.Available, err
}
func loadCodexDesktopModelInventory(ctx context.Context) (codexDesktopModelInventory, error) {
client, err := codexDesktopClientFactory()
if err != nil {
return codexDesktopModelInventory{}, err
}
recommendations, recommendationsErr := codexDesktopRecommendations(ctx)
if recommendationsErr != nil {
slog.Debug("could not load ChatGPT model recommendations", "error", recommendationsErr)
}
var access proxy.ClaudeDesktopAccessState
accessKnown := false
var last codexDesktopModelInventory
for attempt := range codexDesktopModelLoadAttempts {
if !accessKnown {
resolved, accessErr := codexDesktopAccessState(ctx)
if accessErr == nil {
access = resolved
accessKnown = true
} else {
slog.Debug("could not determine ChatGPT model access", "error", accessErr)
}
}
var listed []api.ListModelResponse
listKnown := false
if response, listErr := client.List(ctx); listErr == nil {
listed = response.Models
listKnown = true
}
var accountCloud []string
cloudKnown := false
if names, cloudErr := codexDesktopCloudModels(ctx); cloudErr == nil {
accountCloud = names
cloudKnown = true
}
last = buildCodexDesktopModelInventory(recommendations, listed, accountCloud, access, accessKnown, listKnown, cloudKnown)
// Retry access lookup failures even when recommendations are available.
if len(last.Available) > 0 && (accessKnown || attempt+1 == codexDesktopModelLoadAttempts) {
return last, nil
}
if attempt+1 == codexDesktopModelLoadAttempts {
break
}
timer := time.NewTimer(codexDesktopModelRetryWait)
select {
case <-ctx.Done():
timer.Stop()
return codexDesktopModelInventory{}, ctx.Err()
case <-timer.C:
}
}
if len(last.Catalog) > 0 {
return last, nil
}
return codexDesktopModelInventory{}, errors.New("no Ollama models are available for ChatGPT")
}
func loadCodexDesktopRecommendations(ctx context.Context) ([]api.ModelRecommendation, error) {
req, err := newSignedOllamaRequest(ctx, http.MethodGet, codexDesktopRecommendationsEndpoint())
if err != nil {
return nil, fmt.Errorf("prepare ChatGPT model recommendations request: %w", err)
}
resp, err := codexDesktopRecommendationsClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch ChatGPT model recommendations: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody))
return nil, fmt.Errorf("fetch ChatGPT model recommendations: status %d", resp.StatusCode)
}
var payload api.ModelRecommendationsResponse
decoder := json.NewDecoder(io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody+1))
if err := decoder.Decode(&payload); err != nil {
return nil, fmt.Errorf("decode ChatGPT model recommendations: %w", err)
}
if len(payload.Recommendations) == 0 {
return nil, errors.New("ChatGPT model recommendations are empty")
}
return payload.Recommendations, nil
}
func loadCodexDesktopAccountCloudModels(ctx context.Context) ([]string, error) {
models, err := currentClaudeDesktopCloudModels(ctx)
if err != nil {
return nil, err
}
names := make([]string, 0, len(models))
for _, model := range models {
name := strings.TrimSpace(model.OllamaModel)
if name == "" {
name = strings.TrimSpace(model.Name)
}
if name != "" {
names = append(names, name)
}
}
return names, nil
}
func buildCodexDesktopModelInventory(
recommendations []api.ModelRecommendation,
listed []api.ListModelResponse,
accountCloud []string,
access proxy.ClaudeDesktopAccessState,
accessKnown, localInventoryKnown, cloudInventoryKnown bool,
) codexDesktopModelInventory {
actual := codexDesktopAvailableModels(listed, accountCloud)
actualByName := make(map[string]launch.LaunchModel, len(actual))
for _, model := range actual {
actualByName[codexDesktopModelKey(model.Name)] = model
}
seen := make(map[string]bool, len(actual)+len(recommendations))
recommended := make([]codexDesktopCatalogModel, 0, len(recommendations))
for _, recommendation := range recommendations {
route := codexDesktopRecommendationRoute(recommendation)
key := codexDesktopModelKey(route)
if key == "" || seen[key] {
continue
}
seen[key] = true
model, present := actualByName[key]
if !present {
model = launch.LaunchModel{Name: route, Remote: codexDesktopCloudModel(route)}
}
if recommendation.ContextLength > 0 {
model.ContextLength = recommendation.ContextLength
}
if recommendation.MaxOutputTokens > 0 {
model.MaxOutputTokens = recommendation.MaxOutputTokens
}
if recommendation.Thinking != nil {
model.Thinking = recommendation.Thinking.Clone()
}
availability, reason := codexDesktopRecommendationAccess(
model,
present,
strings.TrimSpace(recommendation.RequiredPlan),
access,
accessKnown,
localInventoryKnown,
)
entry := codexDesktopCatalogModel{
Model: model,
DisplayName: strings.TrimSpace(recommendation.Model),
Description: strings.TrimSpace(recommendation.Description),
Recommended: true,
Availability: availability,
Reason: reason,
RequiredPlan: strings.TrimSpace(recommendation.RequiredPlan),
}
recommended = append(recommended, entry)
}
extras := make([]codexDesktopCatalogModel, 0, len(actual))
for _, model := range actual {
key := codexDesktopModelKey(model.Name)
if key == "" || seen[key] {
continue
}
seen[key] = true
availability, reason := codexDesktopInventoryModelAccess(model, access, accessKnown, cloudInventoryKnown)
extras = append(extras, codexDesktopCatalogModel{
Model: model,
DisplayName: model.Name,
Availability: availability,
Reason: reason,
})
}
// Sort recommendations only; preserve saved list order.
slices.SortStableFunc(recommended, func(a, b codexDesktopCatalogModel) int {
return codexDesktopRecommendationPriority(a.Model.Name) - codexDesktopRecommendationPriority(b.Model.Name)
})
catalog := make([]codexDesktopCatalogModel, 0, len(recommended)+len(extras))
catalog = append(catalog, recommended...)
catalog = append(catalog, extras...)
available := make([]launch.LaunchModel, 0, len(catalog))
for _, entry := range catalog {
// Recommendations remain configurable regardless of current availability.
if entry.Recommended || entry.Availability == proxy.ClaudeDesktopAvailabilityAvailable {
available = append(available, entry.Model)
}
}
defaults := codexDesktopRecommendationDefaults(catalog)
return codexDesktopModelInventory{
Available: available,
Catalog: catalog,
Defaults: defaults,
DefaultPrimary: codexDesktopDefaultPrimary(catalog, defaults, access, accessKnown),
}
}
func codexDesktopRecommendationPriority(name string) int {
switch codexDesktopModelKey(name) {
case "kimi-k3:cloud":
return 0
case "glm-5.3:cloud":
return 1
case "glm-5.3-flash:cloud":
return 2
case "deepseek-v4-flash:cloud":
return 3
case "gemma4:31b:cloud":
return 4
default:
return 5
}
}
func codexDesktopRecommendationRoute(recommendation api.ModelRecommendation) string {
name := strings.TrimSpace(recommendation.Model)
if name != "" && recommendation.RequiredPlan != "" && !modelref.HasExplicitCloudSource(name) {
name += ":cloud"
}
return name
}
func codexDesktopRecommendationAccess(
model launch.LaunchModel,
present bool,
requiredPlan string,
access proxy.ClaudeDesktopAccessState,
accessKnown, localInventoryKnown bool,
) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) {
if !model.Remote {
if !localInventoryKnown {
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
}
if present {
return proxy.ClaudeDesktopAvailabilityAvailable, ""
}
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessModelNotInstalled
}
if !accessKnown {
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
}
if access.Cloud == proxy.ClaudeDesktopCloudOff {
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff
}
if access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown {
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
}
if access.Account == proxy.ClaudeDesktopAccountSignedOut {
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired
}
if !codexDesktopPlanSatisfies(access.Plan, requiredPlan) {
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessUpgradeRequired
}
// Recommended cloud models need not appear in /api/tags.
return proxy.ClaudeDesktopAvailabilityAvailable, ""
}
func codexDesktopInventoryModelAccess(
model launch.LaunchModel,
access proxy.ClaudeDesktopAccessState,
accessKnown, cloudInventoryKnown bool,
) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) {
if !model.Remote {
return proxy.ClaudeDesktopAvailabilityAvailable, ""
}
if !accessKnown {
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
}
if access.Cloud == proxy.ClaudeDesktopCloudOff {
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff
}
if !cloudInventoryKnown || access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown {
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
}
if access.Account == proxy.ClaudeDesktopAccountSignedOut {
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired
}
return proxy.ClaudeDesktopAvailabilityAvailable, ""
}
func codexDesktopPlanSatisfies(plan, required string) bool {
plan = strings.ToLower(strings.TrimSpace(plan))
required = strings.ToLower(strings.TrimSpace(required))
if required == "" || required == "free" {
return true
}
return plan != "" && plan != "free"
}
func codexDesktopRecommendationDefaults(catalog []codexDesktopCatalogModel) []launch.LaunchModel {
defaults := make([]launch.LaunchModel, 0, codexDesktopMaxModels)
for _, entry := range catalog {
if !entry.Recommended {
continue
}
defaults = append(defaults, entry.Model)
if len(defaults) == codexDesktopMaxModels {
return defaults
}
}
if len(defaults) > 0 {
return defaults
}
for _, entry := range catalog {
if entry.Availability != proxy.ClaudeDesktopAvailabilityAvailable {
continue
}
defaults = append(defaults, entry.Model)
if len(defaults) == codexDesktopMaxModels {
break
}
}
return defaults
}
func codexDesktopDefaultPrimary(
catalog []codexDesktopCatalogModel,
defaults []launch.LaunchModel,
access proxy.ClaudeDesktopAccessState,
accessKnown bool,
) string {
if len(defaults) == 0 {
return ""
}
// Choose the starting model independently of picker order.
if accessKnown && access.Account == proxy.ClaudeDesktopAccountSignedIn && codexDesktopPlanSatisfies(access.Plan, "pro") {
return codexDesktopPreferredPrimary("glm-5.3-flash:cloud", defaults)
}
for _, entry := range catalog {
if !entry.Recommended {
continue
}
required := strings.ToLower(strings.TrimSpace(entry.RequiredPlan))
if required == "" || required == "free" {
return entry.Model.Name
}
}
return defaults[0].Name
}
func codexDesktopPreferredPrimary(preferred string, models []launch.LaunchModel) string {
preferredKey := codexDesktopModelKey(preferred)
for _, model := range models {
if preferredKey != "" && codexDesktopModelKey(model.Name) == preferredKey {
return model.Name
}
}
if len(models) > 0 {
return models[0].Name
}
return ""
}
func codexDesktopModelStatuses(inventory codexDesktopModelInventory, selected []string) []codexDesktopModelStatus {
selectedSet := make(map[string]bool, len(selected))
for _, name := range selected {
selectedSet[codexDesktopModelKey(name)] = true
}
statuses := make([]codexDesktopModelStatus, 0, len(inventory.Catalog)+len(selected))
seen := make(map[string]bool, cap(statuses))
for _, entry := range inventory.Catalog {
key := codexDesktopModelKey(entry.Model.Name)
seen[key] = true
displayName := entry.DisplayName
if displayName == "" {
displayName = entry.Model.Name
}
statuses = append(statuses, codexDesktopModelStatus{
Name: entry.Model.Name,
DisplayName: displayName,
Description: entry.Description,
Recommended: entry.Recommended,
Selected: selectedSet[key],
Availability: string(entry.Availability),
Reason: string(entry.Reason),
RequiredPlan: entry.RequiredPlan,
})
}
for _, name := range selected {
key := codexDesktopModelKey(name)
if key == "" || seen[key] {
continue
}
seen[key] = true
statuses = append(statuses, codexDesktopModelStatus{
Name: name,
DisplayName: name,
Selected: true,
Availability: string(proxy.ClaudeDesktopAvailabilityUnknown),
Reason: string(proxy.ClaudeDesktopAccessVerificationUnavailable),
})
}
return statuses
}
func buildCodexDesktopModels(selected []string, listed []api.ListModelResponse, accountCloud []string) (string, []launch.LaunchModel, error) {
available := codexDesktopAvailableModels(listed, accountCloud)
return selectCodexDesktopModels(selected, available)
}
func codexDesktopAvailableModels(listed []api.ListModelResponse, accountCloud []string) []launch.LaunchModel {
installed := make(map[string]api.ListModelResponse, len(listed))
for _, model := range listed {
for _, name := range []string{model.Name, model.Model} {
if key := codexDesktopModelKey(name); key != "" {
installed[key] = model
}
}
}
accountCloudSet := make(map[string]bool, len(accountCloud))
for _, name := range accountCloud {
if key := codexDesktopModelKey(name); key != "" {
accountCloudSet[key] = true
}
}
models := make([]launch.LaunchModel, 0, len(listed)+len(accountCloud))
seen := make(map[string]bool, cap(models))
add := func(model launch.LaunchModel) {
model.Name = strings.TrimSpace(model.Name)
key := codexDesktopModelKey(model.Name)
if key == "" || seen[key] {
return
}
seen[key] = true
models = append(models, model)
}
for _, model := range listed {
if codexDesktopListedModelIsCloud(model) && !accountCloudSet[codexDesktopModelKey(model.Name)] && !accountCloudSet[codexDesktopModelKey(model.Model)] {
continue
}
add(codexDesktopLaunchModel(model))
}
for _, name := range accountCloud {
key := codexDesktopModelKey(name)
if listedModel, ok := installed[key]; ok {
add(codexDesktopLaunchModel(listedModel))
continue
}
add(launch.LaunchModel{Name: strings.TrimSpace(name), Remote: true})
}
return models
}
func codexDesktopDefaultModels(inventory codexDesktopModelInventory) []launch.LaunchModel {
if inventory.Catalog != nil || inventory.Defaults != nil {
return append([]launch.LaunchModel(nil), inventory.Defaults...)
}
return append([]launch.LaunchModel(nil), inventory.Available[:min(len(inventory.Available), codexDesktopMaxModels)]...)
}
func codexDesktopListedModelIsCloud(model api.ListModelResponse) bool {
return model.RemoteModel != "" || model.RemoteHost != "" ||
codexDesktopCloudModel(model.Name) ||
codexDesktopCloudModel(model.Model)
}
func selectCodexDesktopModels(selected []string, available []launch.LaunchModel) (string, []launch.LaunchModel, error) {
byName := make(map[string]launch.LaunchModel, len(available))
for _, model := range available {
byName[codexDesktopModelKey(model.Name)] = model
}
if len(selected) > codexDesktopMaxModels {
return "", nil, fmt.Errorf("choose up to %d models for ChatGPT", codexDesktopMaxModels)
}
resolved := make([]launch.LaunchModel, 0, codexDesktopMaxModels)
seen := make(map[string]bool, codexDesktopMaxModels)
for _, name := range selected {
name = strings.TrimSpace(name)
key := codexDesktopModelKey(name)
if key == "" || seen[key] {
continue
}
model, ok := byName[key]
if !ok {
return "", nil, fmt.Errorf("ChatGPT model %q is not available", name)
}
seen[key] = true
resolved = append(resolved, model)
}
if len(selected) == 0 {
for _, model := range available {
if len(resolved) == codexDesktopMaxModels {
break
}
key := codexDesktopModelKey(model.Name)
if key == "" || seen[key] {
continue
}
seen[key] = true
resolved = append(resolved, model)
}
}
if len(resolved) == 0 {
return "", nil, errors.New("choose at least one available Ollama model for ChatGPT")
}
return resolved[0].Name, resolved, nil
}
// Reopening tolerates stale selections; explicit Settings changes use strict validation.
func reconcileCodexDesktopModels(selected []string, available, defaults []launch.LaunchModel) (string, []launch.LaunchModel, error) {
if len(selected) == 0 {
if len(defaults) > 0 {
return selectCodexDesktopModels(codexDesktopModelNames(defaults), available)
}
return selectCodexDesktopModels(nil, available)
}
byName := make(map[string]launch.LaunchModel, len(available))
for _, model := range available {
byName[codexDesktopModelKey(model.Name)] = model
}
resolved := make([]launch.LaunchModel, 0, min(len(selected), codexDesktopMaxModels))
seen := make(map[string]bool, codexDesktopMaxModels)
for _, name := range selected {
key := codexDesktopModelKey(name)
model, ok := byName[key]
if key == "" || !ok || seen[key] {
continue
}
seen[key] = true
resolved = append(resolved, model)
if len(resolved) == codexDesktopMaxModels {
break
}
}
if len(resolved) == 0 {
if len(defaults) > 0 {
return selectCodexDesktopModels(codexDesktopModelNames(defaults), available)
}
return selectCodexDesktopModels(nil, available)
}
return resolved[0].Name, resolved, nil
}
func codexDesktopModelNames(models []launch.LaunchModel) []string {
names := make([]string, 0, len(models))
for _, model := range models {
if name := strings.TrimSpace(model.Name); name != "" {
names = append(names, name)
}
}
return names
}
func codexDesktopLaunchModel(model api.ListModelResponse) launch.LaunchModel {
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(model.Model)
}
return launch.LaunchModel{
Name: name,
Remote: model.RemoteModel != "" || model.RemoteHost != "" || codexDesktopCloudModel(name),
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
ContextLength: model.Details.ContextLength,
EmbeddingLength: model.Details.EmbeddingLength,
Size: model.Size,
Details: model.Details,
}
}
func codexDesktopModelKey(name string) string {
name = strings.TrimSpace(name)
parsed, err := modelref.ParseRef(name)
if err != nil {
return strings.TrimSuffix(name, ":latest")
}
base := strings.TrimSuffix(strings.TrimSpace(parsed.Base), ":latest")
if parsed.Source == modelref.ModelSourceCloud {
return base + ":cloud"
}
return base
}
func codexDesktopCloudModel(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud")
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,212 @@
//go:build darwin
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
maxCodexDesktopDiskImageBytes = 2 << 30
codexDesktopBundleID = "com.openai.codex"
codexDesktopTeamID = "2DC432GLL2"
)
var errCodexDesktopDestinationExists = errors.New("ChatGPT installation destination already exists")
func codexDesktopInstallDestinations() []string {
destinations := []string{"/Applications/ChatGPT.app"}
if home, err := os.UserHomeDir(); err == nil {
destinations = append(destinations, filepath.Join(home, "Applications", "ChatGPT.app"))
}
return destinations
}
func installCodexDesktopDiskImage(imagePath string, destinations []string, verify func(string) error) (installedPath string, err error) {
if len(destinations) == 0 {
return "", errors.New("ChatGPT installation destination is required")
}
if verify == nil {
return "", errors.New("ChatGPT bundle verifier is required")
}
info, err := os.Stat(imagePath)
if err != nil {
return "", fmt.Errorf("stat ChatGPT disk image: %w", err)
}
if !info.Mode().IsRegular() {
return "", errors.New("ChatGPT disk image is not a regular file")
}
if info.Size() > maxCodexDesktopDiskImageBytes {
return "", fmt.Errorf("ChatGPT disk image exceeds %d bytes", maxCodexDesktopDiskImageBytes)
}
workDir, err := os.MkdirTemp("", "ollama-chatgpt-install-")
if err != nil {
return "", fmt.Errorf("create ChatGPT installation directory: %w", err)
}
defer os.RemoveAll(workDir)
mountPath := filepath.Join(workDir, "volume")
if err := os.Mkdir(mountPath, 0o700); err != nil {
return "", fmt.Errorf("create ChatGPT mount point: %w", err)
}
output, err := exec.Command(
"/usr/bin/hdiutil",
"attach",
"-nobrowse",
"-readonly",
"-mountpoint",
mountPath,
imagePath,
).CombinedOutput()
if err != nil {
return "", fmt.Errorf("mount ChatGPT disk image: %w: %s", err, strings.TrimSpace(string(output)))
}
defer func() {
detachOutput, detachErr := exec.Command("/usr/bin/hdiutil", "detach", mountPath).CombinedOutput()
if detachErr == nil {
return
}
forceOutput, forceErr := exec.Command("/usr/bin/hdiutil", "detach", "-force", mountPath).CombinedOutput()
if forceErr != nil && err == nil {
err = fmt.Errorf(
"unmount ChatGPT disk image: %v: %s; force detach: %v: %s",
detachErr,
strings.TrimSpace(string(detachOutput)),
forceErr,
strings.TrimSpace(string(forceOutput)),
)
}
}()
bundlePath, err := codexDesktopBundleOnVolume(mountPath)
if err != nil {
return "", err
}
return installCodexDesktopBundle(bundlePath, destinations, verify)
}
func codexDesktopBundleOnVolume(mountPath string) (string, error) {
for _, name := range []string{"ChatGPT.app", "Codex.app"} {
bundlePath := filepath.Join(mountPath, name)
info, err := os.Lstat(bundlePath)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", fmt.Errorf("inspect ChatGPT bundle: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", fmt.Errorf("ChatGPT disk image contains an invalid %s", name)
}
return bundlePath, nil
}
return "", errors.New("ChatGPT disk image does not contain ChatGPT.app")
}
func installCodexDesktopBundle(bundlePath string, destinations []string, verify func(string) error) (string, error) {
if err := validateCodexDesktopBundle(bundlePath); err != nil {
return "", err
}
if err := verify(bundlePath); err != nil {
return "", fmt.Errorf("verify ChatGPT signature: %w", err)
}
var permissionErr error
for _, destination := range destinations {
if strings.TrimSpace(destination) == "" {
continue
}
if _, err := os.Lstat(destination); err == nil {
return "", fmt.Errorf("%w: %s", errCodexDesktopDestinationExists, destination)
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("check ChatGPT destination %s: %w", destination, err)
}
parent := filepath.Dir(destination)
if err := os.MkdirAll(parent, 0o755); err != nil {
if errors.Is(err, os.ErrPermission) {
permissionErr = err
continue
}
return "", fmt.Errorf("create ChatGPT destination: %w", err)
}
stageDir, err := os.MkdirTemp(parent, ".ollama-chatgpt-install-")
if err != nil {
if errors.Is(err, os.ErrPermission) {
permissionErr = err
continue
}
return "", fmt.Errorf("create staged ChatGPT destination: %w", err)
}
stagedBundle := filepath.Join(stageDir, "ChatGPT.app")
copyOutput, copyErr := exec.Command("/usr/bin/ditto", bundlePath, stagedBundle).CombinedOutput()
if copyErr == nil {
copyErr = validateCodexDesktopBundle(stagedBundle)
}
if copyErr == nil {
copyErr = verify(stagedBundle)
}
if copyErr == nil {
copyErr = os.Rename(stagedBundle, destination)
}
removeErr := os.RemoveAll(stageDir)
if copyErr != nil {
if errors.Is(copyErr, os.ErrPermission) {
permissionErr = copyErr
continue
}
return "", fmt.Errorf("install ChatGPT in %s: %w: %s", parent, copyErr, strings.TrimSpace(string(copyOutput)))
}
if removeErr != nil {
return "", fmt.Errorf("remove staged ChatGPT destination: %w", removeErr)
}
return destination, nil
}
if permissionErr != nil {
return "", fmt.Errorf("install ChatGPT in Applications: %w", permissionErr)
}
return "", errors.New("ChatGPT installation destination is required")
}
func validateCodexDesktopBundle(bundlePath string) error {
info, err := os.Stat(bundlePath)
if err != nil || !info.IsDir() {
return errors.New("ChatGPT disk image does not contain a valid app bundle")
}
for _, executableName := range []string{"ChatGPT", "Codex"} {
executable := filepath.Join(bundlePath, "Contents", "MacOS", executableName)
info, err = os.Stat(executable)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return fmt.Errorf("inspect ChatGPT executable: %w", err)
}
if info.Mode().IsRegular() && info.Mode()&0o111 != 0 {
return nil
}
return errors.New("ChatGPT executable is not executable")
}
return errors.New("ChatGPT executable is missing")
}
func verifyCodexDesktopBundle(bundlePath string) error {
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
}
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
if err != nil {
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
}
details := string(output)
if !strings.Contains(details, "Identifier="+codexDesktopBundleID) ||
!strings.Contains(details, "TeamIdentifier="+codexDesktopTeamID) {
return errors.New("unexpected ChatGPT signing identity")
}
return nil
}
@@ -0,0 +1,120 @@
//go:build darwin
package main
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestInstallCodexDesktopBundle(t *testing.T) {
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
verified := 0
installed, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error {
verified++
return nil
})
if err != nil {
t.Fatal(err)
}
if installed != destination {
t.Fatalf("installed = %q, want %q", installed, destination)
}
if verified != 2 {
t.Fatalf("signature verification count = %d, want 2", verified)
}
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "ChatGPT"))
if err != nil {
t.Fatal(err)
}
if info.Mode()&0o111 == 0 {
t.Fatal("installed ChatGPT executable is not executable")
}
}
func TestInstallCodexDesktopBundleAcceptsCodexNamedSource(t *testing.T) {
bundle := writeCodexDesktopTestBundle(t, "Codex.app", "Codex")
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(destination, "Contents", "MacOS", "Codex")); err != nil {
t.Fatal(err)
}
}
func TestInstallCodexDesktopBundleVerifiesBeforeCopy(t *testing.T) {
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
wantErr := errors.New("invalid signature")
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid bundle created destination: %v", err)
}
}
func TestInstallCodexDesktopBundleDoesNotOverwrite(t *testing.T) {
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
if err := os.MkdirAll(destination, 0o755); err != nil {
t.Fatal(err)
}
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
t.Fatalf("error = %v, want destination exists", err)
}
}
func TestInstallCodexDesktopBundleDoesNotOverwriteBrokenSymlink(t *testing.T) {
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
if err := os.Symlink(filepath.Join(t.TempDir(), "missing"), destination); err != nil {
t.Fatal(err)
}
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
t.Fatalf("error = %v, want destination exists", err)
}
}
func TestCodexDesktopBundleOnVolumeRejectsSymlink(t *testing.T) {
volume := t.TempDir()
target := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
if err := os.Symlink(target, filepath.Join(volume, "ChatGPT.app")); err != nil {
t.Fatal(err)
}
if _, err := codexDesktopBundleOnVolume(volume); err == nil {
t.Fatal("codexDesktopBundleOnVolume accepted a symlink")
}
}
func TestInstallCodexDesktopDiskImageRealArchive(t *testing.T) {
image := os.Getenv("OLLAMA_TEST_CODEX_DESKTOP_DMG")
if image == "" {
t.Skip("set OLLAMA_TEST_CODEX_DESKTOP_DMG to the official ChatGPT DMG")
}
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
installed, err := installCodexDesktopDiskImage(image, []string{destination}, verifyCodexDesktopBundle)
if err != nil {
t.Fatal(err)
}
if installed != destination {
t.Fatalf("installed = %q, want %q", installed, destination)
}
}
func writeCodexDesktopTestBundle(t *testing.T, appName, executableName string) string {
t.Helper()
bundle := filepath.Join(t.TempDir(), appName)
executable := filepath.Join(bundle, "Contents", "MacOS", executableName)
if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
return bundle
}
+98 -80
View File
@@ -16,6 +16,7 @@ import (
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
@@ -24,11 +25,21 @@ import (
"github.com/ollama/ollama/app/webview"
)
const (
defaultWindowWidth = 1360
defaultWindowHeight = 960
onboardingWindowWidth = 900
onboardingWindowHeight = 660
minimumWindowWidth = onboardingWindowWidth
minimumWindowHeight = onboardingWindowHeight
)
type Webview struct {
port int
token string
webview webview.WebView
mutex sync.Mutex
port int
token string
webview webview.WebView
mutex sync.Mutex
onboarding atomic.Bool
Store *store.Store
}
@@ -88,85 +99,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
// Windows-specific scrollbar styling
if runtime.GOOS == "windows" {
init += `
// Fix scrollbar styling for Edge WebView2 on Windows only
// Keep Edge WebView2 scrollbars aligned with the system theme.
function updateScrollbarStyles() {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const existingStyle = document.getElementById('scrollbar-style');
if (existingStyle) existingStyle.remove();
const style = document.createElement('style');
style.id = 'scrollbar-style';
if (isDark) {
style.textContent = ` + "`" + `
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
style.textContent = ` + "`" + `
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-track { background: #1a1a1a !important; }
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
::-webkit-scrollbar-thumb { background: #404040 !important; }
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
::-webkit-scrollbar-button {
background: transparent !important;
border: none !important;
width: 0px !important;
height: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:vertical:end:increment {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background: transparent !important;
width: 0px !important;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background: transparent !important;
width: 0px !important;
}
` + "`" + `;
} else {
style.textContent = ` + "`" + `
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
::-webkit-scrollbar-button {
background: transparent !important;
border: none !important;
width: 0px !important;
height: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:vertical:end:increment {
background: transparent !important;
height: 0px !important;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background: transparent !important;
width: 0px !important;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background: transparent !important;
width: 0px !important;
}
` + "`" + `;
}
}
::-webkit-scrollbar-button {
background: transparent !important;
border: none !important;
width: 0px !important;
height: 0px !important;
margin: 0 !important;
padding: 0 !important;
}
` + "`" + `;
document.head.appendChild(style);
}
window.addEventListener('load', updateScrollbarStyles);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
`
}
// on windows make ctrl+n open new chat
@@ -187,15 +151,32 @@ func (w *Webview) Run(path string) unsafe.Pointer {
`
}
init += `
init += fmt.Sprintf(`
window.OLLAMA_PLATFORM = %q;
window.OLLAMA_WEBSEARCH = true;
`
`, runtime.GOOS)
wv.Init(init)
// Add keyboard handler for zoom
wv.Init(`
window.addEventListener('keydown', function(e) {
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (
e.key === '+' || e.key === '=' || e.key === '-' ||
e.key === '_' || e.key === '0' ||
e.code === 'NumpadAdd' || e.code === 'NumpadSubtract'
);
// Keep fixed-scale onboarding and apps pages at their intended size.
const isFixedScalePage =
window.location.pathname === '/onboarding' ||
window.location.pathname === '/connect';
if (isFixedScalePage && isZoomShortcut) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// CMD/Ctrl + Plus/Equals (zoom in)
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
e.preventDefault();
@@ -237,10 +218,42 @@ func (w *Webview) Run(path string) unsafe.Pointer {
showWindow(wv.Window())
})
wv.Bind("activateOllama", func() {
showWindow(wv.Window())
})
bindClaudeDesktop(wv)
bindCodexDesktop(wv)
wv.Bind("close", func() {
hideWindow(wv.Window())
})
wv.Bind("setOnboardingWindow", func(enabled bool) {
w.onboarding.Store(enabled)
wv.Dispatch(func() {
if enabled {
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
setOnboardingWindowStyle(wv.Window(), true)
return
}
width, height := defaultWindowWidth, defaultWindowHeight
if w.Store != nil {
storedWidth, storedHeight, err := w.Store.WindowSize()
if err != nil {
slog.Error("failed to restore window size", "error", err)
} else if storedWidth > 0 && storedHeight > 0 {
width, height = storedWidth, storedHeight
}
}
wv.SetSize(width, height, webview.HintNone)
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
setOnboardingWindowStyle(wv.Window(), false)
})
})
// Webviews do not allow access to the file system by default, so we need to
// bind file system operations here
wv.Bind("selectModelsDirectory", func() {
@@ -450,18 +463,18 @@ func (w *Webview) Run(path string) unsafe.Pointer {
}()
}
width, height := defaultWindowWidth, defaultWindowHeight
if w.Store != nil {
width, height, err := w.Store.WindowSize()
storedWidth, storedHeight, err := w.Store.WindowSize()
if err != nil {
slog.Error("failed to get window size", "error", err)
}
if width > 0 && height > 0 {
wv.SetSize(width, height, webview.HintNone)
} else {
wv.SetSize(800, 600, webview.HintNone)
if storedWidth > 0 && storedHeight > 0 {
width, height = storedWidth, storedHeight
}
}
wv.SetSize(800, 600, webview.HintMin)
wv.SetSize(width, height, webview.HintNone)
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
w.webview = wv
w.webview.Navigate(url)
@@ -476,6 +489,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
}
func (w *Webview) Terminate() {
w.onboarding.Store(false)
w.mutex.Lock()
if w.webview == nil {
w.mutex.Unlock()
@@ -489,6 +503,10 @@ func (w *Webview) Terminate() {
wv.Destroy()
}
func (w *Webview) OnboardingActive() bool {
return w.onboarding.Load()
}
func (w *Webview) IsRunning() bool {
w.mutex.Lock()
defer w.mutex.Unlock()
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.6.17 -->
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<g id="g314">
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+2 -2
View File
@@ -143,13 +143,13 @@ func utf16ptr(utf16 []uint16) *uint16 {
func utf16slice(ptr *uint16) []uint16 { //nolint:unused
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
slice := *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
i := 0
for slice[len(slice)-1] != 0 {
i++
}
hdr.Len = i
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
slice = *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
return slice
}
+1 -1
View File
@@ -365,7 +365,7 @@ time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not f
if err != nil {
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
}
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
info, err := GetInferenceInfo(ctx)
if err != nil {
+54 -22
View File
@@ -14,7 +14,7 @@ import (
// currentSchemaVersion defines the current database schema version.
// Increment this when making schema changes that require migrations.
const currentSchemaVersion = 16
const currentSchemaVersion = 18
// database wraps the SQLite connection.
// SQLite handles its own locking for concurrent access:
@@ -82,12 +82,14 @@ func (db *database) init() error {
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
selected_model TEXT NOT NULL DEFAULT '',
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
last_home_view TEXT NOT NULL DEFAULT 'launch',
last_home_view TEXT NOT NULL DEFAULT 'chat',
onboarding_version INTEGER NOT NULL DEFAULT 0,
think_enabled BOOLEAN NOT NULL DEFAULT 0,
think_level TEXT NOT NULL DEFAULT '',
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
remote TEXT NOT NULL DEFAULT '', -- deprecated
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT %d
);
@@ -271,6 +273,18 @@ func (db *database) migrate() error {
return fmt.Errorf("migrate v15 to v16: %w", err)
}
version = 16
case 16:
// Existing users should not be shown onboarding after an upgrade.
if err := db.migrateV16ToV17(); err != nil {
return fmt.Errorf("migrate v16 to v17: %w", err)
}
version = 17
case 17:
// Remember that Claude Desktop has been connected at least once.
if err := db.migrateV17ToV18(); err != nil {
return fmt.Errorf("migrate v17 to v18: %w", err)
}
version = 18
default:
// If we have a version we don't recognize, just set it to current
// This might happen during development
@@ -527,7 +541,7 @@ func (db *database) migrateV14ToV15() error {
// migrateV15ToV16 adds the last_home_view column to the settings table
func (db *database) migrateV15ToV16() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add last_home_view column: %w", err)
}
@@ -540,6 +554,38 @@ func (db *database) migrateV15ToV16() error {
return nil
}
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
// zero for genuinely new installs, while all existing rows are marked complete
// and moved off the retired launch home view.
func (db *database) migrateV16ToV17() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add onboarding_version column: %w", err)
}
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
if err != nil {
return fmt.Errorf("complete onboarding for existing users: %w", err)
}
return nil
}
// migrateV17ToV18 adds durable Claude Desktop integration history.
func (db *database) migrateV17ToV18() error {
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN claude_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
if err != nil && !duplicateColumnError(err) {
return fmt.Errorf("add claude_desktop_used column: %w", err)
}
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 18`)
if err != nil {
return fmt.Errorf("update schema version: %w", err)
}
return nil
}
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
func (db *database) cleanupOrphanedData() error {
_, err := db.conn.Exec(`
@@ -1188,9 +1234,9 @@ func (db *database) getSettings() (Settings, error) {
var s Settings
err := db.conn.QueryRow(`
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, think_enabled, think_level, auto_update_enabled
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
FROM settings
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
if err != nil {
return Settings{}, fmt.Errorf("get settings: %w", err)
}
@@ -1200,28 +1246,14 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"codex-app": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
}
if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok {
lastHomeView = "launch"
}
lastHomeView = "chat"
}
_, err := db.conn.Exec(`
UPDATE settings
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ?
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed)
if err != nil {
return fmt.Errorf("set settings: %w", err)
}
+85 -3
View File
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
}
}
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
t.Fatalf("failed to read last_home_view: %v", err)
}
if lastHomeView != "launch" {
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
if lastHomeView != "chat" {
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
}
version, err := db.getSchemaVersion()
@@ -174,6 +174,88 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
}
}
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
t.Run("fresh installs need onboarding", func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.OnboardingVersion != 0 {
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
}
})
t.Run("existing installs skip onboarding", func(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "existing.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
if _, err := db.conn.Exec(`
ALTER TABLE settings DROP COLUMN onboarding_version;
UPDATE settings SET schema_version = 16;
`); err != nil {
t.Fatalf("failed to seed v16 settings row: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("migration from v16 to v17 failed: %v", err)
}
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.OnboardingVersion != 1 {
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
}
})
}
func TestClaudeDesktopUsedDefaultsAndMigration(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "claude-history.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
settings, err := db.getSettings()
if err != nil {
t.Fatalf("failed to read settings: %v", err)
}
if settings.ClaudeDesktopUsed {
t.Fatal("expected fresh installs to have no Claude Desktop history")
}
if _, err := db.conn.Exec(`
ALTER TABLE settings DROP COLUMN claude_desktop_used;
UPDATE settings SET schema_version = 17;
`); err != nil {
t.Fatalf("failed to seed v17 settings row: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("migration from v17 to v18 failed: %v", err)
}
settings, err = db.getSettings()
if err != nil {
t.Fatalf("failed to read migrated settings: %v", err)
}
if settings.ClaudeDesktopUsed {
t.Fatal("expected existing installs to start with no inferred Claude Desktop history")
}
}
func TestChatDeletionWithCascade(t *testing.T) {
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
tmpDir := t.TempDir()
+8
View File
@@ -57,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
t.Error("expected has completed first run to be true after migration")
}
settings, err := s.Settings()
if err != nil {
t.Fatalf("failed to get settings: %v", err)
}
if settings.OnboardingVersion != CurrentOnboardingVersion {
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
}
// Verify migration is marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
+21 -2
View File
@@ -167,13 +167,22 @@ type Settings struct {
// SidebarOpen indicates if the chat sidebar is open
SidebarOpen bool
// LastHomeView stores the preferred home route target ("chat" or integration name)
// LastHomeView is retained for settings compatibility and resolves to chat.
LastHomeView string
// OnboardingVersion stores the latest onboarding flow the user has completed.
OnboardingVersion int
// AutoUpdateEnabled indicates if automatic updates should be downloaded
AutoUpdateEnabled bool
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
ClaudeDesktopUsed bool
}
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
const CurrentOnboardingVersion = 1
type Store struct {
// DBPath allows overriding the default database path (mainly for testing)
DBPath string
@@ -334,6 +343,16 @@ func (s *Store) migrateFromConfig(database *database) error {
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
return fmt.Errorf("migrate first time run: %w", err)
}
if hasCompleted {
settings, err := database.getSettings()
if err != nil {
return fmt.Errorf("read settings for onboarding migration: %w", err)
}
settings.OnboardingVersion = CurrentOnboardingVersion
if err := database.setSettings(settings); err != nil {
return fmt.Errorf("migrate onboarding completion: %w", err)
}
}
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
// Mark as migrated
@@ -393,7 +412,7 @@ func (s *Store) Settings() (Settings, error) {
}
if settings.LastHomeView == "" {
settings.LastHomeView = "launch"
settings.LastHomeView = "chat"
}
return settings, nil
+64 -12
View File
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
}
})
t.Run("settings default home view is launch", func(t *testing.T) {
t.Run("settings default home view is chat", func(t *testing.T) {
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
t.Fatal(err)
}
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
t.Fatal(err)
}
@@ -117,12 +117,12 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "launch" {
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
t.Run("settings codex app home view is accepted", func(t *testing.T) {
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
t.Fatal(err)
}
@@ -132,8 +132,8 @@ func TestStore(t *testing.T) {
t.Fatal(err)
}
if loaded.LastHomeView != "codex-app" {
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
if loaded.LastHomeView != "chat" {
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
}
})
@@ -227,6 +227,58 @@ func TestStore(t *testing.T) {
})
}
func TestOnboardingVersionRoundTrip(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
settings, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if settings.OnboardingVersion != 0 {
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
}
settings.OnboardingVersion = 1
if err := s.SetSettings(settings); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.OnboardingVersion != 1 {
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
}
}
func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
settings, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if settings.ClaudeDesktopUsed {
t.Fatal("expected Claude Desktop history to be false by default")
}
settings.ClaudeDesktopUsed = true
if err := s.SetSettings(settings); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if !loaded.ClaudeDesktopUsed {
t.Fatal("expected Claude Desktop history to persist")
}
}
// setupTestStore creates a temporary store for testing
func setupTestStore(t *testing.T) (*Store, func()) {
t.Helper()
+4
View File
@@ -415,7 +415,9 @@ export class Settings {
SelectedModel: string;
SidebarOpen: boolean;
LastHomeView: string;
OnboardingVersion: number;
AutoUpdateEnabled: boolean;
ClaudeDesktopUsed: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
@@ -434,7 +436,9 @@ export class Settings {
this.SelectedModel = source["SelectedModel"];
this.SidebarOpen = source["SidebarOpen"];
this.LastHomeView = source["LastHomeView"];
this.OnboardingVersion = source["OnboardingVersion"];
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
this.ClaudeDesktopUsed = source["ClaudeDesktopUsed"];
}
}
export class SettingsResponse {
+2 -1
View File
@@ -2,12 +2,13 @@
<html lang="en" style="overflow: hidden">
<head>
<meta charset="UTF-8" />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/index.css" />
<title>Ollama</title>
</head>
<body class="dark:bg-neutral-900 select-text">
<body class="bg-white dark:bg-neutral-900 select-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
+33
View File
@@ -43,6 +43,7 @@
"@types/node": "^24.7.2",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
@@ -56,6 +57,7 @@
"playwright": "^1.53.2",
"postcss-preset-env": "^10.2.4",
"react-markdown": "^10.1.0",
"react-test-renderer": "19.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
@@ -4593,6 +4595,16 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-test-renderer": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@@ -11152,6 +11164,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-test-renderer": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-is": "^19.1.0",
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-test-renderer/node_modules/react-is": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+2
View File
@@ -52,6 +52,7 @@
"@types/node": "^24.7.2",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
@@ -65,6 +66,7 @@
"playwright": "^1.53.2",
"postcss-preset-env": "^10.2.4",
"react-markdown": "^10.1.0",
"react-test-renderer": "19.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg width="92" height="96" viewBox="0 0 92 96" xmlns="http://www.w3.org/2000/svg">
<g fill="#24292F">
<path fill-rule="evenodd" d="M65.45 16.8c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4 4 0 0 1-.01 3.6l-5.73 11.34v6.6c0 10.94-8.82 19.8-19.71 19.8H26.02C15.13 96 6.31 87.14 6.31 76.2v-6.6L.45 58.3a4 4 0 0 1-.01-3.67l5.87-11.43v-6.6c0-10.94 8.82-19.8 19.71-19.8h39.43Zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88V45L7.44 54.46a4 4 0 0 0 .01 3.68L12.32 67.5v5.63c0 9.32 7.55 16.87 16.87 16.87h33.74c9.32 0 16.87-7.55 16.87-16.87V67.5l4.77-9.39a4 4 0 0 0 .01-3.61L79.8 45v-5.62c0-9.32-7.55-16.88-16.87-16.88Z"/>
<circle cx="45.73" cy="11.5" r="11"/>
<rect x="27" y="41" width="13" height="30" rx="6.5"/>
<rect x="51" y="41" width="13" height="30" rx="6.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

@@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g transform="scale(32)"><clipPath id="codex-color-clip"><path d="M0 0h16v16H0z"/></clipPath><g clip-path="url(#codex-color-clip)"><path d="M13.003 0H2.997A3.012 3.012 0 000 2.997v10.006A3.012 3.012 0 002.997 16h10.006A3.012 3.012 0 0016 13.003V2.997A3.012 3.012 0 0013.003 0z" fill="#fff" fill-rule="nonzero"/><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.104.104 0 00.043 0 4.556 4.556 0 013.046.275l.047.022.116.057a4.585 4.585 0 012.188 2.399c.209.51.313 1.041.315 1.595.015.412-.03.824-.134 1.223a.124.124 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.12.12 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.109.109 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.438 4.438 0 01-1.945-.466 4.553 4.553 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.797 5.797 0 01-.37-.961 4.575 4.575 0 01-.014-2.298.133.133 0 00.006-.056.083.083 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.898 3.898 0 01-.251-1.192 5.193 5.193 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33a6.29 6.29 0 01.646-.227.1.1 0 00.065-.066 4.512 4.512 0 01.829-1.615 4.54 4.54 0 011.837-1.388zm3.482 10.565a.64.64 0 00-.601.636.64.64 0 00.601.636h3.636l.036.001a.64.64 0 00.637-.637.64.64 0 00-.637-.637l-.036.001h-3.636zM8.462 9.23a.64.64 0 00-.543-.304.64.64 0 00-.563.935l1.272 2.224-1.266 2.136a.638.638 0 001.095.649l1.454-2.455a.637.637 0 00.005-.64L8.462 9.23z" fill="url(#codex-color-gradient)" fill-rule="nonzero" transform="scale(.66667)"/></g></g><defs><linearGradient id="codex-color-gradient" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 18 -18 0 12 3)"><stop offset="0" stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#4D6BFE" fill-rule="nonzero"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,11 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="omp-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#ed4abf"/>
<stop offset=".5" stop-color="#9b4dff"/>
<stop offset="1" stop-color="#5ad8e6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0f0a14"/>
<path fill="url(#omp-gradient)" d="M14 16h36v8H40v32h-8V24h-6v22h-8V24h-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 451 B

@@ -0,0 +1,11 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="poolside-gradient" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse">
<stop stop-color="#6c5cff"/>
<stop offset="1" stop-color="#3c2cff"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="13" fill="url(#poolside-gradient)"/>
<path d="M13 32c0-10.5 8.5-19 19-19 10.49 0 19 8.5 19 19s-8.51 19-19 19c-10.5 0-19-8.5-19-19Z" fill="none" stroke="#fff" stroke-width="4"/>
<path d="M16 24c8-4.1 17.1-.9 22.6 7.1 4.3-1.2 8.6.5 11 4.1M23.5 47.5 38 17.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

@@ -0,0 +1,3 @@
<svg viewBox="0 0 141.38 140" xmlns="http://www.w3.org/2000/svg">
<path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.32 3.32 0 0 0 0-3.34l-9.62-16.67a3.34 3.34 0 0 0-2.89-1.67H82.23l-8.66-15A3.33 3.33 0 0 0 70.68-.02H51.43a3.33 3.33 0 0 0-2.88 1.67L32.19 29.98l-1.92 3.33H12.96a3.34 3.34 0 0 0-2.88 1.67L.45 51.66a3.32 3.32 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67a3.34 3.34 0 0 0 2.89 1.67h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32a3.34 3.34 0 0 0 2.89-1.67l9.62-16.67a3.32 3.32 0 0 0-.01-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/>
</svg>

After

Width:  |  Height:  |  Size: 832 B

+168
View File
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { listModels } = vi.hoisted(() => ({ listModels: vi.fn() }));
vi.mock("./lib/ollama-client", () => ({
ollamaClient: { list: listModels },
}));
import {
fetchConnectUrl,
getClaudeDesktopAvailableModels,
getIntegrationStatuses,
} from "./api";
describe("fetchConnectUrl", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("requests a desktop handoff after account creation", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
signin_url:
"https://ollama.com/connect?name=MacBook&key=public-key",
}),
{ status: 401 },
),
),
);
await expect(fetchConnectUrl()).resolves.toBe(
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
);
});
});
describe("getIntegrationStatuses", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns desktop and launcher integration metadata", async () => {
const fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify([
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
},
{
id: "opencode",
name: "OpenCode",
description: "Open-source coding agent",
command: "ollama launch opencode",
},
]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
await expect(getIntegrationStatuses()).resolves.toEqual([
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
},
{
id: "opencode",
name: "OpenCode",
description: "Open-source coding agent",
command: "ollama launch opencode",
},
]);
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:3001/api/v1/integrations",
);
});
});
describe("getClaudeDesktopAvailableModels", () => {
afterEach(() => {
listModels.mockReset();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("returns installed local models while pruning remote entries", async () => {
listModels.mockResolvedValue({
models: [
{ name: "llama3.2:latest", digest: "local" },
{
name: "remote-placeholder",
digest: "remote",
remote_host: "https://ollama.com",
},
],
});
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
const models = await getClaudeDesktopAvailableModels();
expect(models.map((model) => model.model)).toEqual(["llama3.2"]);
expect(fetch).not.toHaveBeenCalled();
});
it("does not request cloud models when they are unavailable to the user", async () => {
listModels.mockResolvedValue({
models: [
{ name: "qwen3:8b", digest: "local" },
{ name: "deepseek-v4-flash:cloud", digest: "cached-cloud" },
{ name: "gemma4:31b-cloud", digest: "legacy-cached-cloud" },
],
});
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
const models = await getClaudeDesktopAvailableModels();
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
expect(fetch).not.toHaveBeenCalled();
});
it("loads the account cloud list in parallel when Cloud is available", async () => {
listModels.mockResolvedValue({
models: [{ name: "qwen3:8b", digest: "local" }],
});
const fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
models: [
{ name: "glm-5.2", digest: "cloud" },
{ name: "gemma4:31b-cloud", digest: "legacy-cloud" },
{ name: "qwen3:8b", digest: "cloud-duplicate" },
],
}),
),
);
vi.stubGlobal("fetch", fetch);
const models = await getClaudeDesktopAvailableModels(true);
expect(models.map((model) => model.model)).toEqual([
"qwen3:8b",
"glm-5.2:cloud",
"gemma4:31b-cloud",
]);
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:3001/api/v1/models/cloud",
);
});
it("keeps local models when the account cloud list fails", async () => {
listModels.mockResolvedValue({
models: [{ name: "qwen3:8b", digest: "local" }],
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
const models = await getClaudeDesktopAvailableModels(true);
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
});
});
+102 -2
View File
@@ -32,6 +32,24 @@ export interface CloudStatusResponse {
disabled: boolean;
source: CloudStatusSource;
}
export interface IntegrationStatus {
id: string;
name: string;
description: string;
installed?: boolean;
command?: string;
}
export type IntegrationStatuses = IntegrationStatus[];
export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
const response = await fetch(`${API_BASE}/api/v1/integrations`);
if (!response.ok) {
throw new Error(`Failed to fetch integration statuses: ${response.status}`);
}
return response.json();
}
// Helper function to convert Uint8Array to base64
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
@@ -81,7 +99,9 @@ export async function fetchConnectUrl(): Promise<string> {
if (response.status === 401) {
const data = await response.json();
if (data.signin_url) {
return data.signin_url;
const connectUrl = new URL(data.signin_url);
connectUrl.searchParams.set("launch", "true");
return connectUrl.toString();
}
}
@@ -176,6 +196,84 @@ export async function getModels(query?: string): Promise<Model[]> {
}
}
export async function getClaudeDesktopAvailableModels(
includeCloudModels = false,
): Promise<Model[]> {
try {
const [localResult, cloudResult] = await Promise.all([
ollama.list(),
includeCloudModels
? fetch(`${API_BASE}/api/v1/models/cloud`)
.then(async (response) => {
if (!response.ok) {
throw new Error(`cloud model list returned ${response.status}`);
}
return (await response.json()) as { models?: ModelResponse[] };
})
.catch((error) => {
console.warn("Failed to fetch cloud models:", error);
return { models: [] };
})
: Promise.resolve({ models: [] as ModelResponse[] }),
]);
const localModels = localResult.models.filter((model: ModelResponse) => {
const response = model as ModelResponse & {
remote_model?: string;
remote_host?: string;
};
const name = model.name.replace(/:latest$/, "");
return (
!response.remote_model &&
!response.remote_host &&
!name.endsWith("cloud")
);
});
const cloudModels = (cloudResult.models ?? []).map((model) => {
const name = model.name.replace(/:latest$/, "");
const tag = name.slice(name.lastIndexOf(":") + 1).toLowerCase();
const explicitCloud =
name.endsWith(":cloud") ||
(name.includes(":") && tag.endsWith("-cloud"));
return {
...model,
name: explicitCloud ? name : `${name}:cloud`,
};
});
const seen = new Set<string>();
return [...localModels, ...cloudModels]
.filter((model: ModelResponse) => {
const base = model.name
.replace(/:latest$/, "")
.replace(/:cloud$/, "");
if (!base || seen.has(base)) return false;
const families = model.details?.families;
const supported =
!families ||
families.length === 0 ||
!families.every((family: string) =>
family.toLowerCase().includes("bert"),
);
if (supported) seen.add(base);
return supported;
})
.map(
(model: ModelResponse) =>
new Model({
model: model.name.replace(/:latest$/, ""),
digest: model.digest,
modified_at: model.modified_at
? new Date(model.modified_at)
: undefined,
}),
);
} catch (err) {
throw new Error(`Failed to fetch Ollama models: ${err}`);
}
}
export async function getModelCapabilities(
modelName: string,
): Promise<ModelCapabilitiesResponse> {
@@ -418,7 +516,9 @@ export interface ModelRecommendationsResponse {
recommendations: ModelRecommendation[];
}
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
export async function getModelRecommendations(): Promise<
ModelRecommendation[]
> {
const response = await fetch(
`${API_BASE}/api/experimental/model-recommendations`,
);
+43
View File
@@ -0,0 +1,43 @@
import { Link } from "@/components/ui/link";
import { ChatIcon } from "@/components/ChatIcon";
import { Cog6ToothIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
type AppSection = "apps" | "chat" | "settings";
export function AppNavigation({ current }: { current: AppSection }) {
const itemClass = (section: AppSection) =>
`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800 ${
current === section ? "bg-neutral-100 dark:bg-neutral-800" : ""
}`;
return (
<div className="flex flex-col gap-0.5">
<Link to="/connect" className={itemClass("apps")} draggable={false}>
<RectangleGroupIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Apps</span>
</Link>
<Link
to="/c/$chatId"
params={{ chatId: "new" }}
mask={{ to: "/" }}
className={itemClass("chat")}
draggable={false}
>
<ChatIcon />
<span className="truncate">Chat</span>
</Link>
<Link to="/settings" className={itemClass("settings")} draggable={false}>
<Cog6ToothIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Settings</span>
</Link>
</div>
);
}
export function AppSidebar({ current }: { current: AppSection }) {
return (
<nav className="flex flex-1 flex-col px-4 pb-4 select-none">
<AppNavigation current={current} />
</nav>
);
}
+14
View File
@@ -0,0 +1,14 @@
export function ChatIcon({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={`${className} fill-current`}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
</svg>
);
}
+88 -146
View File
@@ -6,14 +6,12 @@ import { getChat } from "@/api";
import { Link } from "@/components/ui/link";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { ChatsResponse } from "@/gotypes";
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
import { AppNavigation } from "@/components/AppSidebar";
// there's a hidden debug feature to copy a chat's data to the clipboard by
// holding shift and clicking this many times within this many seconds
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
interface ChatSidebarProps {
currentChatId?: string;
}
@@ -240,156 +238,100 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
[startEditing, handleDeleteChat],
);
if (isLoading) {
return (
<nav className="flex min-h-0 flex-col">
<div className="flex flex-1 flex-col p-4">
<div className="p-4">Loading...</div>
</div>
</nav>
);
}
if (error) {
return (
<nav className="flex min-h-0 flex-col">
<div className="flex flex-1 flex-col p-4">
<div className="p-4 text-red-500">Error loading chats</div>
</div>
</nav>
);
}
const isWindows = navigator.platform.toLowerCase().includes("win");
return (
<nav className="flex flex-1 flex-col min-h-0 select-none">
<nav
aria-busy={isLoading || undefined}
className="flex flex-1 flex-col min-h-0 select-none"
>
<header className="flex flex-col gap-0.5 px-4 pb-2">
<Link
href="/c/new"
mask={{ to: "/" }}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
}`}
draggable={false}
>
<svg
className="h-5 w-5 fill-current"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
</svg>
<span className="truncate">New Chat</span>
</Link>
<Link
to="/c/$chatId"
params={{ chatId: "launch" }}
onClick={() => {
if (currentChatId !== "launch") {
sessionStorage.setItem(launchSidebarRequestedKey, "1");
}
}}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
? "bg-neutral-100 dark:bg-neutral-800"
: ""
}`}
draggable={false}
>
<RocketLaunchIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Launch</span>
</Link>
{isWindows && (
<Link
href="/settings"
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-300`}
draggable={false}
>
<CogIcon className="h-5 w-5 stroke-current" />
<span className="truncate">Settings</span>
</Link>
)}
<AppNavigation current="chat" />
</header>
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
<div className="flex flex-col gap-3 pt-4">
{chatGroups.map((group) => (
<div key={group.name} className="flex flex-col gap-0.5">
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
{group.name}
</h3>
{group.chats.map((chat) => (
<div
key={chat.id}
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
? "bg-neutral-100 text-black dark:bg-neutral-800"
: ""
{error ? (
<div className="px-2 pt-4 text-sm text-red-500">
Error loading chats
</div>
) : (
<div className="flex flex-col gap-3 pt-4">
{chatGroups.map((group) => (
<div key={group.name} className="flex flex-col gap-0.5">
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
{group.name}
</h3>
{group.chats.map((chat) => (
<div
key={chat.id}
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
chat.id === currentChatId
? "bg-neutral-100 text-black dark:bg-neutral-800"
: ""
}`}
onMouseEnter={() => handleMouseEnter(chat.id)}
onContextMenu={(e) =>
handleContextMenu(
e,
chat.id,
chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString(),
)
}
>
{editingChatId === chat.id ? (
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
<span className="truncate font-sans text-sm w-full">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveRename();
} else if (e.key === "Escape") {
setEditingChatId(null);
setEditValue("");
}
}}
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
style={{
font: "inherit",
lineHeight: "inherit",
padding: 0,
margin: 0,
}}
/>
</span>
</div>
) : (
<Link
to="/c/$chatId"
params={{ chatId: chat.id }}
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
onClick={(e) => {
handleShiftClick(e, chat.id);
}}
draggable={false}
>
<span className="truncate font-sans text-sm">
{chat.title ||
onMouseEnter={() => handleMouseEnter(chat.id)}
onContextMenu={(e) =>
handleContextMenu(
e,
chat.id,
chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString()}
</span>
{copiedChatId === chat.id && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
Copied!
chat.createdAt.toLocaleString(),
)
}
>
{editingChatId === chat.id ? (
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
<span className="truncate font-sans text-sm w-full">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveRename();
} else if (e.key === "Escape") {
setEditingChatId(null);
setEditValue("");
}
}}
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
style={{
font: "inherit",
lineHeight: "inherit",
padding: 0,
margin: 0,
}}
/>
</span>
)}
</Link>
)}
</div>
))}
</div>
))}
</div>
</div>
) : (
<Link
to="/c/$chatId"
params={{ chatId: chat.id }}
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
onClick={(e) => {
handleShiftClick(e, chat.id);
}}
draggable={false}
>
<span className="truncate font-sans text-sm">
{chat.title ||
chat.userExcerpt ||
chat.createdAt.toLocaleString()}
</span>
{copiedChatId === chat.id && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
Copied!
</span>
)}
</Link>
)}
</div>
))}
</div>
))}
</div>
)}
</div>
</nav>
);
@@ -0,0 +1,711 @@
import {
act,
create,
type ReactTestInstance,
type ReactTestRenderer,
} from "react-test-renderer";
import {
createRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { describe, expect, it, vi } from "vitest";
import { Switch } from "./ui/switch";
import {
ClaudeDesktopModelsSettings,
type ClaudeDesktopModelsSettingsHandle,
} from "./ClaudeDesktopModelsSettings";
vi.mock("@headlessui/react", async (importOriginal) => {
const React = await import("react");
const original = await importOriginal<typeof import("@headlessui/react")>();
type PopoverContextValue = {
open: boolean;
close: () => void;
toggle: () => void;
};
const PopoverContext = React.createContext<PopoverContextValue | null>(null);
const usePopover = () => {
const context = React.useContext(PopoverContext);
if (!context) throw new Error("Popover components must be nested");
return context;
};
function TestPopover({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const [open, setOpen] = React.useState(false);
const context = {
open,
close: () => setOpen(false),
toggle: () => setOpen((current) => !current),
};
return (
<PopoverContext.Provider value={context}>
<div className={className}>{children}</div>
</PopoverContext.Provider>
);
}
function TestPopoverButton({
onClick,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
const { open, toggle } = usePopover();
return (
<button
{...props}
aria-expanded={open}
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
onClick?.(event);
toggle();
}}
/>
);
}
function TestPopoverPanel({
anchor,
children,
...props
}: HTMLAttributes<HTMLDivElement> & {
anchor?: unknown;
children: ReactNode | ((props: { close: () => void }) => ReactNode);
}) {
const { open, close } = usePopover();
if (!open) return null;
return (
<div {...props} data-anchor={JSON.stringify(anchor)}>
{typeof children === "function" ? children({ close }) : children}
</div>
);
}
return Object.assign({}, original, {
Popover: TestPopover,
PopoverButton: TestPopoverButton,
PopoverPanel: TestPopoverPanel,
});
});
const fableRoute = {
routeId: "claude-fable-5",
routeName: "Fable 5",
};
function testStatus(model = "glm-5.2:cloud", running = false) {
return {
supported: true,
used: true,
installed: true,
connected: true,
running,
startFailed: false,
portConflict: false,
autoMode: false,
modelSource: "user" as const,
mappings: [{ ...fableRoute, model }],
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: model === "glm-5.2:cloud",
availability: "available" as const,
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: model === "kimi-k3:cloud",
availability: "available" as const,
},
],
};
}
async function selectKimi(renderer: ReactTestRenderer) {
await act(async () => {
pickerButton(renderer).props.onClick();
await Promise.resolve();
});
await act(async () => {
renderer.root.findAllByProps({ role: "option" })[1].props.onClick();
await Promise.resolve();
});
}
function pickerButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType("button")
.find(
(candidate) =>
candidate.props["aria-label"] === "Ollama model for Fable 5",
);
if (!button) throw new Error("Claude model picker button not found");
return button;
}
function actionButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType("button")
.find(
(candidate) =>
!candidate.props["aria-label"] &&
candidate.props.className?.includes("flex-shrink-0"),
);
if (!button) throw new Error("Claude action button not found");
return button;
}
function textContent(node: ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
describe("ClaudeDesktopModelsSettings interactions", () => {
it("opens below without scrolling and disables auto mode for draft changes", async () => {
class TestHTMLElement {
focus() {}
}
const focus = vi.fn();
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
autoMode: true,
modelSource: "user",
mappings: [
{
routeId: "claude-fable-5",
routeName: "Fable 5",
model: "glm-5.2:cloud",
},
],
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
autoMode: true,
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
cloud: true,
selected: false,
autoMode: true,
},
],
}}
/>,
{
createNodeMock: (element) =>
element.type === "input" ? { focus } : null,
},
);
await Promise.resolve();
});
const autoModeSwitch = () =>
renderer!.root.findByProps({ role: "switch" });
expect(autoModeSwitch().props.disabled).not.toBe(true);
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
await act(async () => {
pickerButton(renderer!).props.onClick();
await Promise.resolve();
});
expect(
renderer!.root.findByProps({
"data-anchor": JSON.stringify({
to: "bottom end",
gap: 8,
padding: 8,
}),
}),
).toBeDefined();
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
await act(async () => {
const options = renderer!.root.findAllByProps({ role: "option" });
options[1].props.onClick();
await Promise.resolve();
});
expect(autoModeSwitch().props.disabled).toBe(true);
expect(autoModeSwitch().props["aria-checked"]).toBe(true);
expect(
renderer!.root
.findAllByType("p")
.some((node) =>
node.children
.join("")
.includes(
"Start or restart Claude to apply model changes before changing auto mode.",
),
),
).toBe(true);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("asks for confirmation from live native state before restarting", async () => {
class TestHTMLElement {
focus() {}
}
const apply = vi
.fn()
.mockResolvedValueOnce({
status: testStatus("glm-5.2:cloud", true),
error:
"Claude Desktop restart confirmation is required before changing its profile",
restartConfirmationRequired: true,
})
.mockResolvedValueOnce({
status: testStatus("kimi-k3:cloud", true),
mappingsApplied: true,
});
const confirm = vi.fn(() => true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
applyClaudeDesktopMappings: apply,
confirm,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(confirm).toHaveBeenCalledWith(
"Restart Claude Desktop? Any running task will stop.",
);
expect(apply).toHaveBeenNthCalledWith(
1,
{ "claude-fable-5": "kimi-k3:cloud" },
false,
);
expect(apply).toHaveBeenNthCalledWith(
2,
{ "claude-fable-5": "kimi-k3:cloud" },
true,
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("restores Auto mode when restart confirmation is canceled", async () => {
class TestHTMLElement {
focus() {}
}
const runningStatus = {
...testStatus("glm-5.2:cloud", true),
autoMode: true,
models: testStatus().models.map((model) => ({
...model,
autoMode: true,
})),
};
const setAutoMode = vi.fn().mockResolvedValue({
status: runningStatus,
error:
"Claude Desktop restart confirmation is required before changing its profile",
restartConfirmationRequired: true,
});
const confirm = vi.fn(() => false);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
setClaudeDesktopAutoMode: setAutoMode,
confirm,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={runningStatus}
/>,
);
await Promise.resolve();
});
await act(async () => {
renderer!.root.findByType(Switch).props.onChange(false);
await Promise.resolve();
await Promise.resolve();
});
expect(setAutoMode).toHaveBeenCalledTimes(1);
expect(setAutoMode).toHaveBeenCalledWith(false, false);
expect(confirm).toHaveBeenCalledWith(
"Restart Claude to change auto mode? Any running task will stop.",
);
expect(
renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
).toBe(true);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("ignores a stale focus refresh that finishes after apply", async () => {
class TestHTMLElement {
focus() {}
}
let focusHandler: (() => void) | undefined;
let resolveRefresh:
| ((status: ReturnType<typeof testStatus>) => void)
| undefined;
const staleRefresh = new Promise<ReturnType<typeof testStatus>>(
(resolve) => {
resolveRefresh = resolve;
},
);
vi.stubGlobal("window", {
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === "focus") focusHandler = handler;
}),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
getClaudeDesktopStatus: vi.fn(() => staleRefresh),
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
status: testStatus("kimi-k3:cloud"),
mappingsApplied: true,
}),
confirm: vi.fn(() => true),
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
focusHandler?.();
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
resolveRefresh?.(testStatus("glm-5.2:cloud"));
await staleRefresh;
await Promise.resolve();
});
const picker = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
expect(picker.findAllByType("span")[0].children.join("")).toBe(
"kimi-k3:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("accepts committed mappings when launching Claude fails", async () => {
class TestHTMLElement {
focus() {}
}
const onDraftChange = vi.fn();
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
applyClaudeDesktopMappings: vi.fn().mockResolvedValue({
status: testStatus("kimi-k3:cloud"),
error:
"Claude model mappings were saved, but Claude Desktop could not open",
mappingsApplied: true,
}),
confirm: vi.fn(() => true),
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
initialLocalModels={[]}
initialStatus={testStatus()}
onDraftChange={onDraftChange}
/>,
);
await Promise.resolve();
});
await selectKimi(renderer!);
await act(async () => {
actionButton(renderer!).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(onDraftChange).toHaveBeenLastCalledWith(false);
const picker = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
expect(picker.findAllByType("span")[0].children.join("")).toBe(
"kimi-k3:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("keeps the previous mappings when reset restart is canceled", async () => {
class TestHTMLElement {
focus() {}
}
const currentStatus = testStatus("kimi-k3:cloud", true);
const resetMappings = vi.fn().mockResolvedValue({
status: currentStatus,
restartConfirmationRequired: true,
});
const confirm = vi.fn(() => false);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
resetClaudeDesktopMappings: resetMappings,
confirm,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
ref={settingsRef}
initialLocalModels={[]}
initialStatus={currentStatus}
/>,
);
await Promise.resolve();
});
let resetSucceeded = true;
await act(async () => {
resetSucceeded =
(await settingsRef.current?.resetToDefaults()) ?? false;
});
expect(resetSucceeded).toBe(false);
expect(confirm).toHaveBeenCalledOnce();
expect(resetMappings).toHaveBeenCalledWith(false);
const picker = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
expect(picker.findAllByType("span")[0].children.join("")).toBe(
"kimi-k3:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("applies the native reset result and shows progress", async () => {
class TestHTMLElement {
focus() {}
}
const initialStatus = {
...testStatus("glm-5.2:cloud"),
mappings: [
{ ...fableRoute, model: "glm-5.2:cloud" },
{
routeId: "claude-sonnet-5",
routeName: "Sonnet 5",
model: "kimi-k3:cloud",
},
],
};
const resetStatus = {
...initialStatus,
mappings: [
{ ...fableRoute },
{
routeId: "claude-sonnet-5",
routeName: "Sonnet 5",
model: "glm-5.2:cloud",
},
],
};
const resetResult = {
status: resetStatus,
mappingsApplied: true,
};
let resolveReset!: (result: typeof resetResult) => void;
const resetRequestResult = new Promise<typeof resetResult>((resolve) => {
resolveReset = resolve;
});
const resetMappings = vi.fn(() => resetRequestResult);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLElement: TestHTMLElement,
resetClaudeDesktopMappings: resetMappings,
});
vi.stubGlobal("document", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
let renderer: ReactTestRenderer | undefined;
const settingsRef = createRef<ClaudeDesktopModelsSettingsHandle>();
try {
await act(async () => {
renderer = create(
<ClaudeDesktopModelsSettings
ref={settingsRef}
initialLocalModels={[]}
initialStatus={initialStatus}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
let resetRequest: Promise<boolean> | undefined;
await act(async () => {
resetRequest = settingsRef.current?.resetToDefaults();
await Promise.resolve();
});
expect(actionButton(renderer!).props.disabled).toBe(true);
expect(textContent(actionButton(renderer!))).toContain("Resetting…");
resolveReset(resetResult);
let resetSucceeded = false;
await act(async () => {
resetSucceeded = (await resetRequest) ?? false;
});
expect(resetSucceeded).toBe(true);
expect(resetMappings).toHaveBeenCalledWith(false);
const fable = renderer!.root.findByProps({
"aria-label": "Ollama model for Fable 5",
});
const sonnet = renderer!.root.findByProps({
"aria-label": "Ollama model for Sonnet 5",
});
expect(fable.findAllByType("span")[0].children.join("")).toBe(
"Select a model",
);
expect(sonnet.findAllByType("span")[0].children.join("")).toBe(
"glm-5.2:cloud",
);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
});
@@ -0,0 +1,177 @@
import type { ClaudeDesktopStatus } from "@/types/webview";
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ClaudeDesktopModelsSettings } from "./ClaudeDesktopModelsSettings";
const routes = [
{ routeId: "claude-fable-5", routeName: "Fable 5" },
{ routeId: "claude-opus-5", routeName: "Opus 5" },
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
{
routeId: "claude-haiku-4-5-20251001",
routeName: "Haiku 4.5",
},
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
];
function status(
overrides: Partial<ClaudeDesktopStatus> = {},
): ClaudeDesktopStatus {
return {
supported: true,
used: true,
installed: true,
configured: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
availability: "available",
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: true,
availability: "available",
},
],
mappings: routes.map((route, index) => ({
...route,
model: index === 0 ? "glm-5.2:cloud" : undefined,
})),
...overrides,
};
}
describe("ClaudeDesktopModelsSettings", () => {
it("labels model plan and account requirements in the picker", () => {
expect(
claudeDesktopModelStatusLabel({
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: false,
requiredPlan: "free",
}),
).toBeNull();
expect(
claudeDesktopModelStatusLabel({
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: false,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
}),
).toBe("Pro plan required");
expect(
claudeDesktopModelStatusLabel({
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
cloud: true,
selected: false,
availability: "unavailable",
reason: "sign_in_required",
requiredPlan: "free",
}),
).toBe("Sign in required");
});
it("renders the five explicit Claude routes and an Ollama model picker", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={status()} />,
);
expect(html).toContain(">Claude</h2>");
for (const route of routes) {
expect(html).toContain(route.routeName);
expect(html).not.toContain(`>${route.routeId}<`);
}
expect((html.match(/aria-haspopup="listbox"/g) ?? []).length).toBe(5);
expect(html).not.toContain('for="claude-route-');
expect(html).toContain(
"Choose which Ollama model Claude uses for each model option.",
);
expect(html).not.toContain("routing");
expect(html).not.toContain("Built-in defaults");
expect(html).not.toContain("Unassigned");
expect(html).toContain("Select a model");
expect(html).toContain("Start Claude");
});
it("allows the same Ollama model to be assigned to multiple routes", () => {
const shared = routes.map((route) => ({
...route,
model: "qwen3:8b",
}));
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={status({ mappings: shared })}
/>,
);
expect((html.match(/>qwen3:8b<\/span>/g) ?? []).length).toBe(5);
});
it("keeps an unavailable default visible with its access status", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={status({
models: [
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
cloud: true,
selected: true,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: false,
availability: "available",
},
],
})}
/>,
);
expect(html).toContain(">glm-5.2:cloud</span>");
});
it("presents Start or Restart based on whether Claude is running", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings
initialStatus={status({ configured: false, connected: false })}
/>,
);
expect(html).toContain("Start Claude");
expect(html).not.toContain("Apply changes");
const runningHTML = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={status({ running: true })} />,
);
expect(runningHTML).toContain("Restart Claude");
expect(runningHTML).toContain("disabled");
});
it("stays hidden until Claude has been enabled once", () => {
const html = renderToStaticMarkup(
<ClaudeDesktopModelsSettings initialStatus={status({ used: false })} />,
);
expect(html).toBe("");
});
});
@@ -0,0 +1,701 @@
import { getClaudeDesktopAvailableModels } from "@/api";
import { Button } from "@/components/ui/button";
import { Description, Field, Label } from "@/components/ui/fieldset";
import { Switch } from "@/components/ui/switch";
import { claudeDesktopRecoveryMessage } from "@/lib/claudeDesktop";
import { claudeDesktopModelStatusLabel } from "@/lib/claudeDesktopModelStatus";
import type {
ClaudeDesktopActionResult,
ClaudeDesktopMappingStatus,
ClaudeDesktopModelStatus,
ClaudeDesktopStatus,
} from "@/types/webview";
import {
ArrowPathIcon,
ArrowRightIcon,
CheckIcon,
ChevronUpDownIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/20/solid";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
export interface ClaudeDesktopModelsSettingsHandle {
resetToDefaults: () => Promise<boolean>;
}
interface ClaudeDesktopModelsSettingsProps {
initialStatus?: ClaudeDesktopStatus;
initialLocalModels?: string[];
initialCloudModels?: string[];
includeCloudModels?: boolean;
onDraftChange?: (hasChanges: boolean) => void;
showSectionHeading?: boolean;
}
const fallbackRoutes: ClaudeDesktopMappingStatus[] = [
{ routeId: "claude-fable-5", routeName: "Fable 5" },
{ routeId: "claude-opus-5", routeName: "Opus 5" },
{ routeId: "claude-sonnet-5", routeName: "Sonnet 5" },
{
routeId: "claude-haiku-4-5-20251001",
routeName: "Haiku 4.5",
},
{ routeId: "claude-sonnet-4-6", routeName: "Sonnet 4.6" },
];
function isInvalidModelName(name: string): boolean {
const normalized = name.trim().toLowerCase().replace(/[-:]+/g, " ");
return normalized === "ollama cloud";
}
function visibleModels(
status: ClaudeDesktopStatus,
): ClaudeDesktopModelStatus[] {
return (status.models ?? []).filter(
(model) => !isInvalidModelName(model.name) && model.reason !== "cloud_off",
);
}
function modelIsAvailable(model: ClaudeDesktopModelStatus): boolean {
return !model.availability || model.availability === "available";
}
function initialMappings(
status: ClaudeDesktopStatus,
): ClaudeDesktopMappingStatus[] {
const models = visibleModels(status);
const known = new Set(models.map((model) => model.name));
const available = new Set(
models.filter(modelIsAvailable).map((model) => model.name),
);
const routes = (
status.mappings?.length ? status.mappings : fallbackRoutes
).map((route) => ({ ...route }));
if (!status.mappings?.length) {
const selected = models.filter(
(model) => model.selected && available.has(model.name),
);
selected.slice(0, routes.length).forEach((model, index) => {
routes[index].model = model.name;
});
}
for (const route of routes) {
if (route.model && !known.has(route.model)) route.model = undefined;
}
if (!routes.some((route) => route.model)) {
const first = models.find(modelIsAvailable);
if (first && routes.length > 0) routes[0].model = first.name;
}
return routes;
}
function mappingsEqual(
left: ClaudeDesktopMappingStatus[],
right: ClaudeDesktopMappingStatus[],
): boolean {
return (
left.length === right.length &&
left.every(
(route, index) =>
route.routeId === right[index]?.routeId &&
(route.model ?? "") === (right[index]?.model ?? ""),
)
);
}
function mappingRecord(
mappings: ClaudeDesktopMappingStatus[],
): Record<string, string> {
return Object.fromEntries(
mappings
.filter((route) => route.model)
.map((route) => [route.routeId, route.model ?? ""]),
);
}
function formatModelList(names: string[]): string {
if (names.length < 2) return names[0] ?? "";
if (names.length === 2) return `${names[0]} or ${names[1]}`;
return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
}
interface ClaudeModelPickerProps {
id: string;
routeName: string;
value?: string;
models: ClaudeDesktopModelStatus[];
disabled: boolean;
onChange: (model: string) => void;
}
function ClaudeModelPicker({
id,
routeName,
value,
models,
disabled,
onChange,
}: ClaudeModelPickerProps) {
return (
<Popover className="relative min-w-0">
<PopoverButton
id={id}
aria-label={`Ollama model for ${routeName}`}
aria-haspopup="listbox"
disabled={disabled}
className="flex min-h-9 w-full items-center gap-2 rounded-lg bg-neutral-50 px-3 py-1.5 text-left text-sm text-neutral-800 outline-none ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-700 dark:text-neutral-100 dark:ring-neutral-600 dark:hover:bg-neutral-600"
>
<span
className={`min-w-0 flex-1 truncate ${value ? "" : "text-neutral-400"}`}
>
{value || "Select a model"}
</span>
<ChevronUpDownIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
</PopoverButton>
<PopoverPanel
anchor={{ to: "bottom end", gap: 8, padding: 8 }}
className="z-50 flex w-[var(--button-width)] min-w-64 flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
>
{({ close }) => (
<ClaudeModelPickerOptions
routeName={routeName}
value={value}
models={models}
onChange={(model) => {
onChange(model);
close();
}}
/>
)}
</PopoverPanel>
</Popover>
);
}
function ClaudeModelPickerOptions({
routeName,
value,
models,
onChange,
}: Pick<
ClaudeModelPickerProps,
"routeName" | "value" | "models" | "onChange"
>) {
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredModels = models.filter((model) =>
model.displayName.toLowerCase().includes(normalizedQuery),
);
useEffect(() => {
searchRef.current?.focus({ preventScroll: true });
}, []);
return (
<>
<div className="flex flex-none items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
<MagnifyingGlassIcon className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Find model..."
aria-label={`Find model for ${routeName}`}
autoCorrect="off"
autoComplete="off"
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
/>
</div>
<div role="listbox" className="min-h-0 overflow-y-auto py-1">
{filteredModels.map((model) => {
const available = modelIsAvailable(model);
const statusLabel = claudeDesktopModelStatusLabel(model);
const selected = value === model.name;
return (
<button
key={model.name}
type="button"
role="option"
aria-selected={selected}
disabled={!available}
onClick={() => onChange(model.name)}
className="flex w-full cursor-pointer items-start gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-45 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60"
>
<span className="mt-0.5 h-4 w-4 flex-shrink-0">
{selected && <CheckIcon className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{model.displayName}</span>
{statusLabel && (
<span className="mt-0.5 block truncate text-xs text-neutral-400">
{statusLabel}
</span>
)}
</span>
</button>
);
})}
{filteredModels.length === 0 && (
<p className="px-3 py-2 text-neutral-400">No models found</p>
)}
</div>
</>
);
}
export const ClaudeDesktopModelsSettings = forwardRef<
ClaudeDesktopModelsSettingsHandle,
ClaudeDesktopModelsSettingsProps
>(function ClaudeDesktopModelsSettings(
{
initialStatus,
initialLocalModels,
initialCloudModels,
includeCloudModels = false,
onDraftChange,
showSectionHeading = true,
},
ref,
) {
const [status, setStatus] = useState<ClaudeDesktopStatus | null>(
initialStatus ?? null,
);
const [models, setModels] = useState<ClaudeDesktopModelStatus[]>(() =>
initialStatus ? visibleModels(initialStatus) : [],
);
const [mappings, setMappings] = useState<ClaudeDesktopMappingStatus[]>(() =>
initialStatus ? initialMappings(initialStatus) : [],
);
const [savedMappings, setSavedMappings] = useState<
ClaudeDesktopMappingStatus[]
>(() => (initialStatus ? initialMappings(initialStatus) : []));
const [localModels, setLocalModels] = useState<string[]>(
initialLocalModels ?? [],
);
const [accountCloudModels, setAccountCloudModels] = useState<string[]>(
initialCloudModels ?? [],
);
const [modelsLoading, setModelsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [resettingMappings, setResettingMappings] = useState(false);
const [autoModeApplying, setAutoModeApplying] = useState(false);
const [autoModeOverride, setAutoModeOverride] = useState<boolean | null>(
null,
);
const draftRef = useRef({ mappings, savedMappings });
const statusRequestRef = useRef(0);
const operationInFlightRef = useRef(false);
draftRef.current = { mappings, savedMappings };
const applyStatus = useCallback(
(next: ClaudeDesktopStatus, preserveDraft = false) => {
const nextMappings = initialMappings(next);
const draft = draftRef.current;
const keepDraft =
preserveDraft && !mappingsEqual(draft.mappings, draft.savedMappings);
setStatus(next);
setModels(visibleModels(next));
if (!keepDraft) {
setMappings(nextMappings);
setSavedMappings(nextMappings);
}
setError(null);
},
[],
);
const refreshStatus = useCallback(async () => {
if (!window.getClaudeDesktopStatus) return;
const request = ++statusRequestRef.current;
try {
const next = await window.getClaudeDesktopStatus();
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
applyStatus(next, true);
}
} catch {
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
setError("Ollama could not read the Claude connection status.");
}
}
}, [applyStatus]);
useEffect(() => {
if (!initialStatus) void refreshStatus();
const handleFocus = () => void refreshStatus();
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [initialStatus, refreshStatus]);
useEffect(() => {
if (initialLocalModels || !status?.used) return;
let cancelled = false;
setModelsLoading(true);
void getClaudeDesktopAvailableModels(includeCloudModels)
.then((installed) => {
if (!cancelled) {
setLocalModels(installed.map((model) => model.model));
setAccountCloudModels(
installed
.filter((model) => model.isCloud())
.map((model) => model.model),
);
}
})
.catch(() => {
if (!cancelled) setError("Ollama could not load your models.");
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [includeCloudModels, initialLocalModels, status?.used]);
const catalogModels = useMemo(() => {
const current = new Set(models.map((model) => model.name));
const installed: ClaudeDesktopModelStatus[] = localModels
.filter((name) => !current.has(name) && !isInvalidModelName(name))
.sort((left, right) => left.localeCompare(right))
.map((name) => ({
name,
displayName: name,
selected: false,
availability: "available",
}));
return [...models, ...installed];
}, [localModels, models]);
const hasDraftChanges = !mappingsEqual(mappings, savedMappings);
const assignedModels = mappings
.map((route) => route.model)
.filter((model): model is string => Boolean(model));
const hasInvalidMapping = assignedModels.some((name) => {
const model = catalogModels.find((candidate) => candidate.name === name);
return !model || !modelIsAvailable(model);
});
const busy = applying || resettingMappings || autoModeApplying;
useEffect(() => {
onDraftChange?.(hasDraftChanges);
}, [hasDraftChanges, onDraftChange]);
const updateMapping = (routeId: string, model: string) => {
setError(null);
setMappings((current) =>
current.map((route) =>
route.routeId === routeId
? { ...route, model: model || undefined }
: route,
),
);
};
const runMappingAction = useCallback(
async (
action: (restartConfirmed: boolean) => Promise<ClaudeDesktopActionResult>,
failureMessage: string,
): Promise<boolean> => {
try {
let result = await action(false);
if (result.restartConfirmationRequired) {
applyStatus(result.status, true);
if (
!window.confirm(
"Restart Claude Desktop? Any running task will stop.",
)
) {
return false;
}
result = await action(true);
}
++statusRequestRef.current;
if (result.error) {
applyStatus(result.status, !result.mappingsApplied);
setError(result.error);
return Boolean(result.mappingsApplied);
}
applyStatus(result.status);
return true;
} catch {
setError(failureMessage);
return false;
}
},
[applyStatus],
);
const applyChanges = async () => {
const applyMappings = window.applyClaudeDesktopMappings;
if (!applyMappings) {
setError(
"Claude routing settings are available in the Ollama macOS app.",
);
return;
}
if (assignedModels.length === 0) {
setError("Choose at least one Ollama model for Claude.");
return;
}
if (hasInvalidMapping) {
setError("Choose models available to your account and device.");
return;
}
if (operationInFlightRef.current) return;
const mappingsToApply = mappingRecord(mappings);
setApplying(true);
setError(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
await runMappingAction(
(restartConfirmed) => applyMappings(mappingsToApply, restartConfirmed),
"Ollama could not apply the Claude model mappings.",
);
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setApplying(false);
}
};
const toggleAutoMode = async (checked: boolean) => {
if (!window.setClaudeDesktopAutoMode) {
setError("Auto mode is available in the Ollama macOS app.");
return;
}
setError(null);
setAutoModeOverride(checked);
setAutoModeApplying(true);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
let result = await window.setClaudeDesktopAutoMode(checked, false);
if (result.restartConfirmationRequired) {
applyStatus(result.status, true);
if (
!window.confirm(
"Restart Claude to change auto mode? Any running task will stop.",
)
) {
return;
}
result = await window.setClaudeDesktopAutoMode(checked, true);
}
++statusRequestRef.current;
applyStatus(result.status);
if (result.error) setError(result.error);
} catch {
setError("Ollama could not update Claude auto mode.");
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setAutoModeOverride(null);
setAutoModeApplying(false);
}
};
const resetToDefaults = useCallback(async (): Promise<boolean> => {
if (operationInFlightRef.current) return false;
const resetMappings = window.resetClaudeDesktopMappings;
if (!resetMappings) {
setError("Ollama could not reset the Claude model mappings.");
return false;
}
setResettingMappings(true);
setError(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
return await runMappingAction(
resetMappings,
"Ollama could not reset the Claude model mappings.",
);
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setResettingMappings(false);
}
}, [runMappingAction]);
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
if (!status?.supported || !status.used) return null;
const autoModeModelNames = Array.from(
new Set([
...models.filter((model) => model.autoMode).map((model) => model.name),
...accountCloudModels,
]),
);
const autoModeModelSet = new Set(autoModeModelNames);
const autoModeAvailable =
!hasDraftChanges &&
assignedModels.length > 0 &&
assignedModels.some((name) => autoModeModelSet.has(name));
const autoMode = autoModeAvailable
? (autoModeOverride ?? status.autoMode ?? false)
: (status.autoMode ?? false);
const autoModeDescription = hasDraftChanges
? "Start or restart Claude to apply model changes before changing auto mode."
: autoModeAvailable
? "Let Claude decide when to ask before making changes."
: accountCloudModels.length > 0
? "Select a cloud model from Ollama.com to use auto mode."
: autoModeModelNames.length > 0
? `Select one of ${formatModelList(autoModeModelNames)} to use auto mode.`
: "Auto mode needs a cloud model available to your Ollama.com account.";
const guidance =
claudeDesktopRecoveryMessage(status.error, error) ??
(hasDraftChanges && status.running
? "Restarting Claude will stop any running task."
: null);
return (
<div
aria-label={showSectionHeading ? undefined : "Claude settings"}
className="space-y-2"
>
{showSectionHeading && (
<h2
id="apps-settings-heading"
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Apps
</h2>
)}
<div
aria-labelledby="claude-settings-heading"
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
>
<div className="flex items-start space-x-3">
<img
src="/launch-icons/claude.svg"
alt=""
className="mt-0.5 h-5 w-5 flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<h2
id="claude-settings-heading"
className="text-sm font-medium text-neutral-900 dark:text-white"
>
Claude
</h2>
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
Choose which Ollama model Claude uses for each model option.
</p>
</div>
<Button
type="button"
color="white"
onClick={applyChanges}
disabled={
busy ||
assignedModels.length === 0 ||
hasInvalidMapping ||
(status.running && !hasDraftChanges)
}
className="flex-shrink-0"
>
{(applying || resettingMappings) && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{resettingMappings
? "Resetting…"
: applying
? status.running
? "Restarting…"
: "Starting…"
: status.running
? "Restart Claude"
: "Start Claude"}
</Button>
</div>
<div className="mt-4 w-full max-w-xl space-y-1">
{mappings.map((mapping) => (
<div
key={mapping.routeId}
className="relative grid min-h-12 grid-cols-[5.5rem_3.75rem_minmax(0,1fr)] items-center gap-2 py-1 max-sm:grid-cols-1 max-sm:gap-2"
>
<div className="min-w-0">
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
{mapping.routeName}
</span>
</div>
<ArrowRightIcon
aria-hidden="true"
className="absolute left-[6.6625rem] h-4 w-4 -translate-x-1/2 text-neutral-300 dark:text-neutral-500 max-sm:hidden"
/>
<div className="col-start-3 w-2/3 min-w-0 max-sm:col-start-auto max-sm:w-full">
<ClaudeModelPicker
id={`claude-route-${mapping.routeId}`}
routeName={mapping.routeName}
value={mapping.model ?? ""}
disabled={busy || modelsLoading}
models={catalogModels}
onChange={(model) =>
updateMapping(mapping.routeId, model)
}
/>
</div>
</div>
))}
</div>
<Field className="mt-3 w-full max-w-xl border-t border-neutral-200 pt-3 dark:border-neutral-700">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<Label>Enable auto mode</Label>
<Description>{autoModeDescription}</Description>
</div>
<Switch
checked={autoMode}
disabled={busy || !autoModeAvailable}
onChange={(checked) => void toggleAutoMode(checked)}
className="flex-shrink-0"
/>
</div>
</Field>
{guidance && (
<p
role={error || status.error ? "alert" : "status"}
className="mt-3 w-full max-w-xl text-xs leading-5 text-neutral-500 dark:text-neutral-400"
>
{guidance}
</p>
)}
</div>
</div>
</div>
</div>
);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,645 @@
import { Button } from "@/components/ui/button";
import type {
CodexDesktopModelStatus,
CodexDesktopModelsSettings as ModelsSettings,
CodexDesktopModelsSettingsResult,
CodexDesktopStatus,
} from "@/types/webview";
import {
ArrowPathIcon,
CheckIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
export interface CodexDesktopModelsSettingsHandle {
resetToDefaults: () => Promise<boolean>;
}
interface CodexDesktopModelsSettingsProps {
initialSettings?: ModelsSettings;
accountKey?: string;
onDraftChange?: (hasChanges: boolean) => void;
}
const CHATGPT_OPEN_POLL_INTERVAL_MS = 250;
const CHATGPT_OPEN_TIMEOUT_MS = 30_000;
function wait(milliseconds: number): Promise<void> {
return new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
}
async function waitForChatGPTToOpen(): Promise<
CodexDesktopStatus | null | undefined
> {
const getStatus = window.getCodexDesktopStatus;
if (!getStatus) return undefined;
const deadline = Date.now() + CHATGPT_OPEN_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const status = await getStatus();
if (status.running) return status;
} catch {
// ChatGPT may be between processes during a restart. Keep checking until
// it opens or the launch timeout expires.
}
await wait(CHATGPT_OPEN_POLL_INTERVAL_MS);
}
return null;
}
function selectionsEqual(
left: string[] | null | undefined,
right: string[] | null | undefined,
): boolean {
left ??= [];
right ??= [];
return (
left.length === right.length &&
left.every((model, index) => model === right[index])
);
}
function normalizeSettings(
settings: ModelsSettings & {
selected?: string[] | null;
available?: string[] | null;
models?: CodexDesktopModelStatus[] | null;
},
): ModelsSettings {
const available = settings.available ?? [];
return {
...settings,
usesDefaults: settings.usesDefaults ?? false,
selected: settings.selected ?? [],
available,
models:
settings.models ??
available.map((name) => ({
name,
displayName: name,
selected: settings.selected?.includes(name) ?? false,
availability: "available" as const,
})),
maxModels: settings.maxModels || 5,
};
}
function modelIsAvailable(model: CodexDesktopModelStatus): boolean {
return !model.availability || model.availability === "available";
}
function modelCanBeSelected(model: CodexDesktopModelStatus): boolean {
return model.recommended || modelIsAvailable(model);
}
function modelStatusLabel(model: CodexDesktopModelStatus): string | null {
switch (model.reason) {
case "cloud_off":
return "Cloud models are off";
case "sign_in_required":
return "Sign in required";
case "upgrade_required":
return model.requiredPlan
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
: "Upgrade required";
case "verification_unavailable":
return "Access unavailable";
case "model_not_installed":
return "Not installed";
}
return null;
}
function ModelOptions({
models,
selected,
maxModels,
onToggle,
}: {
models: CodexDesktopModelStatus[];
selected: string[];
maxModels: number;
onToggle: (model: string) => void;
}) {
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const searchRef = useRef<HTMLInputElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selectedSet = useMemo(() => new Set(selected), [selected]);
const normalizedQuery = query.trim().toLowerCase();
const filtered = models.filter((model) =>
`${model.displayName} ${model.name}`
.toLowerCase()
.includes(normalizedQuery),
);
useEffect(() => {
searchRef.current?.focus({ preventScroll: true });
}, []);
useEffect(() => {
setHighlightedIndex(-1);
}, [normalizedQuery]);
useEffect(() => {
if (highlightedIndex < 0) return;
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" });
}, [highlightedIndex]);
const optionIsDisabled = (model: CodexDesktopModelStatus) =>
!modelCanBeSelected(model) ||
(!selectedSet.has(model.name) && selected.length >= maxModels);
const moveHighlight = (direction: -1 | 1) => {
setHighlightedIndex((current) => {
if (filtered.length === 0) return -1;
const start = current < 0 ? (direction === 1 ? -1 : 0) : current;
for (let offset = 1; offset <= filtered.length; offset += 1) {
const candidate =
(start + direction * offset + filtered.length) % filtered.length;
if (!optionIsDisabled(filtered[candidate])) return candidate;
}
return -1;
});
};
return (
<>
<div className="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
<MagnifyingGlassIcon className="h-4 w-4 shrink-0 text-neutral-400" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
moveHighlight(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
moveHighlight(-1);
} else if (
event.key === "Enter" &&
highlightedIndex >= 0 &&
highlightedIndex < filtered.length
) {
event.preventDefault();
onToggle(filtered[highlightedIndex].name);
}
}}
placeholder="Find model..."
aria-label="Find ChatGPT model"
role="combobox"
aria-expanded="true"
aria-controls="chatgpt-model-options-listbox"
aria-activedescendant={
highlightedIndex >= 0
? `chatgpt-model-option-${highlightedIndex}`
: undefined
}
autoCorrect="off"
autoComplete="off"
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
/>
</div>
<div
id="chatgpt-model-options-listbox"
role="listbox"
aria-multiselectable="true"
className="min-h-0 overflow-y-auto py-1"
>
{filtered.map((model, index) => {
const checked = selectedSet.has(model.name);
const disabled = optionIsDisabled(model);
const statusLabel = modelStatusLabel(model);
return (
<button
key={model.name}
id={`chatgpt-model-option-${index}`}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
role="option"
aria-selected={checked}
disabled={disabled}
onClick={() => onToggle(model.name)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60 ${
highlightedIndex === index
? "bg-neutral-100 dark:bg-neutral-700/60"
: ""
}`}
>
<span className="h-4 w-4 shrink-0">
{checked && <CheckIcon className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{model.displayName}</span>
{statusLabel && (
<span className="mt-0.5 block truncate text-xs text-neutral-400">
{statusLabel}
</span>
)}
</span>
</button>
);
})}
{filtered.length === 0 && (
<p className="px-3 py-2 text-neutral-400">No models found</p>
)}
</div>
</>
);
}
export const CodexDesktopModelsSettings = forwardRef<
CodexDesktopModelsSettingsHandle,
CodexDesktopModelsSettingsProps
>(function CodexDesktopModelsSettings(
{ initialSettings, accountKey, onDraftChange },
ref,
) {
const normalizedInitialSettings = initialSettings
? normalizeSettings(initialSettings)
: null;
const [settings, setSettings] = useState<ModelsSettings | null>(
normalizedInitialSettings,
);
const [selected, setSelected] = useState<string[]>(
normalizedInitialSettings?.selected ?? [],
);
const [saved, setSaved] = useState<string[]>(
normalizedInitialSettings?.selected ?? [],
);
const [loading, setLoading] = useState(!initialSettings);
const [applying, setApplying] = useState(false);
const [resetting, setResetting] = useState(false);
const [launchAction, setLaunchAction] = useState<"start" | "restart">(
"start",
);
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const draftRef = useRef({ selected, saved });
const accountKeyRef = useRef(accountKey);
const statusRequestRef = useRef(0);
const operationInFlightRef = useRef(false);
draftRef.current = { selected, saved };
const applyResult = useCallback(
(result: CodexDesktopModelsSettingsResult, preserveDraft = false) => {
const nextSettings = normalizeSettings(result.settings);
const keepDraft =
preserveDraft &&
!selectionsEqual(draftRef.current.selected, draftRef.current.saved);
setSettings(nextSettings);
if (!keepDraft) {
setSelected(nextSettings.selected);
setSaved(nextSettings.selected);
}
setError(result.error ?? null);
setWarning(result.warning ?? null);
},
[],
);
const refresh = useCallback(async () => {
if (!window.getCodexDesktopModelsSettings) {
setError("ChatGPT model settings are unavailable in this Ollama build.");
setWarning(null);
setLoading(false);
return;
}
const request = ++statusRequestRef.current;
try {
const result = await window.getCodexDesktopModelsSettings();
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
applyResult(result, true);
}
} catch {
if (
request === statusRequestRef.current &&
!operationInFlightRef.current
) {
setError("Ollama could not load the ChatGPT model settings.");
setWarning(null);
}
} finally {
if (request === statusRequestRef.current) setLoading(false);
}
}, [applyResult]);
useEffect(() => {
if (!initialSettings) void refresh();
const onFocus = () => void refresh();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [initialSettings, refresh]);
useEffect(() => {
if (accountKeyRef.current === accountKey) return;
accountKeyRef.current = accountKey;
void refresh();
}, [accountKey, refresh]);
const hasChanges = !selectionsEqual(selected, saved);
useEffect(() => {
onDraftChange?.(hasChanges);
}, [hasChanges, onDraftChange]);
const maxModels = settings?.maxModels ?? 5;
const models = useMemo(() => {
const catalog = [...(settings?.models ?? [])];
const known = new Set(catalog.map((model) => model.name));
for (const name of selected) {
if (known.has(name)) continue;
known.add(name);
catalog.push({
name,
displayName: name,
selected: true,
availability: "unknown",
reason: "verification_unavailable",
});
}
return catalog;
}, [selected, settings?.models]);
const displayNames = useMemo(
() => new Map(models.map((model) => [model.name, model.displayName])),
[models],
);
const toggleModel = (model: string) => {
setError(null);
setWarning(null);
setSelected((current) => {
if (current.includes(model)) {
return current.filter((name) => name !== model);
}
if (current.length >= maxModels) return current;
return [...current, model];
});
};
const applyChanges = async () => {
if (!window.applyCodexDesktopModels) {
setError("ChatGPT model settings are available in the Ollama macOS app.");
return;
}
if (selected.length === 0) {
setError("Choose at least one model for ChatGPT.");
return;
}
if (operationInFlightRef.current) return;
setApplying(true);
setLaunchAction(settings?.running ? "restart" : "start");
setError(null);
setWarning(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const modelsToApply =
!hasChanges && settings?.usesDefaults ? [] : selected;
let result = await window.applyCodexDesktopModels(modelsToApply, false);
if (result.restartConfirmationRequired) {
applyResult(result, true);
if (
!window.confirm(
result.settings.connected
? "Restart ChatGPT to update Ollama models? Any running task will stop."
: "Restart ChatGPT to add Ollama models? Any running task will stop.",
)
) {
return;
}
result = await window.applyCodexDesktopModels(modelsToApply, true);
}
++statusRequestRef.current;
if (result.error) {
setSettings(normalizeSettings(result.settings));
setError(result.error);
setWarning(result.warning ?? null);
return;
}
applyResult(result);
const openedStatus = await waitForChatGPTToOpen();
if (openedStatus === null) {
setError("ChatGPT is taking longer than expected to open. Try again.");
return;
}
if (openedStatus) {
setSettings((current) =>
current
? {
...current,
installed: openedStatus.installed,
connected: openedStatus.connected,
running: openedStatus.running,
}
: current,
);
}
} catch {
setError("Ollama could not apply the ChatGPT models.");
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setApplying(false);
}
};
const resetToDefaults = useCallback(async (): Promise<boolean> => {
if (operationInFlightRef.current) return false;
const resetModels = window.resetCodexDesktopModels;
if (!resetModels) {
setError("Ollama could not reset the ChatGPT models.");
return false;
}
setResetting(true);
setError(null);
setWarning(null);
operationInFlightRef.current = true;
++statusRequestRef.current;
try {
const result = await resetModels();
++statusRequestRef.current;
if (result.error) {
setSettings(normalizeSettings(result.settings));
setError(result.error);
setWarning(result.warning ?? null);
return false;
}
applyResult(result);
return true;
} catch {
setError("Ollama could not reset the ChatGPT models.");
return false;
} finally {
++statusRequestRef.current;
operationInFlightRef.current = false;
setResetting(false);
}
}, [applyResult]);
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
if (!settings?.supported && !loading && !error) return null;
const busy = applying || resetting;
return (
<div
aria-labelledby="chatgpt-model-settings-heading"
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
>
<div className="flex items-start space-x-3">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
<img
src="/launch-icons/codex.svg"
alt=""
className="h-5 w-5 dark:hidden"
/>
<img
src="/launch-icons/codex-dark.svg"
alt=""
className="hidden h-5 w-5 dark:block"
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<h2
id="chatgpt-model-settings-heading"
className="text-sm font-medium text-neutral-900 dark:text-white"
>
ChatGPT
</h2>
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
Choose up to {maxModels} Ollama models to use in ChatGPT.
</p>
</div>
<div className="shrink-0">
<Button
type="button"
color="white"
onClick={() => void applyChanges()}
disabled={loading || busy || selected.length === 0}
>
{applying && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{applying
? launchAction === "restart"
? "Restarting…"
: "Starting…"
: settings?.running
? hasChanges
? "Save & restart ChatGPT"
: "Restart ChatGPT"
: hasChanges
? "Save & start ChatGPT"
: "Start ChatGPT"}
</Button>
</div>
</div>
<div className="mt-4 w-full max-w-xl">
<Popover className="relative w-full">
<div
data-testid="chatgpt-model-picker"
className="relative flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg bg-neutral-50 px-2 py-1.5 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
>
<PopoverButton
aria-label="Add ChatGPT model"
disabled={loading || busy}
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed"
>
<span className="sr-only">Choose ChatGPT models</span>
</PopoverButton>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{selected.map((model) => (
<span
key={model}
className="pointer-events-none inline-flex max-w-full items-stretch overflow-hidden rounded-md bg-neutral-200/70 text-sm text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
>
<span className="min-w-0 py-1 pl-2 pr-1">
<span className="block truncate">
{displayNames.get(model) ?? model}
</span>
</span>
<button
type="button"
aria-label={`Remove ${model}`}
disabled={busy}
onClick={(event) => {
event.stopPropagation();
toggleModel(model);
}}
className="pointer-events-auto inline-flex shrink-0 items-center px-1.5 hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50 dark:hover:bg-neutral-500"
>
<XMarkIcon className="h-3.5 w-3.5" />
</button>
</span>
))}
{selected.length === 0 && (
<span className="px-1 py-1 text-sm text-neutral-400">
{loading ? "Loading models…" : "Select models"}
</span>
)}
</div>
</div>
<PopoverPanel
anchor={{ to: "bottom start", gap: 8, padding: 8 }}
data-testid="chatgpt-model-options"
className="z-50 flex w-[var(--button-width)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
>
<ModelOptions
models={models}
selected={selected}
maxModels={maxModels}
onToggle={toggleModel}
/>
</PopoverPanel>
</Popover>
</div>
{error && (
<p
role="alert"
className="mt-3 w-full max-w-xl text-xs leading-5 text-red-600 dark:text-red-400"
>
{error}
</p>
)}
{warning && (
<p
role="status"
className="mt-3 w-full max-w-xl text-xs leading-5 text-zinc-500 dark:text-zinc-400"
>
{warning}
</p>
)}
</div>
</div>
</div>
);
});
@@ -0,0 +1,558 @@
import type { IntegrationStatus } from "@/api";
import type { CodexDesktopStatus } from "@/types/webview";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CodexDesktopRow } from "./CodexDesktopRow";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const integration: IntegrationStatus = {
id: "chatgpt",
name: "ChatGPT",
description: "Use Ollama models in ChatGPT",
installed: true,
command: "ollama launch chatgpt",
};
function status(
overrides: Partial<CodexDesktopStatus> = {},
): CodexDesktopStatus {
return {
supported: true,
installed: true,
connected: false,
running: false,
...overrides,
};
}
describe("CodexDesktopRow", () => {
it("renders a disconnected ChatGPT toggle", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow integration={integration} initialStatus={status()} />,
);
expect(html).toContain(">ChatGPT (Desktop)</p>");
expect(html).toContain("Use Ollama models in ChatGPT");
expect(html).toContain('aria-label="Add Ollama models to ChatGPT"');
expect(html).toContain('aria-checked="false"');
});
it("shows only the Ollama request count when connected", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow
integration={integration}
initialStatus={status({
connected: true,
model: "qwen3:8b",
models: ["qwen3:8b", "glm-5.3-flash:cloud", "kimi-k2.7-code:cloud"],
})}
/>,
);
expect(html).toContain("0 Ollama requests this session");
expect(html).not.toContain("Codex + Ollama");
expect(html).not.toContain("3 Ollama models");
expect(html).toContain('aria-label="Remove Ollama models from ChatGPT"');
expect(html).toContain('aria-checked="true"');
});
it("shows the Ollama request count with singular copy", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow
integration={integration}
initialStatus={status({
connected: true,
model: "qwen3:8b",
requests: 1,
})}
/>,
);
expect(html).toContain("1 Ollama request this session");
});
it("offers installation when ChatGPT is not installed", () => {
const html = renderToStaticMarkup(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
expect(html).toContain("Use Ollama models in ChatGPT");
expect(html).not.toContain('disabled=""');
expect(html).toContain('title="Install ChatGPT and add Ollama models"');
expect(html).toContain("Download &amp; connect");
});
it("matches Claude's download and install progress states", async () => {
const notInstalled = status({ installed: false });
let finishInstall!: (result: "opened") => void;
const install = new Promise<"opened">((resolve) => {
finishInstall = resolve;
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
getCodexDesktopStatus: vi.fn().mockResolvedValue(notInstalled),
setCodexDesktopConnected: vi.fn(),
installCodexDesktop: vi.fn().mockReturnValue(install),
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={notInstalled}
/>,
);
});
const toggle = renderer!.root.findByProps({ role: "switch" });
await act(async () => {
toggle.props.onClick();
await Promise.resolve();
});
expect(toggle.props["aria-checked"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
expect(toggle.props.className).toContain("disabled:cursor-wait");
expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
"Downloading…",
);
expect(
renderer!.root.findAll((node) =>
node.children.includes(
"Ollama is downloading the ChatGPT installer…",
),
),
).toHaveLength(1);
await act(async () => {
finishInstall("opened");
await Promise.resolve();
await Promise.resolve();
});
expect(toggle.props["aria-checked"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
"Finish installing…",
);
expect(
renderer!.root.findAll((node) =>
node.children.includes(
"Finish installing ChatGPT. Ollama will connect it automatically.",
),
),
).toHaveLength(1);
} finally {
await act(async () => renderer?.unmount());
}
});
it("matches Claude's connecting state", async () => {
let finishConnect!: (result: { status: CodexDesktopStatus }) => void;
const connect = new Promise<{ status: CodexDesktopStatus }>((resolve) => {
finishConnect = resolve;
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setCodexDesktopConnected: vi.fn().mockReturnValue(connect),
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={integration}
initialStatus={status()}
/>,
);
});
const toggle = renderer!.root.findByProps({ role: "switch" });
await act(async () => {
toggle.props.onClick();
await Promise.resolve();
});
expect(toggle.props["aria-checked"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
"Connecting…",
);
expect(
renderer!.root.findAll((node) =>
node.children.includes("Connecting ChatGPT to Ollama…"),
),
).toHaveLength(1);
await act(async () => {
finishConnect({ status: status({ connected: true }) });
await connect;
});
} finally {
await act(async () => renderer?.unmount());
}
});
it("opens the installer and connects after ChatGPT is detected", async () => {
const installedStatus = status({ installed: true });
const connectedStatus = status({
installed: true,
connected: true,
models: ["glm-5.3-flash:cloud"],
});
const openInstaller = vi.fn().mockResolvedValue("opened");
const getStatus = vi.fn().mockResolvedValue(installedStatus);
const connect = vi.fn().mockResolvedValue({ status: connectedStatus });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
getCodexDesktopStatus: getStatus,
setCodexDesktopConnected: connect,
installCodexDesktop: openInstaller,
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(openInstaller).toHaveBeenCalledOnce();
expect(getStatus).toHaveBeenCalled();
expect(connect).toHaveBeenCalledWith(true, false);
expect(
renderer!.root.findByProps({
"aria-label": "Remove Ollama models from ChatGPT",
}).props["aria-checked"],
).toBe(true);
expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
"Ollama models added alongside Codex models",
);
} finally {
await act(async () => renderer?.unmount());
}
});
it("does not restart ChatGPT automatically when installation detection finds it running", async () => {
const installedAndRunning = status({ installed: true, running: true });
const connect = vi.fn();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
getCodexDesktopStatus: vi.fn().mockResolvedValue(installedAndRunning),
setCodexDesktopConnected: connect,
installCodexDesktop: vi.fn().mockResolvedValue("opened"),
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(connect).not.toHaveBeenCalled();
expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
);
expect(
renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
}).props["aria-checked"],
).toBe(false);
} finally {
await act(async () => renderer?.unmount());
}
});
it("returns to the disconnected state when installation is cancelled", async () => {
const getStatus = vi.fn();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getCodexDesktopStatus: getStatus,
setCodexDesktopConnected: vi.fn(),
installCodexDesktop: vi.fn().mockResolvedValue("cancelled"),
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
});
expect(getStatus).not.toHaveBeenCalled();
expect(toggle.props["aria-checked"]).toBe(false);
expect(toggle.props.disabled).toBe(false);
expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0);
} finally {
await act(async () => renderer?.unmount());
}
});
it("uses concise restart copy when adding Ollama models", async () => {
const confirm = vi.fn(() => false);
const runningStatus = status({ running: true });
const connect = vi.fn().mockResolvedValue({
status: runningStatus,
restartConfirmationRequired: true,
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setCodexDesktopConnected: connect,
confirm,
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
});
expect(confirm).toHaveBeenCalledWith(
"Restart ChatGPT to add Ollama models? Any running task will stop.",
);
expect(connect).toHaveBeenCalledOnce();
expect(connect).toHaveBeenCalledWith(true, false);
} finally {
await act(async () => renderer?.unmount());
}
});
it("adds Ollama models after the restart is confirmed", async () => {
const confirm = vi.fn(() => true);
const connectedStatus = status({
connected: true,
running: true,
models: ["glm-5.3-flash:cloud"],
});
const connect = vi
.fn()
.mockResolvedValueOnce({
status: status({ running: true }),
restartConfirmationRequired: true,
})
.mockResolvedValueOnce({ status: connectedStatus });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
setCodexDesktopConnected: connect,
confirm,
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
});
expect(confirm).toHaveBeenCalledWith(
"Restart ChatGPT to add Ollama models? Any running task will stop.",
);
expect(connect).toHaveBeenNthCalledWith(1, true, false);
expect(connect).toHaveBeenNthCalledWith(2, true, true);
expect(
renderer!.root.findByProps({
"aria-label": "Remove Ollama models from ChatGPT",
}).props["aria-checked"],
).toBe(true);
} finally {
await act(async () => renderer?.unmount());
}
});
it("keeps a native restart failure visible after confirmation", async () => {
let onFocus: (() => void) | undefined;
const addEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "focus") onFocus = handler;
});
const getStatus = vi.fn().mockResolvedValue(status({ running: true }));
const confirm = vi.fn(() => {
onFocus?.();
return true;
});
const connect = vi
.fn()
.mockResolvedValueOnce({
status: status({ running: true }),
restartConfirmationRequired: true,
})
.mockResolvedValueOnce({
status: status({ running: true }),
error: "quit ChatGPT: timed out waiting for ChatGPT to exit",
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener,
removeEventListener: vi.fn(),
getCodexDesktopStatus: getStatus,
setCodexDesktopConnected: connect,
confirm,
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={integration}
initialStatus={status({ running: true })}
/>,
);
});
const toggle = renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
});
await act(async () => {
await toggle.props.onClick();
});
expect(getStatus).not.toHaveBeenCalled();
expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
"quit ChatGPT: timed out waiting for ChatGPT to exit",
);
expect(toggle.props["aria-checked"]).toBe(false);
} finally {
await act(async () => renderer?.unmount());
}
});
it("allows the normal profile to be restored if ChatGPT is removed", async () => {
const html = renderToStaticMarkup(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false, connected: true })}
/>,
);
expect(html).toContain('aria-label="Remove Ollama models from ChatGPT"');
expect(html).not.toContain('disabled=""');
const restore = vi.fn().mockResolvedValue({
status: status({ installed: false, connected: false }),
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setCodexDesktopConnected: restore,
confirm: vi.fn(() => true),
});
let renderer;
try {
await act(async () => {
renderer = create(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false, connected: true })}
/>,
);
});
const restoreButton = renderer!.root.findByProps({
"aria-label": "Remove Ollama models from ChatGPT",
});
await act(async () => {
await restoreButton.props.onClick();
});
expect(restore).toHaveBeenCalledWith(false, false);
} finally {
await act(async () => renderer?.unmount());
}
});
});
@@ -0,0 +1,387 @@
import type { IntegrationStatus } from "@/api";
import { INTEGRATION_ICONS } from "@/lib/launchCommands";
import type {
CodexDesktopActionResult,
CodexDesktopInstallResult,
CodexDesktopStatus,
} from "@/types/webview";
import { ArrowPathIcon, CommandLineIcon } from "@heroicons/react/24/outline";
import { useCallback, useEffect, useRef, useState } from "react";
export const CODEX_DESKTOP_INSTALL_TIMEOUT_MS = 120_000;
type CodexConnectPhase =
| "idle"
| "installing"
| "waiting-for-install"
| "connecting"
| "disconnecting";
interface CodexDesktopRowProps {
integration: IntegrationStatus;
initialStatus?: CodexDesktopStatus;
}
function CodexIcon({ integration }: { integration: IntegrationStatus }) {
const icon = INTEGRATION_ICONS[integration.id];
return (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent">
{icon ? (
<>
<img
src={icon.src}
alt=""
className={`${icon.className ?? "h-7 w-7"} rounded-sm object-contain ${icon.darkSrc ? "dark:hidden" : ""}`}
/>
{icon.darkSrc && (
<img
src={icon.darkSrc}
alt=""
className={`${icon.className ?? "h-7 w-7"} hidden rounded-sm object-contain dark:block`}
/>
)}
</>
) : (
<CommandLineIcon className="h-6 w-6 stroke-[1.5] text-neutral-700 dark:text-neutral-300" />
)}
</div>
);
}
function codexDesktopDescription(
status: CodexDesktopStatus | null,
defaultDescription: string,
): string {
if (!status?.connected) return defaultDescription;
const requestCount = status.requests ?? 0;
return `${requestCount} Ollama ${requestCount === 1 ? "request" : "requests"} this session`;
}
export function CodexDesktopRow({
integration,
initialStatus,
}: CodexDesktopRowProps) {
const [status, setStatus] = useState<CodexDesktopStatus | null>(
initialStatus ?? null,
);
const [phase, setPhase] = useState<CodexConnectPhase>("idle");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const mounted = useRef(true);
const operationInFlight = useRef(false);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const refreshStatus = useCallback(async () => {
if (operationInFlight.current || !window.getCodexDesktopStatus) return;
try {
const next = await window.getCodexDesktopStatus();
setStatus(next);
setError(null);
setNotice(null);
} catch {
setError("Ollama could not read the ChatGPT connection status.");
}
}, []);
useEffect(() => {
if (!initialStatus) void refreshStatus();
const onFocus = () => void refreshStatus();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [initialStatus, refreshStatus]);
useEffect(() => {
if (!status?.connected || !window.getCodexDesktopRequestCount) return;
let active = true;
let checking = false;
const refreshRequestCount = async () => {
if (!active || checking || document.visibilityState === "hidden") return;
checking = true;
try {
const requests = await window.getCodexDesktopRequestCount?.();
if (!active || requests === undefined) return;
setStatus((current) => {
if (!current || current.requests === requests) return current;
return { ...current, requests };
});
} catch {
// The next interval or window-focus refresh can recover the count.
} finally {
checking = false;
}
};
void refreshRequestCount();
const interval = window.setInterval(refreshRequestCount, 1000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [status?.connected]);
useEffect(() => {
if (phase !== "waiting-for-install") return;
let active = true;
let checking = false;
let completing = false;
const checkForInstall = async () => {
if (
!active ||
checking ||
completing ||
!window.getCodexDesktopStatus ||
!window.setCodexDesktopConnected
) {
return;
}
checking = true;
try {
const next = await window.getCodexDesktopStatus();
if (!active || !mounted.current) return;
setStatus(next);
if (!next.installed) return;
completing = true;
if (next.running) {
setPhase("idle");
setError(
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
);
return;
}
setPhase("connecting");
const result = await window.setCodexDesktopConnected(true, false);
if (!mounted.current) return;
setStatus(result.status);
if (result.restartConfirmationRequired) {
setError(
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
);
} else if (result.error || !result.status.connected) {
setError(
result.error || "Ollama could not add its models to ChatGPT.",
);
} else {
setNotice("Ollama models added alongside Codex models");
}
setPhase("idle");
} catch {
if (!mounted.current) return;
setPhase("idle");
setError("Ollama could not finish connecting ChatGPT.");
} finally {
checking = false;
}
};
void checkForInstall();
const interval = window.setInterval(checkForInstall, 1000);
const timeout = window.setTimeout(() => {
if (!active || completing) return;
setPhase("idle");
setError("ChatGPT installation wasnt detected. Try again.");
}, CODEX_DESKTOP_INSTALL_TIMEOUT_MS);
return () => {
active = false;
window.clearInterval(interval);
window.clearTimeout(timeout);
};
}, [phase]);
const connected = status?.connected ?? false;
const installed = status?.installed ?? integration.installed ?? false;
const pending = phase !== "idle";
const displayedConnected =
phase === "disconnecting"
? false
: connected ||
phase === "installing" ||
phase === "waiting-for-install" ||
phase === "connecting";
const isConnecting = phase !== "idle";
const statusLabel =
phase === "installing"
? "Downloading…"
: phase === "waiting-for-install"
? "Finish installing…"
: phase === "connecting"
? "Connecting…"
: phase === "disconnecting"
? "Disconnecting…"
: !connected && !installed
? "Download & connect"
: null;
const description =
error ??
notice ??
(phase === "installing"
? "Ollama is downloading the ChatGPT installer…"
: phase === "waiting-for-install"
? "Finish installing ChatGPT. Ollama will connect it automatically."
: phase === "connecting"
? "Connecting ChatGPT to Ollama…"
: phase === "disconnecting"
? "Restoring ChatGPTs usual connection…"
: codexDesktopDescription(status, integration.description));
const toggleConnection = async () => {
if (pending || operationInFlight.current) return;
if (!window.setCodexDesktopConnected) {
setError("The ChatGPT integration is unavailable.");
return;
}
const enabled = !connected;
if (enabled && !installed) {
if (!window.installCodexDesktop || !window.getCodexDesktopStatus) {
setError("Ollama could not install ChatGPT.");
return;
}
setPhase("installing");
setError(null);
setNotice(null);
let installResult: CodexDesktopInstallResult = "failed";
try {
installResult = await window.installCodexDesktop();
} catch {
// The shared failure message below covers a rejected native request.
}
if (installResult === "cancelled") {
setPhase("idle");
return;
}
if (installResult !== "opened") {
setPhase("idle");
setError("Ollama could not install ChatGPT.");
return;
}
setPhase("waiting-for-install");
return;
}
const nextPhase = enabled ? "connecting" : "disconnecting";
operationInFlight.current = true;
setPhase(nextPhase);
setError(null);
setNotice(null);
try {
let result: CodexDesktopActionResult =
await window.setCodexDesktopConnected(enabled, false);
setStatus(result.status);
if (result.restartConfirmationRequired) {
// Keep focus-driven status refreshes from discarding this operation
// while the native confirmation dialog temporarily owns focus.
if (
!window.confirm(
enabled
? "Restart ChatGPT to add Ollama models? Any running task will stop."
: "Restart ChatGPT to remove Ollama models? Any running task will stop.",
)
) {
return;
}
setPhase(nextPhase);
result = await window.setCodexDesktopConnected(enabled, true);
setStatus(result.status);
}
if (result.error) {
setError(result.error);
return;
}
if (result.status.connected !== enabled) {
setError(
enabled
? "Ollama could not add its models to ChatGPT."
: "Ollama could not remove its models from ChatGPT.",
);
return;
}
setNotice(
enabled
? "Ollama models added alongside Codex models"
: "Ollama models removed · Codex models remain available",
);
} catch {
setError(
enabled
? "Ollama could not add its models to ChatGPT."
: "Ollama could not remove its models from ChatGPT.",
);
} finally {
operationInFlight.current = false;
if (mounted.current) setPhase("idle");
}
};
return (
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
<div className="flex min-w-0 items-center gap-3">
<CodexIcon integration={integration} />
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
ChatGPT (Desktop)
</p>
<p
role={error ? "alert" : notice ? "status" : undefined}
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
>
{description}
</p>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2.5">
{statusLabel && (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
>
{isConnecting && (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
)}
{statusLabel}
</span>
)}
<button
type="button"
role="switch"
aria-checked={displayedConnected}
aria-busy={pending || undefined}
aria-label={
connected
? "Remove Ollama models from ChatGPT"
: isConnecting
? "Connecting ChatGPT"
: "Add Ollama models to ChatGPT"
}
title={
connected
? "Remove Ollama models"
: installed
? "Add Ollama models"
: "Install ChatGPT and add Ollama models"
}
disabled={pending}
onClick={() => void toggleConnection()}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${displayedConnected ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${pending ? "animate-pulse" : ""} ${displayedConnected ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
/>
</button>
</div>
</div>
);
}
+1 -1
View File
@@ -68,7 +68,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
const iconSize = size === "sm" ? "h-3 w-3" : "h-7 w-7";
const baseClasses =
size === "sm"
? `text-xs px-4 py-2 z-10 rounded-lg hover:cursor-pointer ${className}`
? `text-xs px-4 py-2 z-10 cursor-pointer rounded-lg ${className}`
: `${iconSize} px-1 py-0.5 text-xs cursor-pointer rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center ${className}`;
const icon = isCopied ? (
@@ -1,158 +0,0 @@
import { useSettings } from "@/hooks/useSettings";
import CopyButton from "@/components/CopyButton";
interface LaunchCommand {
id: string;
name: string;
command: string;
description: string;
icon: string;
darkIcon?: string;
iconClassName?: string;
borderless?: boolean;
}
const LAUNCH_COMMANDS: LaunchCommand[] = [
{
id: "claude",
name: "Claude Code",
command: "ollama launch claude",
description: "Anthropic's coding tool with subagents",
icon: "/launch-icons/claude-code.svg",
iconClassName: "h-7 w-7",
},
{
id: "chatgpt",
name: "ChatGPT",
command: "ollama launch chatgpt",
description: "Complete work with ChatGPT",
icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
},
{
id: "hermes",
name: "Hermes Agent",
command: "ollama launch hermes",
description: "Self-improving AI agent built by Nous Research",
icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
},
{
id: "opencode",
name: "OpenCode",
command: "ollama launch opencode",
description: "Anomaly's open-source coding agent",
icon: "/launch-icons/opencode.svg",
iconClassName: "h-7 w-7 rounded",
},
{
id: "codex",
name: "Codex",
command: "ollama launch codex",
description: "OpenAI's open-source coding agent",
icon: "/launch-icons/codex.svg",
darkIcon: "/launch-icons/codex-dark.svg",
iconClassName: "h-7 w-7",
},
{
id: "copilot",
name: "Copilot CLI",
command: "ollama launch copilot",
description: "GitHub's AI coding agent for the terminal",
icon: "/launch-icons/copilot.svg",
darkIcon: "/launch-icons/copilot-dark.svg",
iconClassName: "h-7 w-7",
},
{
id: "droid",
name: "Droid",
command: "ollama launch droid",
description: "Factory's coding agent across terminal and IDEs",
icon: "/launch-icons/droid.svg",
},
{
id: "pi",
name: "Pi",
command: "ollama launch pi",
description: "Minimal AI agent toolkit with plugin support",
icon: "/launch-icons/pi.svg",
darkIcon: "/launch-icons/pi-dark.svg",
iconClassName: "h-7 w-7",
},
];
export default function LaunchCommands() {
const isWindows = navigator.platform.toLowerCase().includes("win");
const { setSettings } = useSettings();
const renderCommandCard = (item: LaunchCommand) => (
<div key={item.command} className="w-full text-left">
<div className="flex items-start gap-4 sm:gap-5">
<div
aria-hidden="true"
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg overflow-hidden ${item.borderless ? "" : "border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"}`}
>
{item.darkIcon ? (
<picture>
<source srcSet={item.darkIcon} media="(prefers-color-scheme: dark)" />
<img src={item.icon} alt="" className={`${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
</picture>
) : (
<img src={item.icon} alt="" className={item.borderless ? "h-full w-full rounded-xl" : `${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
)}
</div>
<div className="min-w-0 flex-1">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{item.name}
</span>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{item.description}
</p>
<div className="mt-2 flex items-center gap-2 rounded-xl border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-3 py-2">
<code className="min-w-0 flex-1 truncate text-xs text-neutral-600 dark:text-neutral-300">
{item.command}
</code>
<CopyButton
content={item.command}
size="md"
title="Copy command to clipboard"
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-200/60 dark:hover:bg-neutral-700/70"
onCopy={() => {
setSettings({ LastHomeView: item.id }).catch(() => { });
}}
/>
</div>
</div>
</div>
</div>
);
return (
<main className="flex h-screen w-full flex-col relative">
<section
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
>
<div className="max-w-[730px] mx-auto w-full px-4 pt-4 pb-20 sm:px-6 sm:pt-6 sm:pb-24 lg:px-8 lg:pt-8 lg:pb-28">
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
Launch
</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Copy a command and run it in your terminal.
</p>
<div className="mt-6 grid gap-7">
{LAUNCH_COMMANDS.map(renderCommandCard)}
</div>
</div>
</section>
</main>
);
}
File diff suppressed because one or more lines are too long.
+1 -1
View File
@@ -273,7 +273,7 @@ function ToolRoleContent({
);
}
return (
// collapsable tool result with raw json
// collapsible tool result with raw json
<div className="space-y-2">
{content && !rawToolResult && (
<pre className="text-xs whitespace-pre-wrap overflow-x-auto bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200 p-2 rounded-md max-h-40">
@@ -0,0 +1,952 @@
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { describe, expect, it, vi } from "vitest";
import {
ClaudeConnectedIntro,
FIRST_MODEL_COMMAND,
ConnectAppsScreen,
IntroScreen,
default as Onboarding,
RunOllamaScreen,
shouldShowClaudeConnectedIntro,
WelcomeScreen,
} from "./Onboarding";
import {
CLAUDE_CONNECTION_TIMEOUT_MS,
CLAUDE_INSTALL_TIMEOUT_MS,
isClaudeConnectionComplete,
scheduleClaudeInstallTimeout,
} from "@/lib/claudeDesktop";
import { isWindowsPlatform } from "@/lib/platform";
import {
authenticationTimeoutAction,
nextOnboardingStep,
onboardingConnectUrl,
} from "@/lib/onboarding";
import type { IntegrationStatuses } from "@/api";
describe("Onboarding", () => {
it("explains what Ollama is before asking the user to choose a path", () => {
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
expect(html).toContain("Welcome to Ollama!");
expect(html.indexOf('alt="Ollama waving"')).toBeLessThan(
html.indexOf("Welcome to Ollama!"),
);
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
expect(html).not.toMatch(/alt="Ollama waving" class="[^"]*dark:/);
expect(html).toContain(
"Run open models with your coding agents so you can spend less while keeping your data private.",
);
expect(html.indexOf("Connect your apps")).toBeLessThan(
html.indexOf("Easily switch models"),
);
expect(html.indexOf("Easily switch models")).toBeLessThan(
html.indexOf("Your data stays yours"),
);
expect(html).toContain("Power your existing coding apps with open models");
expect(html).toContain("Swap between frontier models in one click.");
expect(html).toContain("Your prompt data is never logged or trained on.");
expect(html).toContain("Continue");
expect(html).not.toContain("Skip");
});
it("renders the apps screen without browser platform globals", () => {
vi.stubGlobal("navigator", undefined);
try {
expect(() =>
renderToStaticMarkup(<ConnectAppsScreen initialIntegrations={[]} />),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
it("hides the Claude and ChatGPT desktop integrations on Windows", () => {
vi.stubGlobal("window", {
OLLAMA_PLATFORM: "windows",
innerHeight: 660,
});
vi.stubGlobal("navigator", { platform: "MacIntel" });
try {
expect(isWindowsPlatform()).toBe(true);
const html = renderToStaticMarkup(
<ConnectAppsScreen
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
},
{
id: "claude",
name: "Claude Code",
description: "Anthropic's coding tool with subagents",
command: "ollama launch claude",
},
{
id: "chatgpt",
name: "ChatGPT",
description: "Use Ollama models in ChatGPT",
installed: true,
command: "ollama launch chatgpt",
},
]}
/>,
);
expect(html).not.toContain('id="desktop-heading"');
expect(html).not.toContain("Use Ollama models in Claude Desktop");
expect(html).not.toContain("Use Ollama models in ChatGPT");
expect(html).not.toContain("ollama launch chatgpt");
expect(html).toContain('id="terminal-heading"');
expect(html).toContain("ollama launch claude");
} finally {
vi.unstubAllGlobals();
}
});
it("shows the account choice only to signed-out users", () => {
expect(nextOnboardingStep("intro", "continue", false)).toBe("welcome");
expect(nextOnboardingStep("intro", "continue", true)).toBe("apps");
expect(nextOnboardingStep("welcome", "authenticated", true)).toBe("apps");
expect(nextOnboardingStep("apps", "continue", true)).toBe("apps");
expect(nextOnboardingStep("welcome", "local", false)).toBe("run");
});
it("lets an in-flight authentication check finish before timing out", () => {
expect(authenticationTimeoutAction(false, true)).toBe("defer");
expect(authenticationTimeoutAction(false, false)).toBe("fail");
expect(authenticationTimeoutAction(true, true)).toBe("ignore");
});
it("detects when the menu bar already reached the requested Claude state", () => {
const status = {
supported: true,
installed: true,
configured: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
};
expect(isClaudeConnectionComplete(true, status)).toBe(true);
expect(
isClaudeConnectionComplete(true, { ...status, connected: false }),
).toBe(false);
expect(
isClaudeConnectionComplete(true, { ...status, startFailed: true }),
).toBe(false);
expect(
isClaudeConnectionComplete(false, {
...status,
configured: false,
connected: false,
}),
).toBe(true);
expect(
isClaudeConnectionComplete(false, { ...status, connected: false }),
).toBe(false);
});
it("bounds the Claude installer wait", () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setTimeout: globalThis.setTimeout });
const onTimeout = vi.fn();
try {
scheduleClaudeInstallTimeout(onTimeout);
vi.advanceTimersByTime(CLAUDE_INSTALL_TIMEOUT_MS - 1);
expect(onTimeout).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(onTimeout).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it("keeps the Claude switch on and busy through installer detection", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const disconnectedStatus = {
supported: true,
used: false,
installed: false,
configured: false,
connected: false,
running: false,
startFailed: false,
portConflict: false,
};
let finishInstall!: (result: "opened") => void;
const install = new Promise<"opened">((resolve) => {
finishInstall = resolve;
});
vi.stubGlobal("navigator", { platform: "MacIntel" });
vi.stubGlobal("window", {
OLLAMA_PLATFORM: "darwin",
innerHeight: 660,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
getClaudeDesktopConnectionSummary: vi
.fn()
.mockResolvedValue(disconnectedStatus),
setClaudeDesktopConnected: vi.fn(),
installClaudeDesktop: vi.fn().mockReturnValue(install),
});
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ConnectAppsScreen
initialClaudeStatus={disconnectedStatus}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: false,
action: "connect",
},
]}
/>,
);
await Promise.resolve();
});
const claudeSwitch = () =>
renderer!.root
.findAllByProps({ role: "switch" })
.find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
let clickResult!: Promise<void>;
await act(async () => {
clickResult = claudeSwitch().props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(claudeSwitch().props["aria-checked"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
expect(
renderer.root
.findAllByProps({ role: "status" })
.some((node) => node.children.includes("Downloading…")),
).toBe(true);
expect(
renderer.root.findAll(
(node) =>
typeof node.props.className === "string" &&
node.props.className.includes("animate-spin"),
),
).not.toHaveLength(0);
await act(async () => {
finishInstall("opened");
await clickResult;
await Promise.resolve();
});
expect(claudeSwitch().props["aria-checked"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
expect(
renderer.root
.findAllByProps({ role: "status" })
.some((node) => node.children.includes("Finish installing…")),
).toBe(true);
expect(
renderer.root.findAll(
(node) =>
typeof node.props.className === "string" &&
node.props.className.includes("animate-spin"),
),
).not.toHaveLength(0);
} finally {
if (renderer) {
act(() => renderer?.unmount());
}
vi.unstubAllGlobals();
}
});
it("preserves a late native error after the Connect Apps action times out", async () => {
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const disconnectedStatus = {
supported: true,
used: false,
installed: true,
configured: false,
connected: false,
running: false,
startFailed: false,
portConflict: false,
};
const connectedStatus = {
...disconnectedStatus,
configured: true,
connected: true,
};
let finishNativeAction!: (result: {
status: typeof connectedStatus;
error?: string;
}) => void;
const nativeAction = new Promise<{
status: typeof connectedStatus;
error?: string;
}>((resolve) => {
finishNativeAction = resolve;
});
const getClaudeStatus = vi
.fn()
.mockResolvedValueOnce(disconnectedStatus)
.mockResolvedValue(connectedStatus);
const setClaudeConnected = vi.fn().mockReturnValue(nativeAction);
vi.stubGlobal("navigator", { platform: "MacIntel" });
vi.stubGlobal("window", {
OLLAMA_PLATFORM: "darwin",
innerHeight: 660,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
getClaudeDesktopConnectionSummary: getClaudeStatus,
setClaudeDesktopConnected: setClaudeConnected,
});
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
renderer = create(
<ConnectAppsScreen
initialClaudeStatus={disconnectedStatus}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
]}
/>,
);
await Promise.resolve();
});
const claudeSwitch = () =>
renderer!.root
.findAllByProps({ role: "switch" })
.find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
expect(claudeSwitch().props["aria-checked"]).toBe(false);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
let clickResult!: Promise<void>;
await act(async () => {
clickResult = claudeSwitch().props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(setClaudeConnected).toHaveBeenCalledWith(true, false);
expect(claudeSwitch().props["aria-checked"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(CLAUDE_CONNECTION_TIMEOUT_MS);
await clickResult;
});
expect(claudeSwitch().props["aria-checked"]).toBe(false);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
expect(
renderer.root.findByProps({ role: "alert" }).children.join(""),
).toContain("Claude is taking too long to connect");
await act(async () => {
finishNativeAction({
status: connectedStatus,
error: "Claude failed to restart.",
});
await Promise.resolve();
await Promise.resolve();
});
expect(getClaudeStatus).toHaveBeenCalledOnce();
expect(claudeSwitch().props["aria-checked"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
expect(
renderer.root.findByProps({ role: "alert" }).children.join(""),
).toContain("Claude failed to restart.");
expect(
renderer.root.findAllByProps({ id: "claude-connected-title" }),
).toHaveLength(0);
} finally {
if (renderer) {
act(() => renderer?.unmount());
}
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it("shows the Claude intro only before the integration has been used", () => {
const firstConnection = {
supported: true,
used: false,
installed: true,
configured: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
};
expect(shouldShowClaudeConnectedIntro(firstConnection)).toBe(true);
expect(
shouldShowClaudeConnectedIntro({ ...firstConnection, used: true }),
).toBe(false);
expect(
shouldShowClaudeConnectedIntro({
...firstConnection,
connected: false,
}),
).toBe(false);
expect(
shouldShowClaudeConnectedIntro({
...firstConnection,
startFailed: true,
}),
).toBe(false);
});
it("uses Continue as the only Claude intro action", () => {
const html = renderToStaticMarkup(
<ClaudeConnectedIntro onDone={vi.fn()} />,
);
expect(html).toContain(">Continue</button>");
expect(html).not.toContain('aria-label="Close"');
});
it("opens the device connection flow without relaunching the app", () => {
expect(
onboardingConnectUrl(
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
"signin",
),
).toBe("https://ollama.com/connect?name=MacBook&key=public-key");
expect(
onboardingConnectUrl(
"https://ollama.com/connect?name=MacBook&key=public-key",
"signup",
),
).toBe(
"https://ollama.com/connect?name=MacBook&key=public-key&signup=true",
);
});
it("preserves the intro for a device that is already connected", () => {
const html = renderToStaticMarkup(
<Onboarding
isAuthenticated
isSigningIn={false}
signInError={null}
completionError={null}
onOpenApps={vi.fn().mockResolvedValue(true)}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onRetryCompletion={vi.fn()}
onUseLocal={vi.fn()}
/>,
);
expect(html).toContain("Welcome to Ollama");
expect(html).not.toContain("Run Ollama");
expect(html).not.toContain("Sign up");
});
it("groups disconnected Claude with a scrollable terminal list", () => {
const integrations: IntegrationStatuses = [
{
id: "claude-desktop",
name: "Claude Code (Desktop)",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
{
id: "claude",
name: "Claude Code",
description: "Anthropic's coding tool with subagents",
installed: true,
action: "copy",
command: "ollama launch claude",
},
{
id: "codex",
name: "Codex CLI",
description: "OpenAI's open-source coding agent",
installed: true,
action: "copy",
command: "ollama launch codex",
},
{
id: "openclaw",
name: "OpenClaw",
description: "Personal AI with 100+ skills",
installed: true,
action: "copy",
command: "ollama launch openclaw",
},
{
id: "opencode",
name: "OpenCode",
description: "Anomaly's open-source coding agent",
installed: false,
action: "copy",
command: "ollama launch opencode",
},
{
id: "droid",
name: "Droid",
description: "AI software engineering agent",
installed: false,
action: "copy",
command: "ollama launch droid",
},
{
id: "dsh",
name: "DeepSeek Harness",
description: "DeepSeek's open-source agent harness",
installed: false,
action: "copy",
command: "ollama launch dsh",
},
{
id: "cline",
name: "Cline",
description: "Autonomous coding agent",
installed: false,
action: "copy",
command: "ollama launch cline",
},
{
id: "terminal",
name: "Terminal",
description: "Run local models from your terminal",
action: "copy",
command: "ollama",
},
];
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialIntegrations={integrations}
/>,
);
expect(html).not.toContain(
"Connect Claude, or copy a command to run in your terminal.",
);
expect(html).toContain("Claude Code (Desktop)");
expect(html).toContain("Use Ollama models in Claude Desktop");
expect(html).toContain("Claude Code");
expect(html).toContain("Codex CLI");
expect(html).not.toContain("Search apps");
expect(html).not.toContain('type="search"');
expect(html).toContain("Desktop");
expect(html).toContain('id="desktop-heading"');
expect(html).toContain('id="terminal-heading"');
expect(html).not.toContain("Ready to launch");
expect(html).not.toContain('id="claude-apps-heading"');
expect(html.indexOf("Desktop")).toBeLessThan(
html.indexOf("Use Ollama models in Claude Desktop"),
);
expect(html).not.toContain(">Command</th>");
expect(html).toContain("ollama launch claude");
expect(html).not.toContain("Installed");
expect(html).toContain("Use Ollama models in ChatGPT");
expect(html).toContain('aria-label="Connect Claude"');
expect(html).toContain('role="switch"');
expect(html).toContain('aria-checked="false"');
expect(html).not.toContain("Inactive");
expect(html).toContain("Download &amp; connect");
expect(html).not.toContain("Active");
expect(html).toContain("bg-transparent");
expect(html).toContain('aria-label="Copy OpenCode command"');
expect(html).toContain('aria-label="Copy Terminal command"');
expect(html).not.toContain(">Copy command</button>");
expect(html).toContain("ChatGPT (Desktop)");
expect(html).toContain("OpenCode");
expect(html).toContain("Terminal");
expect(html).toContain("overflow-y-auto");
expect(html).not.toContain('aria-label="Show more apps"');
expect(html).not.toContain("aria-expanded");
expect(html).not.toContain("grid-rows-[0fr]");
expect(html).not.toContain("inert");
expect(html).toContain("/launch-icons/claude.svg");
expect(html).toContain("/launch-icons/claude-code.svg");
expect(html).toContain("/launch-icons/codex-color.svg");
expect(html).toMatch(
/src="\/launch-icons\/cline\.svg"[^>]*class="[^"]*dark:invert/,
);
expect(html).toContain("/launch-icons/deepseek-harness.svg");
expect(html).not.toMatch(
/src="\/launch-icons\/deepseek-harness\.svg"[^>]*class="[^"]*dark:invert/,
);
expect(html).not.toContain("<table");
expect(html).not.toContain("<footer");
expect(html).not.toContain("Command copied. Run it in your terminal.");
expect(html).toContain("Run local models from your terminal");
expect(html).not.toContain("Launch command");
expect(html).not.toContain('aria-pressed="true"');
expect(html).not.toContain("Continue");
expect(html).not.toContain("Run Ollama");
expect(html).not.toContain('viewBox="0 0 3400 3400"');
});
it("places ChatGPT directly below Claude instead of in Terminal", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
},
{
id: "codex",
name: "Codex CLI",
description: "OpenAI's coding agent",
command: "ollama launch codex",
},
]}
initialCodexStatus={{
supported: true,
installed: true,
connected: false,
running: false,
}}
/>,
);
expect(html.indexOf("Use Ollama models in Claude Desktop")).toBeLessThan(
html.indexOf(">ChatGPT (Desktop)</p>"),
);
expect(html).toContain('aria-label="Add Ollama models to ChatGPT"');
expect(html).not.toContain('aria-label="Copy ChatGPT command"');
expect(html).toContain('aria-label="Copy Codex CLI command"');
});
it("keeps connected Claude in Desktop without an idle status", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialClaudeStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: false,
startFailed: false,
portConflict: false,
routedRequests: 12,
}}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
{
id: "codex",
name: "Codex CLI",
description: "OpenAI's open-source coding agent",
installed: true,
action: "copy",
command: "ollama launch codex",
},
]}
/>,
);
expect(html).toContain('id="desktop-heading"');
expect(html).not.toContain('id="claude-apps-heading"');
expect(html).not.toContain("Ready to launch");
expect(html).not.toContain("Active");
expect(html).not.toContain("Inactive");
expect(html).toContain('aria-checked="true"');
expect(html).toContain('aria-label="Disconnect Claude"');
expect(html).toContain("Connected to Ollama · 12 requests this session");
});
it("shows initial Claude recovery guidance without error styling", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialClaudeStatus={{
supported: true,
used: true,
installed: true,
configured: true,
connected: false,
running: false,
startFailed: true,
portConflict: false,
error: "Cloud models are off. Select an installed model in Settings.",
}}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
]}
/>,
);
expect(html).toContain(
"Cloud models are off. Select an installed model in Settings.",
);
expect(html).toContain('role="alert"');
expect(html).not.toContain("text-red");
expect(html).toContain('aria-checked="true"');
expect(html).toContain('aria-label="Disconnect Claude"');
});
it("keeps Claude model management off the Connect Apps page", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialClaudeStatus={{
supported: true,
used: true,
installed: true,
connected: true,
running: true,
startFailed: false,
portConflict: false,
modelSource: "endpoint",
models: [
{
name: "glm-5.2:cloud",
displayName: "GLM 5.2",
description: "Long-horizon coding",
selected: true,
},
{
name: "qwen3.8:27b",
displayName: "Qwen 3.8 27B",
description: "Local coding",
selected: false,
},
],
}}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: true,
action: "connect",
},
]}
/>,
);
expect(html).not.toContain("Models in Claude");
expect(html).not.toContain("GLM 5.2");
expect(html).not.toContain("Qwen 3.8 27B");
expect(html).not.toContain('type="checkbox"');
expect(html).not.toContain("Restart Claude");
expect(html).not.toContain("Built-in defaults");
});
it("keeps Claude available without a separate not-installed group", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialIntegrations={[
{
id: "claude-desktop",
name: "Claude",
description: "Use Ollama models in Claude Desktop",
installed: false,
action: "connect",
},
]}
/>,
);
expect(html).toContain("Use Ollama models in Claude Desktop");
expect(html).toContain('aria-label="Connect Claude"');
expect(html).toContain("Download &amp; connect");
expect(html).not.toContain("Inactive");
const claudeButton = html.match(
/<button[^>]*aria-label="Connect Claude"[^>]*>/,
)?.[0];
expect(claudeButton).toBeDefined();
expect(claudeButton).not.toContain('disabled=""');
});
it("uses branded icons for the remaining launcher integrations", () => {
const html = renderToStaticMarkup(
<ConnectAppsScreen
completionError={null}
onRetryCompletion={vi.fn()}
initialIntegrations={[
{
id: "cline",
name: "Cline",
description: "Autonomous coding agent",
action: "copy",
command: "ollama launch cline",
},
{
id: "omp",
name: "Oh My Pi",
description: "AI coding agent",
action: "copy",
command: "ollama launch omp",
},
{
id: "pool",
name: "Poolside",
description: "Poolside's coding agent",
action: "copy",
command: "ollama launch pool",
},
{
id: "qwen",
name: "Qwen Code",
description: "Qwen's coding agent",
action: "copy",
command: "ollama launch qwen",
},
]}
/>,
);
expect(html).toContain("/launch-icons/cline.svg");
expect(html).toContain("/launch-icons/oh-my-pi.svg");
expect(html).toContain("/launch-icons/poolside.svg");
expect(html).toContain("/launch-icons/qwen-code.svg");
});
it("offers cloud sign-up, local setup, and sign in on the welcome screen", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated={false}
isSigningIn={false}
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Create an account");
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
expect(html).toContain(
"Create your account for access to faster, larger open models.",
);
expect(html).toContain("Your data is never logged or trained on.");
expect(html).toContain("Sign up");
expect(html).toContain("No thanks, I&#x27;ll use Ollama locally");
expect(html).toContain("Sign in");
expect(html).not.toContain("Skip");
});
it("shows the cloud choice without a sign-in link for authenticated users", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated
isSigningIn={false}
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Create an account");
expect(html).toContain(
"Create your account for access to faster, larger open models.",
);
expect(html).toContain("Your data is never logged or trained on.");
expect(html).not.toContain(">Sign in<");
});
it("shows only the local command on the final page", () => {
const html = renderToStaticMarkup(
<RunOllamaScreen completionError={null} onRetryCompletion={vi.fn()} />,
);
expect(html).toContain("Run Ollama");
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
expect(html).toContain(FIRST_MODEL_COMMAND);
expect(html).not.toContain("Finish");
expect(html).not.toContain("Sign in");
expect(html).not.toContain("create an account");
});
it("shows the connecting state on the welcome action", () => {
const html = renderToStaticMarkup(
<WelcomeScreen
isAuthenticated={false}
isSigningIn
signInError={null}
onSignIn={vi.fn()}
onSignUp={vi.fn()}
onLocal={vi.fn()}
/>,
);
expect(html).toContain("Finish in your browser…");
expect(html).not.toContain("Waiting for sign in…");
});
it("shows a retryable error when onboarding completion cannot be saved", () => {
const onRetryCompletion = vi.fn();
const html = renderToStaticMarkup(
<RunOllamaScreen
completionError="Unable to save setup. Please try again."
onRetryCompletion={onRetryCompletion}
/>,
);
expect(html).toContain("Unable to save setup. Please try again.");
expect(html).toContain('role="alert"');
expect(html).toContain("Try again");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,304 @@
import { act, create, type ReactTestInstance } from "react-test-renderer";
import { forwardRef, useImperativeHandle } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Settings as SettingsType } from "@/gotypes";
import { Badge } from "./ui/badge";
import Settings from "./Settings";
const mocks = vi.hoisted(() => ({
resetClaudeMappings: vi.fn(),
resetChatGPTModels: vi.fn(),
updateSettings: vi.fn(),
updateCloudSetting: vi.fn(),
setShowAppsInMenu: vi.fn(),
refetchUser: vi.fn(),
disconnectUser: vi.fn(),
isWindows: false,
queryClient: {
cancelQueries: vi.fn().mockResolvedValue(undefined),
getQueryData: vi.fn(),
setQueryData: vi.fn(),
invalidateQueries: vi.fn(),
},
settings: null as SettingsType | null,
}));
vi.mock("@/components/ClaudeDesktopModelsSettings", () => ({
ClaudeDesktopModelsSettings: forwardRef(
function MockClaudeDesktopSettings(_props, ref) {
useImperativeHandle(ref, () => ({
resetToDefaults: mocks.resetClaudeMappings,
}));
return <section aria-label="Claude settings" />;
},
),
}));
vi.mock("@/components/CodexDesktopModelsSettings", () => ({
CodexDesktopModelsSettings: forwardRef(
function MockCodexDesktopSettings(_props, ref) {
useImperativeHandle(ref, () => ({
resetToDefaults: mocks.resetChatGPTModels,
}));
return <section aria-label="ChatGPT settings" />;
},
),
}));
vi.mock("@/hooks/useUser", () => ({
useUser: () => ({
user: {
id: "paid-user-id",
name: "Paid user",
email: "paid@example.com",
plan: "pro",
},
isAuthenticated: true,
refreshUser: vi.fn(),
isRefreshing: false,
refetchUser: mocks.refetchUser,
fetchConnectUrl: vi.fn(),
isLoading: false,
disconnectUser: mocks.disconnectUser,
}),
}));
vi.mock("@/hooks/useCloudStatus", () => ({
useCloudStatus: () => ({
cloudDisabled: false,
cloudStatus: { disabled: false, source: "none" },
isKnown: true,
}),
}));
vi.mock("@/lib/platform", () => ({
isWindowsPlatform: () => mocks.isWindows,
}));
vi.mock("@tanstack/react-router", () => ({
useBlocker: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => mocks.queryClient,
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "settings") {
return {
data: { settings: mocks.settings },
isLoading: false,
error: null,
};
}
return { data: { defaultContextLength: 65_536 } };
},
useMutation: ({
mutationFn,
onMutate,
onSuccess,
onError,
onSettled,
}: {
mutationFn: (value: unknown) => Promise<unknown>;
onMutate?: (value: unknown) => Promise<unknown>;
onSuccess?: (result: unknown, value: unknown, context: unknown) => void;
onError?: (error: unknown, value: unknown, context: unknown) => void;
onSettled?: (
result: unknown,
error: unknown,
value: unknown,
context: unknown,
) => void;
}) => {
const run = async (
value: unknown,
callbacks?: { onSuccess?: () => void },
) => {
const context = await onMutate?.(value);
try {
const result = await mutationFn(value);
onSuccess?.(result, value, context);
callbacks?.onSuccess?.();
onSettled?.(result, null, value, context);
return result;
} catch (error) {
onError?.(error, value, context);
onSettled?.(undefined, error, value, context);
throw error;
}
};
return {
mutate: (value: unknown, callbacks?: { onSuccess?: () => void }) => {
void run(value, callbacks);
},
mutateAsync: (value: unknown) => run(value),
};
},
}));
vi.mock("@/api", () => ({
getSettings: vi.fn(),
getInferenceCompute: vi.fn(),
updateSettings: mocks.updateSettings,
updateCloudSetting: mocks.updateCloudSetting,
}));
function textContent(node: ReactTestInstance): string {
return node.children
.map((child) => (typeof child === "string" ? child : textContent(child)))
.join("");
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Settings reset interactions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.isWindows = false;
mocks.settings = new SettingsType({ ContextLength: 65_536 });
mocks.updateSettings.mockResolvedValue({ settings: mocks.settings });
mocks.updateCloudSetting.mockResolvedValue({
disabled: false,
source: "none",
});
mocks.setShowAppsInMenu.mockResolvedValue(undefined);
mocks.resetChatGPTModels.mockResolvedValue(true);
mocks.disconnectUser.mockResolvedValue(undefined);
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
getShowAppsInMenu: vi.fn().mockResolvedValue(true),
setShowAppsInMenu: mocks.setShowAppsInMenu,
open: vi.fn(),
confirm: vi.fn(() => true),
location: { reload: vi.fn() },
OLLAMA_TOOLS: false,
});
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});
it("locks every control and shows Saved after reset succeeds", async () => {
const pendingClaudeReset = deferred<boolean>();
mocks.resetClaudeMappings.mockImplementation(
() => pendingClaudeReset.promise,
);
let renderer;
try {
await act(async () => {
renderer = create(<Settings />);
await Promise.resolve();
});
const resetButton = renderer!.root
.findAllByType("button")
.find((button) => textContent(button).includes("Reset to defaults"));
if (!resetButton) throw new Error("Reset button not found");
await act(async () => {
resetButton.props.onClick();
await Promise.resolve();
});
const settingsFieldset = renderer!.root.findByType("fieldset");
expect(settingsFieldset.props.disabled).toBe(true);
expect(settingsFieldset.props["aria-busy"]).toBe(true);
expect(textContent(resetButton)).toContain("Resetting…");
expect(renderer!.root.findAllByType(Badge)).toHaveLength(0);
expect(mocks.resetChatGPTModels).toHaveBeenCalledOnce();
await act(async () => {
pendingClaudeReset.resolve(true);
await pendingClaudeReset.promise;
await Promise.resolve();
await Promise.resolve();
});
expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false);
expect(renderer!.root.findAllByType(Badge)).toHaveLength(1);
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("hides Claude and ChatGPT desktop settings on Windows", async () => {
mocks.isWindows = true;
let renderer;
try {
await act(async () => {
renderer = create(<Settings />);
await Promise.resolve();
});
expect(
renderer!.root.findAllByProps({ "aria-label": "Claude settings" }),
).toHaveLength(0);
expect(
renderer!.root.findAllByProps({ "aria-label": "ChatGPT settings" }),
).toHaveLength(0);
const resetButton = renderer!.root
.findAllByType("button")
.find((button) => textContent(button).includes("Reset to defaults"));
if (!resetButton) throw new Error("Reset button not found");
await act(async () => {
resetButton.props.onClick();
await vi.waitFor(() => expect(mocks.updateSettings).toHaveBeenCalled());
});
expect(mocks.resetClaudeMappings).not.toHaveBeenCalled();
expect(mocks.resetChatGPTModels).not.toHaveBeenCalled();
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
it("reloads Settings after signing out", async () => {
let renderer;
try {
await act(async () => {
renderer = create(<Settings />);
await Promise.resolve();
});
const signOutButton = renderer!.root
.findAllByType("button")
.find((button) => textContent(button) === "Sign out");
if (!signOutButton) throw new Error("Sign out button not found");
await act(async () => {
signOutButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.disconnectUser).toHaveBeenCalledOnce();
expect(window.location.reload).toHaveBeenCalledOnce();
} finally {
await act(async () => {
renderer?.unmount();
await Promise.resolve();
});
vi.unstubAllGlobals();
}
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import { Settings as SettingsType } from "@/gotypes";
import { applySettingsDefaults } from "./Settings";
function currentSettings(overrides: Partial<SettingsType> = {}) {
return new SettingsType({
ContextLength: 65_536,
...overrides,
});
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Settings defaults", () => {
it("serializes a full reset before showing Saved", async () => {
const settingsUpdate = deferred();
const updateSettings = vi.fn(() => settingsUpdate.promise);
const updateCloud = vi.fn().mockResolvedValue(undefined);
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
const resetChatGPTModels = vi.fn().mockResolvedValue(true);
let resolveClaudeReset!: (succeeded: boolean) => void;
const claudeReset = new Promise<boolean>((resolve) => {
resolveClaudeReset = resolve;
});
const resetClaudeMappings = vi.fn(() => claudeReset);
const onSaved = vi.fn();
const reset = applySettingsDefaults({
updateSettings,
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels,
resetClaudeMappings,
currentSettings: currentSettings({
Expose: true,
Models: "/custom/models",
}),
currentShowAppsInMenu: false,
cloudSource: "config",
onSaved,
});
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce());
expect(updateCloud).toHaveBeenCalledWith(true);
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
expect(resetChatGPTModels).not.toHaveBeenCalled();
expect(resetClaudeMappings).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
expect(updateSettings.mock.calls[0][0]).toMatchObject({
Expose: false,
Models: "",
ContextLength: 65_536,
AutoUpdateEnabled: true,
});
expect(onSaved).not.toHaveBeenCalled();
settingsUpdate.resolve();
await vi.waitFor(() => expect(resetClaudeMappings).toHaveBeenCalledOnce());
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
expect(resetChatGPTModels).toHaveBeenCalledOnce();
expect(onSaved).not.toHaveBeenCalled();
resolveClaudeReset(true);
await reset;
expect(onSaved).toHaveBeenCalledOnce();
expect(updateCloud.mock.invocationCallOrder[0]).toBeLessThan(
updateSettings.mock.invocationCallOrder[0],
);
expect(updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
updateShowAppsInMenu.mock.invocationCallOrder[0],
);
expect(updateShowAppsInMenu.mock.invocationCallOrder[0]).toBeLessThan(
resetChatGPTModels.mock.invocationCallOrder[0],
);
expect(resetChatGPTModels.mock.invocationCallOrder[0]).toBeLessThan(
resetClaudeMappings.mock.invocationCallOrder[0],
);
expect(resetClaudeMappings.mock.invocationCallOrder[0]).toBeLessThan(
onSaved.mock.invocationCallOrder[0],
);
});
it("preserves an environment-only Cloud override", async () => {
const updateCloud = vi.fn().mockResolvedValue(undefined);
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
await applySettingsDefaults({
updateSettings: vi.fn().mockResolvedValue(undefined),
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels: vi.fn().mockResolvedValue(true),
resetClaudeMappings: vi.fn().mockResolvedValue(true),
currentSettings: currentSettings(),
currentShowAppsInMenu: true,
cloudSource: "env",
onSaved: vi.fn(),
});
expect(updateCloud).not.toHaveBeenCalled();
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
});
it("clears the persisted Cloud override when the source is both", async () => {
let environmentDisabled = true;
let configDisabled = true;
const updateCloud = vi.fn(async (enabled: boolean) => {
configDisabled = !enabled;
});
await applySettingsDefaults({
updateSettings: vi.fn().mockResolvedValue(undefined),
updateCloud,
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
resetChatGPTModels: vi.fn().mockResolvedValue(true),
resetClaudeMappings: vi.fn().mockResolvedValue(true),
currentSettings: currentSettings(),
currentShowAppsInMenu: true,
cloudSource: "both",
onSaved: vi.fn(),
});
expect(environmentDisabled || configDisabled).toBe(true);
expect(configDisabled).toBe(false);
environmentDisabled = false;
expect(environmentDisabled || configDisabled).toBe(false);
});
it("does not issue a redundant Cloud update when Cloud is already on", async () => {
const updateCloud = vi.fn().mockResolvedValue(undefined);
await applySettingsDefaults({
updateSettings: vi.fn().mockResolvedValue(undefined),
updateCloud,
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
resetChatGPTModels: vi.fn().mockResolvedValue(true),
resetClaudeMappings: vi.fn().mockResolvedValue(true),
currentSettings: currentSettings(),
currentShowAppsInMenu: true,
cloudSource: "none",
onSaved: vi.fn(),
});
expect(updateCloud).not.toHaveBeenCalled();
});
it("restores Cloud and leaves Claude untouched when settings fail", async () => {
const updateCloud = vi.fn().mockResolvedValue(undefined);
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
const resetChatGPTModels = vi.fn().mockResolvedValue(true);
const resetClaudeMappings = vi.fn().mockResolvedValue(true);
const onSaved = vi.fn();
await expect(
applySettingsDefaults({
updateSettings: vi.fn().mockRejectedValue(new Error("restart failed")),
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels,
resetClaudeMappings,
currentSettings: currentSettings({
Expose: true,
Models: "/custom/models",
}),
currentShowAppsInMenu: false,
cloudSource: "config",
onSaved,
}),
).rejects.toThrow("restart failed");
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
expect(resetChatGPTModels).not.toHaveBeenCalled();
expect(resetClaudeMappings).not.toHaveBeenCalled();
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
it("does not continue when the Cloud reset fails", async () => {
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
const onSaved = vi.fn();
await expect(
applySettingsDefaults({
updateSettings: vi.fn().mockResolvedValue(undefined),
updateCloud: vi.fn().mockRejectedValue(new Error("cloud failed")),
updateShowAppsInMenu,
resetChatGPTModels: vi.fn().mockResolvedValue(true),
resetClaudeMappings: vi.fn().mockResolvedValue(true),
currentSettings: currentSettings(),
currentShowAppsInMenu: true,
cloudSource: "config",
onSaved,
}),
).rejects.toThrow("cloud failed");
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
it("rolls earlier changes back when Claude mappings cannot be reset", async () => {
const onSaved = vi.fn();
const updateSettings = vi.fn().mockResolvedValue(undefined);
const updateCloud = vi.fn().mockResolvedValue(undefined);
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
const previousSettings = currentSettings({
Expose: true,
Models: "/custom/models",
});
await expect(
applySettingsDefaults({
updateSettings,
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels: vi.fn().mockResolvedValue(true),
resetClaudeMappings: vi.fn().mockResolvedValue(false),
currentSettings: previousSettings,
currentShowAppsInMenu: false,
cloudSource: "config",
onSaved,
}),
).rejects.toThrow("Claude model mappings could not be reset");
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
expect(updateSettings).toHaveBeenCalledTimes(2);
expect(updateSettings.mock.calls[1][0]).toBe(previousSettings);
expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]);
expect(onSaved).not.toHaveBeenCalled();
});
it("stops before Claude and rolls settings back when ChatGPT cannot be reset", async () => {
const onSaved = vi.fn();
const updateSettings = vi.fn().mockResolvedValue(undefined);
const updateCloud = vi.fn().mockResolvedValue(undefined);
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
const resetClaudeMappings = vi.fn().mockResolvedValue(true);
const previousSettings = currentSettings({ Expose: true });
await expect(
applySettingsDefaults({
updateSettings,
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels: vi.fn().mockResolvedValue(false),
resetClaudeMappings,
currentSettings: previousSettings,
currentShowAppsInMenu: false,
cloudSource: "config",
onSaved,
}),
).rejects.toThrow("ChatGPT models could not be reset");
expect(resetClaudeMappings).not.toHaveBeenCalled();
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
expect(updateSettings.mock.calls[1][0]).toBe(previousSettings);
expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]);
expect(onSaved).not.toHaveBeenCalled();
});
});
+329 -75
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { Switch } from "@/components/ui/switch";
import { Text } from "@/components/ui/text";
import { Input } from "@/components/ui/input";
@@ -6,24 +6,35 @@ import { Field, Label, Description } from "@/components/ui/fieldset";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
ClaudeDesktopModelsSettings,
type ClaudeDesktopModelsSettingsHandle,
} from "@/components/ClaudeDesktopModelsSettings";
import {
CodexDesktopModelsSettings,
type CodexDesktopModelsSettingsHandle,
} from "@/components/CodexDesktopModelsSettings";
import {
WifiIcon,
FolderIcon,
BoltIcon,
WrenchIcon,
CloudIcon,
XMarkIcon,
CogIcon,
ArrowLeftIcon,
ArrowDownTrayIcon,
ArrowPathIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { Settings as SettingsType } from "@/gotypes";
import { useNavigate } from "@tanstack/react-router";
import { isWindowsPlatform } from "@/lib/platform";
import { settingsMutationScope } from "@/lib/settingsMutationScope";
import { useUser } from "@/hooks/useUser";
import { useCloudStatus } from "@/hooks/useCloudStatus";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useBlocker } from "@tanstack/react-router";
import {
getSettings,
type CloudStatusSource,
type CloudStatusResponse,
updateCloudSetting,
updateSettings,
@@ -44,10 +55,109 @@ function AnimatedDots() {
);
}
interface SettingsDefaultsActions {
updateSettings: (settings: SettingsType) => Promise<unknown>;
updateCloud: (enabled: boolean) => Promise<unknown>;
updateShowAppsInMenu: (visible: boolean) => Promise<unknown>;
resetChatGPTModels: () => Promise<boolean>;
resetClaudeMappings: () => Promise<boolean>;
currentSettings: SettingsType;
currentShowAppsInMenu: boolean;
cloudSource: CloudStatusSource;
onSaved: () => void;
}
interface CloudUpdateRequest {
enabled: boolean;
requestId: number;
}
let latestCloudRequestId = 0;
const savedConfirmationDuration = 3000;
export async function applySettingsDefaults({
updateSettings,
updateCloud,
updateShowAppsInMenu,
resetChatGPTModels,
resetClaudeMappings,
currentSettings,
currentShowAppsInMenu,
cloudSource,
onSaved,
}: SettingsDefaultsActions): Promise<void> {
const cloudNeedsReset = cloudSource === "config" || cloudSource === "both";
const rollbacks: Array<() => Promise<unknown>> = [];
try {
if (cloudNeedsReset) {
await updateCloud(true);
rollbacks.push(() => updateCloud(false));
}
await updateSettings(
new SettingsType({
Expose: false,
Browser: false,
Models: "",
Agent: false,
Tools: false,
ContextLength: currentSettings.ContextLength,
AutoUpdateEnabled: true,
}),
);
rollbacks.push(() => updateSettings(currentSettings));
await updateShowAppsInMenu(true);
rollbacks.push(() => updateShowAppsInMenu(currentShowAppsInMenu));
// Reset app-specific model settings only after the rest of the page has
// succeeded, because those native profile changes cannot be rolled back
// with the settings API.
if (!(await resetChatGPTModels())) {
throw new Error("ChatGPT models could not be reset");
}
if (!(await resetClaudeMappings())) {
throw new Error("Claude model mappings could not be reset");
}
} catch (error) {
const rollbackErrors: unknown[] = [];
for (const rollback of rollbacks.reverse()) {
try {
await rollback();
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
}
}
if (rollbackErrors.length > 0) {
console.error("Failed to roll back settings reset:", rollbackErrors);
}
throw error;
}
onSaved();
}
export default function Settings() {
const queryClient = useQueryClient();
const [showSaved, setShowSaved] = useState(false);
const [restartMessage, setRestartMessage] = useState(false);
const [showAppsInMenu, setShowAppsInMenuState] = useState(true);
const [showAppsInMenuPending, setShowAppsInMenuPending] = useState(false);
const [resettingToDefaults, setResettingToDefaults] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false);
const [hasCodexDraftChanges, setHasCodexDraftChanges] = useState(false);
const claudeModelsSettingsRef =
useRef<ClaudeDesktopModelsSettingsHandle>(null);
const codexModelsSettingsRef = useRef<CodexDesktopModelsSettingsHandle>(null);
const savedConfirmationTimeoutRef = useRef<number | null>(null);
useBlocker({
shouldBlockFn: () =>
!window.confirm("Discard unapplied app model changes?"),
enableBeforeUnload: hasClaudeDraftChanges || hasCodexDraftChanges,
disabled: !hasClaudeDraftChanges && !hasCodexDraftChanges,
});
const {
user,
isAuthenticated,
@@ -61,13 +171,32 @@ export default function Settings() {
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const navigate = useNavigate();
const {
cloudDisabled,
cloudStatus,
isLoading: cloudStatusLoading,
isKnown: cloudStatusKnown,
} = useCloudStatus();
const showSavedConfirmation = useCallback(() => {
if (savedConfirmationTimeoutRef.current !== null) {
window.clearTimeout(savedConfirmationTimeoutRef.current);
}
setShowSaved(true);
savedConfirmationTimeoutRef.current = window.setTimeout(() => {
setShowSaved(false);
savedConfirmationTimeoutRef.current = null;
}, savedConfirmationDuration);
}, []);
useEffect(
() => () => {
if (savedConfirmationTimeoutRef.current !== null) {
window.clearTimeout(savedConfirmationTimeoutRef.current);
}
},
[],
);
const {
data: settingsData,
isLoading: loading,
@@ -87,22 +216,24 @@ export default function Settings() {
const defaultContextLength = inferenceComputeResponse?.defaultContextLength;
const updateSettingsMutation = useMutation({
scope: settingsMutationScope,
mutationFn: updateSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setShowSaved(true);
setTimeout(() => setShowSaved(false), 1500);
},
});
const updateCloudMutation = useMutation({
mutationFn: (enabled: boolean) => updateCloudSetting(enabled),
onMutate: async (enabled: boolean) => {
scope: settingsMutationScope,
mutationFn: ({ enabled }: CloudUpdateRequest) =>
updateCloudSetting(enabled),
onMutate: async ({ enabled, requestId }: CloudUpdateRequest) => {
await queryClient.cancelQueries({ queryKey: ["cloudStatus"] });
const previous = queryClient.getQueryData<CloudStatusResponse | null>([
"cloudStatus",
]);
if (requestId !== latestCloudRequestId) return { previous };
const envForcesDisabled =
previous?.source === "env" || previous?.source === "both";
@@ -121,28 +252,44 @@ export default function Settings() {
return { previous };
},
onError: (_error, _enabled, context) => {
onError: (_error, request, context) => {
if (request.requestId !== latestCloudRequestId) return;
if (context?.previous !== undefined) {
queryClient.setQueryData(["cloudStatus"], context.previous);
}
},
onSuccess: (status) => {
onSuccess: (status, request) => {
if (request.requestId !== latestCloudRequestId) return;
queryClient.setQueryData<CloudStatusResponse | null>(
["cloudStatus"],
status,
);
},
onSettled: (_status, _error, request) => {
if (request.requestId !== latestCloudRequestId) return;
queryClient.invalidateQueries({ queryKey: ["models"] });
queryClient.invalidateQueries({ queryKey: ["cloudStatus"] });
setShowSaved(true);
setTimeout(() => setShowSaved(false), 1500);
},
});
const requestCloudUpdate = (enabled: boolean) => {
const requestId = ++latestCloudRequestId;
return updateCloudMutation.mutateAsync({ enabled, requestId });
};
useEffect(() => {
refetchUser();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
window
.getShowAppsInMenu?.()
.then(setShowAppsInMenuState)
.catch((error) =>
console.error("Failed to load menu app visibility:", error),
);
}, []);
useEffect(() => {
const handleFocus = () => {
if (isAwaitingConnection && pollingInterval) {
@@ -196,34 +343,90 @@ export default function Settings() {
if (field === "ContextLength" && value !== settings.ContextLength) {
setRestartMessage(true);
// Hide restart message after 3 seconds
setTimeout(() => setRestartMessage(false), 3000);
window.setTimeout(
() => setRestartMessage(false),
savedConfirmationDuration,
);
}
updateSettingsMutation.mutate(updatedSettings);
updateSettingsMutation.mutate(updatedSettings, {
onSuccess: showSavedConfirmation,
});
}
},
[settings, updateSettingsMutation],
[settings, showSavedConfirmation, updateSettingsMutation],
);
const handleResetToDefaults = () => {
if (settings) {
const defaultSettings = new SettingsType({
Expose: false,
Browser: false,
Models: "",
Agent: false,
Tools: false,
ContextLength: 0,
AutoUpdateEnabled: true,
});
updateSettingsMutation.mutate(defaultSettings);
const updateShowAppsInMenuVisibility = async (checked: boolean) => {
const previous = showAppsInMenu;
setShowAppsInMenuState(checked);
setShowAppsInMenuPending(true);
try {
await window.setShowAppsInMenu?.(checked);
} catch (error) {
setShowAppsInMenuState(previous);
throw error;
} finally {
setShowAppsInMenuPending(false);
}
};
const handleShowAppsInMenu = (checked: boolean) => {
void updateShowAppsInMenuVisibility(checked)
.then(showSavedConfirmation)
.catch((error) =>
console.error("Failed to update menu app visibility:", error),
);
};
const handleCloudUpdate = (enabled: boolean) => {
void requestCloudUpdate(enabled)
.then(showSavedConfirmation)
.catch((error) =>
console.error("Failed to update cloud setting:", error),
);
};
const cloudOverriddenByEnv =
cloudStatus?.source === "env" || cloudStatus?.source === "both";
const cloudToggleDisabled =
cloudStatusLoading || updateCloudMutation.isPending || cloudOverriddenByEnv;
const cloudToggleDisabled = cloudOverriddenByEnv;
const handleResetToDefaults = async () => {
const cloudSource = cloudStatus?.source;
if (!settings || resettingToDefaults || !cloudSource) return;
setResettingToDefaults(true);
if (savedConfirmationTimeoutRef.current !== null) {
window.clearTimeout(savedConfirmationTimeoutRef.current);
savedConfirmationTimeoutRef.current = null;
}
setShowSaved(false);
setRestartMessage(false);
setResetError(null);
try {
await applySettingsDefaults({
updateSettings: (defaultSettings) =>
updateSettingsMutation.mutateAsync(defaultSettings),
updateCloud: requestCloudUpdate,
updateShowAppsInMenu: updateShowAppsInMenuVisibility,
resetChatGPTModels: async () =>
(await codexModelsSettingsRef.current?.resetToDefaults()) ?? true,
resetClaudeMappings: async () =>
(await claudeModelsSettingsRef.current?.resetToDefaults()) ?? true,
currentSettings: settings,
currentShowAppsInMenu: showAppsInMenu,
cloudSource,
onSaved: showSavedConfirmation,
});
} catch (error) {
console.error("Failed to reset settings:", error);
setResetError(
"Ollama could not reset every setting. Check the settings above and try again.",
);
} finally {
setResettingToDefaults(false);
}
};
const handleConnectOllamaAccount = async () => {
setConnectionError(null);
@@ -260,55 +463,38 @@ export default function Settings() {
}
};
const handleDisconnectOllamaAccount = async () => {
setConnectionError(null);
try {
await disconnectUser();
window.location.reload();
} catch {
setConnectionError("Failed to disconnect Ollama account");
}
};
if (loading) {
return null;
}
if (error || !settings) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<div className="text-red-500">Failed to load settings</div>
</div>
);
}
const isWindows = navigator.platform.toLowerCase().includes("win");
const handleCloseSettings = () => {
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
navigate({ to: "/c/$chatId", params: { chatId } });
};
const isWindows = isWindowsPlatform();
return (
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
<header
className="w-full flex flex-none justify-between h-[52px] py-2.5 items-center border-b border-neutral-200 dark:border-neutral-800 select-none"
onMouseDown={() => window.drag && window.drag()}
onDoubleClick={() => window.doubleClick && window.doubleClick()}
>
<h1
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
>
{isWindows && (
<button
onClick={handleCloseSettings}
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
>
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
</button>
)}
Settings
</h1>
{!isWindows && (
<button
onClick={handleCloseSettings}
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
>
<XMarkIcon className="w-6 h-6 dark:text-white" />
</button>
)}
</header>
<main className="flex min-h-0 w-full flex-1 flex-col select-none dark:bg-neutral-900">
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
<div className="space-y-4 max-w-2xl mx-auto">
<fieldset
disabled={resettingToDefaults}
aria-busy={resettingToDefaults}
className="mx-auto max-w-4xl space-y-4 border-0 p-0"
>
{/* Connect Ollama Account */}
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
<div className="p-4">
@@ -367,7 +553,7 @@ export default function Settings() {
type="button"
color="zinc"
className="px-3 py-2 text-sm"
onClick={() => disconnectUser()}
onClick={() => void handleDisconnectOllamaAccount()}
>
Sign out
</Button>
@@ -439,13 +625,36 @@ export default function Settings() {
if (cloudOverriddenByEnv) {
return;
}
updateCloudMutation.mutate(checked);
handleCloudUpdate(checked);
}}
/>
</div>
</div>
</Field>
{!isWindows && (
<Field>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-1 items-start space-x-3">
<Squares2X2Icon className="mt-1 h-5 w-5 flex-shrink-0 text-black dark:text-neutral-100" />
<div>
<Label>Show apps in menu</Label>
<Description>
Show connected apps at the top of the Ollama menu.
</Description>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={showAppsInMenu}
disabled={showAppsInMenuPending}
onChange={handleShowAppsInMenu}
/>
</div>
</div>
</Field>
)}
{/* Auto Update */}
<Field>
<div className="flex items-start justify-between gap-4">
@@ -463,7 +672,9 @@ export default function Settings() {
<div className="flex-shrink-0">
<Switch
checked={settings.AutoUpdateEnabled}
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
onChange={(checked) =>
handleChange("AutoUpdateEnabled", checked)
}
/>
</div>
</div>
@@ -544,7 +755,9 @@ export default function Settings() {
</Description>
<div className="mt-3">
<Slider
value={settings.ContextLength || defaultContextLength || 0}
value={
settings.ContextLength || defaultContextLength || 0
}
onChange={(value) => {
handleChange("ContextLength", value);
}}
@@ -566,6 +779,33 @@ export default function Settings() {
</div>
</div>
{!isWindows && (
<section
aria-labelledby="apps-settings-heading"
className="space-y-2"
>
<h2
id="apps-settings-heading"
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Apps
</h2>
<ClaudeDesktopModelsSettings
ref={claudeModelsSettingsRef}
includeCloudModels={
isAuthenticated && cloudStatusKnown && !cloudDisabled
}
onDraftChange={setHasClaudeDraftChanges}
showSectionHeading={false}
/>
<CodexDesktopModelsSettings
ref={codexModelsSettingsRef}
accountKey={`${user?.id ?? "signed-out"}:${user?.plan ?? ""}:${cloudDisabled ? "cloud-off" : "cloud-on"}`}
onDraftChange={setHasCodexDraftChanges}
/>
</section>
)}
{/* Agent Mode */}
{window.OLLAMA_TOOLS && (
<div className="overflow-hidden rounded-xl bg-white dark:bg-neutral-800">
@@ -611,17 +851,31 @@ export default function Settings() {
)}
{/* Reset button */}
<div className="mt-6 flex justify-end px-4">
<div className="flex items-center justify-between gap-4 px-4">
{resetError ? (
<p
role="alert"
className="text-xs text-red-600 dark:text-red-400"
>
{resetError}
</p>
) : (
<span />
)}
<Button
type="button"
color="white"
className="px-3"
onClick={handleResetToDefaults}
disabled={resettingToDefaults || !cloudStatusKnown}
onClick={() => void handleResetToDefaults()}
>
Reset to defaults
{resettingToDefaults && (
<ArrowPathIcon data-slot="icon" className="animate-spin" />
)}
{resettingToDefaults ? "Resetting…" : "Reset to defaults"}
</Button>
</div>
</div>
</fieldset>
{/* Saved indicator */}
{(showSaved || restartMessage) && (
@@ -0,0 +1,23 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SidebarLayout } from "./layout";
describe("SidebarLayout", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("keeps the macOS title offset in step with the sidebar transition", () => {
vi.stubGlobal("window", { OLLAMA_PLATFORM: "darwin" });
const html = renderToStaticMarkup(
<SidebarLayout title="Connect your apps" sidebar={<nav />}>
<div />
</SidebarLayout>,
);
expect(html).toContain("pl-36");
expect(html).toContain("transition-[padding-left]");
expect(html).toContain("duration-300");
});
});
+47 -36
View File
@@ -1,30 +1,39 @@
import { Link } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
import { ChatIcon } from "@/components/ChatIcon";
import { isWindowsPlatform } from "@/lib/platform";
import { useState } from "react";
let sessionSidebarOpen = false;
export function SidebarLayout({
sidebar,
title,
children,
}: React.PropsWithChildren<{
sidebar: React.ReactNode;
collapsible?: boolean;
chatId?: string;
title?: string;
}>) {
const { settings, setSettings } = useSettings();
const isWindows = navigator.platform.toLowerCase().includes("win");
const [sidebarOpen, setSidebarOpen] = useState(sessionSidebarOpen);
const isWindows = isWindowsPlatform();
const toggleSidebar = () => {
sessionSidebarOpen = !sidebarOpen;
setSidebarOpen(sessionSidebarOpen);
};
return (
<div className={`flex transition-[width] duration-300 dark:bg-neutral-900`}>
<div className="flex h-screen w-full overflow-hidden dark:bg-neutral-900">
<div
className={`absolute flex mx-2 py-2 z-20 items-center transition-[left] duration-375 text-neutral-500 dark:text-neutral-400 ${settings.sidebarOpen ? (isWindows ? "left-2" : "left-[204px]") : isWindows ? "left-2" : "left-20"}`}
className={`absolute flex mx-2 py-2 z-20 items-center transition-[left] duration-375 text-neutral-500 dark:text-neutral-400 ${sidebarOpen ? (isWindows ? "left-2" : "left-[140px]") : isWindows ? "left-2" : "left-20"}`}
>
<button
onClick={() => setSettings({ SidebarOpen: !settings.sidebarOpen })}
onClick={toggleSidebar}
onMouseDown={(e) => {
e.stopPropagation();
}}
className="h-9 w-9 flex items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700/75 cursor-pointer"
aria-label={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
title={settings.sidebarOpen ? "Hide sidebar" : "Show sidebar"}
aria-label={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
>
<svg
className="h-5 w-5 fill-current"
@@ -35,45 +44,47 @@ export function SidebarLayout({
<path d="M7.76132 16.6344H9.58103V1.59842H7.76132V16.6344ZM4.20898 18.2316H19.124C21.6518 18.2316 23.1293 16.6963 23.1293 14.0209V4.2205C23.1293 1.54512 21.6518 0.00351715 19.124 0.00351715H4.20898C1.54336 0.00351715 0 1.54512 0 4.2205V14.0209C0 16.6963 1.54336 18.2316 4.20898 18.2316ZM4.31191 16.3184C2.79628 16.3184 1.91327 15.4434 1.91327 13.926V4.31542C1.91327 2.79979 2.79628 1.91678 4.31191 1.91678H18.8174C20.333 1.91678 21.216 2.79979 21.216 4.31542V13.926C21.216 15.4434 20.333 16.3184 18.8174 16.3184H4.31191ZM5.85116 5.50038C6.1951 5.50038 6.49217 5.20507 6.49217 4.87968C6.49217 4.54628 6.1951 4.25722 5.85116 4.25722H3.8412C3.49725 4.25722 3.20819 4.54628 3.20819 4.87968C3.20819 5.20507 3.49725 5.50038 3.8412 5.50038H5.85116ZM5.85116 8.1158C6.1951 8.1158 6.49217 7.82049 6.49217 7.4871C6.49217 7.1537 6.1951 6.8744 5.85116 6.8744H3.8412C3.49725 6.8744 3.20819 7.1537 3.20819 7.4871C3.20819 7.82049 3.49725 8.1158 3.8412 8.1158H5.85116ZM5.85116 10.725C6.1951 10.725 6.49217 10.4439 6.49217 10.1105C6.49217 9.77713 6.1951 9.48983 5.85116 9.48983H3.8412C3.49725 9.48983 3.20819 9.77713 3.20819 10.1105C3.20819 10.4439 3.49725 10.725 3.8412 10.725H5.85116Z" />
</svg>
</button>
<Link
to="/c/$chatId"
params={{ chatId: "new" }}
title="New chat"
className={`flex ml-1 items-center justify-center rounded-full transition-opacity duration-375 h-9 w-9 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
settings.sidebarOpen
? "opacity-0 pointer-events-none"
: "opacity-100"
}`}
>
<svg
className="h-5 w-5 fill-current"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{!title && (
<Link
to="/c/$chatId"
params={{ chatId: "new" }}
title="New chat"
className={`flex ml-1 items-center justify-center rounded-full transition-opacity duration-375 h-9 w-9 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
sidebarOpen ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
</svg>
</Link>
<ChatIcon />
</Link>
)}
</div>
<div
className={`flex flex-col transition-[width] duration-300 max-h-screen ${settings.sidebarOpen ? "w-64" : "w-0"}`}
className={`flex max-h-screen flex-col transition-[width] duration-300 ${
sidebarOpen
? "w-48 border-r border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950/40"
: "w-0"
}`}
>
<div
onDoubleClick={() => window.doubleClick && window.doubleClick()}
onMouseDown={() => window.drag && window.drag()}
className="flex-none h-13 w-full"
></div>
{settings.sidebarOpen && sidebar}
{sidebarOpen && sidebar}
</div>
<main
className={`flex flex-1 flex-col min-w-0 transition-all duration-300`}
>
<main className="flex min-w-0 flex-1 flex-col transition-all duration-300">
<div
className={`h-13 flex-none w-full z-10 flex items-center bg-white dark:bg-neutral-900 ${isWindows ? "xl:hidden" : "xl:fixed xl:bg-transparent xl:dark:bg-transparent"}`}
className={`h-13 z-10 flex w-full flex-none items-center bg-white dark:bg-neutral-900 ${title ? "" : isWindows ? "xl:hidden" : "xl:fixed xl:bg-transparent xl:dark:bg-transparent"}`}
onDoubleClick={() => window.doubleClick && window.doubleClick()}
onMouseDown={() => window.drag && window.drag()}
></div>
>
{title && (
<h1
className={`${sidebarOpen ? "pl-6" : isWindows ? "pl-16" : "pl-36"} transition-[padding-left] duration-300 font-rounded text-md font-medium dark:text-white`}
>
{title}
</h1>
)}
</div>
{children}
</main>
</div>
+4 -1
View File
@@ -10,6 +10,7 @@ interface SettingsState {
selectedModel: string;
sidebarOpen: boolean;
lastHomeView: string;
onboardingVersion: number;
thinkEnabled: boolean;
thinkLevel: string;
}
@@ -23,6 +24,7 @@ type SettingsUpdate = Partial<{
SelectedModel: string;
SidebarOpen: boolean;
LastHomeView: string;
OnboardingVersion: number;
}>;
export function useSettings() {
@@ -52,7 +54,8 @@ export function useSettings() {
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
selectedModel: settingsData?.settings?.SelectedModel ?? "",
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
lastHomeView: settingsData?.settings?.LastHomeView ?? "chat",
onboardingVersion: settingsData?.settings?.OnboardingVersion ?? 0,
}),
[settingsData?.settings],
);
+9 -2
View File
@@ -35,8 +35,15 @@ export function useUser() {
const disconnectMutation = useMutation({
mutationFn: disconnectUser,
onSuccess: () => {
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user"] });
const previousUser = queryClient.getQueryData(["user"]);
queryClient.setQueryData(["user"], null);
return { previousUser };
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(["user"], context?.previousUser);
},
});
@@ -54,6 +61,6 @@ export function useUser() {
refetchUser: userQuery.refetch,
fetchConnectUrl: connectUrlQuery.refetch,
connectUrl: connectUrlQuery.data,
disconnectUser: disconnectMutation.mutate,
disconnectUser: disconnectMutation.mutateAsync,
};
}
+54 -5
View File
@@ -2,18 +2,38 @@
@plugin "@tailwindcss/typography";
@import "katex/dist/katex.min.css";
@custom-variant dark {
@media (prefers-color-scheme: dark) {
&:not(.light-only, .light-only *) {
@slot;
}
}
}
@theme {
--font-sans: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
--font-rounded:
"SF Pro Rounded", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
/* Dark mode styles go here */
@layer base {
:root {
/* Example dark mode variables */
--bg-color: #1a1a1a;
--text-color: #ffffff;
color-scheme: light dark;
}
.light-only {
color-scheme: light;
}
a[href],
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
button:disabled,
[role="button"][aria-disabled="true"] {
cursor: not-allowed;
}
}
@@ -28,3 +48,32 @@
opacity: 1;
}
}
@keyframes claude-connected-backdrop-in {
from {
opacity: 0;
}
}
@keyframes claude-connected-dialog-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
}
.claude-connected-backdrop {
animation: claude-connected-backdrop-in 280ms ease-in-out both;
}
.claude-connected-dialog {
animation: claude-connected-dialog-in 345ms cubic-bezier(0.4, 0, 0.2, 1) both;
will-change: opacity, transform;
}
@media (prefers-reduced-motion: reduce) {
.claude-connected-backdrop,
.claude-connected-dialog {
animation: none;
}
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import type { QueryClient } from "@tanstack/react-query";
import { preloadChatData } from "./chatPreload";
describe("preloadChatData", () => {
it("warms Chat data once without installing polling options", async () => {
const prefetchQuery = vi.fn().mockResolvedValue(undefined);
await preloadChatData({ prefetchQuery } as unknown as Pick<
QueryClient,
"prefetchQuery"
>);
expect(prefetchQuery).toHaveBeenCalledTimes(4);
expect(
prefetchQuery.mock.calls.map(([options]) => options.queryKey),
).toEqual([
["chats"],
["health"],
["models", ""],
["modelRecommendations"],
]);
for (const [options] of prefetchQuery.mock.calls) {
expect(options).not.toHaveProperty("refetchInterval");
expect(options).not.toHaveProperty("refetchIntervalInBackground");
}
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { QueryClient } from "@tanstack/react-query";
import {
fetchHealth,
getChats,
getModelRecommendations,
getModels,
} from "@/api";
type PrefetchClient = Pick<QueryClient, "prefetchQuery">;
export function preloadChatData(queryClient: PrefetchClient) {
return Promise.all([
queryClient.prefetchQuery({
queryKey: ["chats"],
queryFn: getChats,
}),
queryClient.prefetchQuery({
queryKey: ["health"],
queryFn: fetchHealth,
}),
queryClient.prefetchQuery({
queryKey: ["models", ""],
queryFn: () => getModels(""),
gcTime: 10 * 60 * 1000,
}),
queryClient.prefetchQuery({
queryKey: ["modelRecommendations"],
queryFn: getModelRecommendations,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
}),
]);
}
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it, vi } from "vitest";
import {
addClaudeModelSelection,
ClaudeConnectionTimeoutError,
claudeDesktopRecoveryMessage,
claudeDesktopMaxModels,
claudeDesktopMaxModelsMessage,
claudeDesktopRequestCountLabel,
claudeDesktopUsableSelection,
defaultClaudeDesktopMaxModels,
isClaudeConfigured,
optimisticClaudeConnectionState,
withClaudeConnectionTimeout,
} from "./claudeDesktop";
describe("isClaudeConfigured", () => {
it("keeps a failed configured profile switchable off", () => {
expect(
isClaudeConfigured({
supported: true,
used: true,
installed: true,
configured: true,
connected: false,
running: false,
startFailed: true,
portConflict: true,
}),
).toBe(true);
});
});
describe("optimisticClaudeConnectionState", () => {
it("shows the requested state while a connection change is pending", () => {
expect(optimisticClaudeConnectionState(false, true)).toBe(true);
expect(optimisticClaudeConnectionState(true, false)).toBe(false);
});
it("uses the confirmed state when no change is pending", () => {
expect(optimisticClaudeConnectionState(true, null)).toBe(true);
expect(optimisticClaudeConnectionState(false, null)).toBe(false);
});
});
describe("claudeDesktopRequestCountLabel", () => {
it("formats zero, singular, and plural session counts", () => {
expect(claudeDesktopRequestCountLabel(0)).toBe("0 requests this session");
expect(claudeDesktopRequestCountLabel(1)).toBe("1 request this session");
expect(claudeDesktopRequestCountLabel(2)).toBe("2 requests this session");
});
});
describe("withClaudeConnectionTimeout", () => {
it("returns a native result that finishes before the watchdog", async () => {
await expect(
withClaudeConnectionTimeout(Promise.resolve("connected"), 100),
).resolves.toBe("connected");
});
it("reports when native work settles after the watchdog", async () => {
vi.useFakeTimers();
try {
let resolveNative!: (value: string) => void;
const nativeAction = new Promise<string>((resolve) => {
resolveNative = resolve;
});
const onLateSettled = vi.fn();
const result = withClaudeConnectionTimeout(
nativeAction,
100,
onLateSettled,
);
const timedOut = expect(result).rejects.toBeInstanceOf(
ClaudeConnectionTimeoutError,
);
await vi.advanceTimersByTimeAsync(100);
await timedOut;
expect(onLateSettled).not.toHaveBeenCalled();
resolveNative("late connection");
await Promise.resolve();
expect(onLateSettled).toHaveBeenCalledOnce();
expect(onLateSettled).toHaveBeenCalledWith({
status: "fulfilled",
value: "late connection",
});
await expect(result).rejects.toBeInstanceOf(ClaudeConnectionTimeoutError);
} finally {
vi.useRealTimers();
}
});
});
describe("claudeDesktopMaxModels", () => {
it("falls back to the five literal Claude slots without a status", () => {
expect(claudeDesktopMaxModels(undefined)).toBe(5);
expect(
claudeDesktopMaxModels({
supported: true,
used: true,
installed: true,
connected: false,
running: false,
startFailed: false,
portConflict: false,
}),
).toBe(defaultClaudeDesktopMaxModels);
});
it("uses the server-provided limit when present", () => {
expect(
claudeDesktopMaxModels({
supported: true,
used: true,
installed: true,
connected: false,
running: false,
startFailed: false,
portConflict: false,
maxModels: 3,
}),
).toBe(3);
});
});
describe("claudeDesktopRecoveryMessage", () => {
it("prefers current native guidance over stale action errors", () => {
expect(
claudeDesktopRecoveryMessage(
"Cloud models are off. Select an installed model in Settings.",
"Ollama could not open Claude.",
),
).toBe("Cloud models are off. Select an installed model in Settings.");
});
it("clears after native recovery when no action error remains", () => {
expect(claudeDesktopRecoveryMessage(undefined, null)).toBeNull();
});
});
describe("addClaudeModelSelection", () => {
it("appends models below the limit", () => {
expect(addClaudeModelSelection(["kimi-k3:cloud"], "qwen3:8b", 5)).toEqual({
selection: ["kimi-k3:cloud", "qwen3:8b"],
});
});
it("rejects a sixth selection with a clear message", () => {
const selection = [
"glm-5.2:cloud",
"kimi-k3:cloud",
"deepseek-v4-pro",
"deepseek-v4-flash",
"gemma4:26b:cloud",
];
const result = addClaudeModelSelection(selection, "qwen3:8b", 5);
expect(result.selection).toBe(selection);
expect(result.error).toBe(
"Claude supports up to 5 models. Deselect one to add another.",
);
});
it("honors a smaller server-provided limit", () => {
const result = addClaudeModelSelection(["qwen3:8b"], "llama3.2", 1);
expect(result.selection).toEqual(["qwen3:8b"]);
expect(result.error).toBe(claudeDesktopMaxModelsMessage(1));
});
it("is a no-op for an already selected model", () => {
expect(addClaudeModelSelection(["qwen3:8b"], "qwen3:8b", 5)).toEqual({
selection: ["qwen3:8b"],
});
});
});
describe("claudeDesktopUsableSelection", () => {
it("replaces unavailable paid selections with an available free model", () => {
expect(
claudeDesktopUsableSelection([
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
selected: true,
availability: "unavailable",
reason: "upgrade_required",
requiredPlan: "pro",
},
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
selected: false,
availability: "available",
requiredPlan: "free",
},
]),
).toEqual(["gemma4:31b-cloud"]);
});
it("preserves selected models that remain available", () => {
expect(
claudeDesktopUsableSelection([
{
name: "gemma4:31b-cloud",
displayName: "gemma4:31b-cloud",
selected: true,
availability: "available",
},
{
name: "qwen3:8b",
displayName: "qwen3:8b",
selected: false,
availability: "available",
},
]),
).toEqual(["gemma4:31b-cloud"]);
});
it("selects all available recommendations for a default catalog", () => {
expect(
claudeDesktopUsableSelection(
[
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
selected: false,
availability: "available",
},
{
name: "kimi-k3:cloud",
displayName: "kimi-k3:cloud",
selected: false,
availability: "available",
},
],
true,
5,
),
).toEqual(["glm-5.2:cloud", "kimi-k3:cloud"]);
});
it("returns no selection when no model is available", () => {
expect(
claudeDesktopUsableSelection([
{
name: "glm-5.2:cloud",
displayName: "glm-5.2:cloud",
selected: true,
availability: "unavailable",
},
]),
).toEqual([]);
});
});
+133
View File
@@ -0,0 +1,133 @@
import type {
ClaudeDesktopModelStatus,
ClaudeDesktopStatus,
} from "@/types/webview";
export const CLAUDE_INSTALL_TIMEOUT_MS = 120_000;
export const CLAUDE_CONNECTION_TIMEOUT_MS = 45_000;
export class ClaudeConnectionTimeoutError extends Error {
constructor() {
super("Claude connection timed out");
this.name = "ClaudeConnectionTimeoutError";
}
}
export function withClaudeConnectionTimeout<T>(
action: Promise<T>,
timeoutMs = CLAUDE_CONNECTION_TIMEOUT_MS,
onLateSettled?: (result: PromiseSettledResult<T>) => void,
): Promise<T> {
return new Promise((resolve, reject) => {
let timedOut = false;
const timeout = globalThis.setTimeout(() => {
timedOut = true;
reject(new ClaudeConnectionTimeoutError());
}, timeoutMs);
action.then(
(value) => {
globalThis.clearTimeout(timeout);
if (timedOut) {
onLateSettled?.({ status: "fulfilled", value });
return;
}
resolve(value);
},
(error: unknown) => {
globalThis.clearTimeout(timeout);
if (timedOut) {
onLateSettled?.({ status: "rejected", reason: error });
return;
}
reject(error);
},
);
});
}
export function isClaudeConnectionComplete(
enabled: boolean,
status: ClaudeDesktopStatus,
) {
const configured = isClaudeConfigured(status);
return enabled
? configured && status.connected && !status.startFailed
: !configured;
}
export function isClaudeConfigured(status: ClaudeDesktopStatus): boolean {
return status.configured ?? status.connected;
}
export function optimisticClaudeConnectionState(
configured: boolean,
pending: boolean | null,
): boolean {
return pending ?? configured;
}
export function claudeDesktopRequestCountLabel(count: number): string {
return `${count} ${count === 1 ? "request" : "requests"} this session`;
}
export function claudeDesktopRecoveryMessage(
statusError?: string,
actionError?: string | null,
): string | null {
return statusError || actionError || null;
}
export function scheduleClaudeInstallTimeout(onTimeout: () => void) {
return window.setTimeout(onTimeout, CLAUDE_INSTALL_TIMEOUT_MS);
}
// Claude Desktop has a bounded model list. The app supplies the limit when it
// knows it; retain the existing five-model behavior for older app versions.
export const defaultClaudeDesktopMaxModels = 5;
export function claudeDesktopMaxModels(
status?: ClaudeDesktopStatus | null,
): number {
return status?.maxModels && status.maxModels > 0
? status.maxModels
: defaultClaudeDesktopMaxModels;
}
export function claudeDesktopMaxModelsMessage(maxModels: number): string {
return `Claude supports up to ${maxModels} models. Deselect one to add another.`;
}
// Unavailable models remain visible for account guidance, but cannot remain
// selected. Choose one available model when filtering would empty the list.
export function claudeDesktopUsableSelection(
models: ClaudeDesktopModelStatus[],
selectAllAvailable = false,
maxModels = defaultClaudeDesktopMaxModels,
): string[] {
const available = models.filter(
(model) =>
model.availability === undefined || model.availability === "available",
);
if (selectAllAvailable) {
return available.slice(0, maxModels).map((model) => model.name);
}
const selected = available
.filter((model) => model.selected)
.map((model) => model.name);
if (selected.length > 0) return selected;
return available.length > 0 ? [available[0].name] : [];
}
// addClaudeModelSelection returns the selection with name appended, or an
// unchanged selection plus an error when the Claude model limit is reached.
export function addClaudeModelSelection(
selection: string[],
name: string,
maxModels: number,
): { selection: string[]; error?: string } {
if (selection.includes(name)) return { selection };
if (selection.length >= maxModels) {
return { selection, error: claudeDesktopMaxModelsMessage(maxModels) };
}
return { selection: [...selection, name] };
}
@@ -0,0 +1,20 @@
import type { ClaudeDesktopModelStatus } from "@/types/webview";
export function claudeDesktopModelStatusLabel(
model: ClaudeDesktopModelStatus,
): string | null {
switch (model.reason) {
case "sign_in_required":
return "Sign in required";
case "upgrade_required":
return model.requiredPlan
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
: "Upgrade required";
case "verification_unavailable":
return "Access unavailable";
case "model_not_installed":
return "Not installed";
}
return null;
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import { preventPageSelectAll } from "./keyboard";
function keyboardEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key: "a",
metaKey: true,
ctrlKey: false,
target: { tagName: "BODY" } as EventTarget,
preventDefault: vi.fn(),
...overrides,
} as unknown as KeyboardEvent;
}
describe("preventPageSelectAll", () => {
it("prevents Command+A from selecting the page", () => {
const event = keyboardEvent();
preventPageSelectAll(event);
expect(event.preventDefault).toHaveBeenCalledOnce();
});
it("prevents Ctrl+A from selecting the page", () => {
const event = keyboardEvent({ metaKey: false, ctrlKey: true });
preventPageSelectAll(event);
expect(event.preventDefault).toHaveBeenCalledOnce();
});
it.each([
{ tagName: "INPUT" },
{ tagName: "TEXTAREA" },
{ tagName: "DIV", isContentEditable: true },
])("keeps Select All working in editable targets", (target) => {
const event = keyboardEvent({ target: target as EventTarget });
preventPageSelectAll(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("leaves unmodified A keypresses alone", () => {
const event = keyboardEvent({ metaKey: false });
preventPageSelectAll(event);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
+24
View File
@@ -0,0 +1,24 @@
type KeyboardTarget = EventTarget & {
tagName?: string;
isContentEditable?: boolean;
};
function isEditableTarget(target: EventTarget | null) {
if (!target || typeof target !== "object") {
return false;
}
const { tagName, isContentEditable } = target as KeyboardTarget;
return (
isContentEditable === true || tagName === "INPUT" || tagName === "TEXTAREA"
);
}
export function preventPageSelectAll(event: KeyboardEvent) {
const isSelectAll =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a";
if (isSelectAll && !isEditableTarget(event.target)) {
event.preventDefault();
}
}
+38
View File
@@ -0,0 +1,38 @@
export interface IntegrationIcon {
src: string;
darkSrc?: string;
className?: string;
}
export const INTEGRATION_ICONS: Record<string, IntegrationIcon> = {
"claude-desktop": { src: "/launch-icons/claude.svg" },
claude: { src: "/launch-icons/claude-code.svg" },
hermes: { src: "/launch-icons/hermes-agent.svg" },
"hermes-desktop": { src: "/launch-icons/hermes-agent.svg" },
openclaw: { src: "/launch-icons/openclaw.svg" },
opencode: {
src: "/launch-icons/opencode.svg",
className: "h-7 w-7 rounded",
},
codex: {
src: "/launch-icons/codex-color.svg",
},
chatgpt: {
src: "/launch-icons/codex.svg",
darkSrc: "/launch-icons/codex-dark.svg",
},
copilot: {
src: "/launch-icons/copilot.svg",
darkSrc: "/launch-icons/copilot-dark.svg",
},
droid: { src: "/launch-icons/droid.svg" },
dsh: { src: "/launch-icons/deepseek-harness.svg" },
pi: { src: "/launch-icons/pi.svg" },
cline: {
src: "/launch-icons/cline.svg",
className: "h-7 w-7 dark:invert",
},
omp: { src: "/launch-icons/oh-my-pi.svg" },
pool: { src: "/launch-icons/poolside.svg" },
qwen: { src: "/launch-icons/qwen-code.svg" },
};
Loaded 100 of 613 files, more files were not shown because too many files have changed in this diff. Show more