Compare commits

..

31 Commits

Author SHA1 Message Date
Rafail Giavrimis 687e778927 CUDA: fuse rms_norm + mul + rope (+ view + set_rows) (#26767)
* CUDA: fuse rms_norm + mul + rope (+ view + set_rows)

* tests: add broadcast weight case to rms_norm_mul_rope

* CUDA: check memory ranges before rms_norm rope fusion

* CUDA: check memory ranges in rope set_rows fusion
2026-08-09 00:32:37 +08:00
Pascal 18f7ad7fc9 server, ui: only offer a working directory when a tool reads it (#26762)
The working directory chip showed up as soon as the server exposed any
builtin tool, so a server started with just get_datetime, or a user who
turned every filesystem tool off in the settings, still got a control
that nothing would read.

Tools now declare whether they resolve their paths and run against the
working directory, next to the write permission they already publish in
the /tools listing. The WebUI shows the chip and enables the /cwd
command only when at least one such tool is both served and left
enabled.
2026-08-08 16:36:21 +02:00
Xuan-Son Nguyen dd2c7c4471 server: add initial tool isolation support (via docker) (#26507)
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
2026-08-08 16:35:53 +02:00
Rafail Giavrimis 69bf643791 CUDA: fix thread/block count in quantized cpy kernel launches (#26731)
* CUDA: fix thread/block count in quantized cpy kernel launches

* tests: add uneven block count cpy case
2026-08-08 07:40:04 +03:00
Pascal 3653e6d6d5 tts: account for the vocoder pass in the timings line (#26733)
get_output runs the waveform work the pipeline defers to it, from a
single trailing window to a full pass depending on the model. Measuring
it keeps the reported total and the audio to process ratio honest.
2026-08-07 22:35:52 +02:00
Aleksander Grygier fc6545d322 allozaur/feat/chat form contenteditable (#26717)
* feat: Add contenteditable tokenizer for badge/code-chip chat input

* feat: Add source-space undo/redo history for the rich input

* feat: Split text glued to a closing code fence onto its own line

* feat: Add ChatFormContenteditable rich input renderer

* feat : wire the contenteditable into ChatForm with auto-switch gating
2026-08-07 20:40:10 +02:00
Georgi Gerganov 1621a3d388 tests : speed-up server test suite 3x (#26734)
* tests : speed-up test suite 3x

* cont : print 30 slowest tests
2026-08-07 21:38:32 +03:00
Aleksander Grygier 6de1b63473 allozaur/feat/chat slash commands (#26716)
* base : slash-command/misc foundation - model icon and focus-selector constants

* feat : slash-command picker and command parsing helpers

* refactor : wire command and @-mention pickers into the chat form

* ui : improve model selector keyboard navigation and load/dismiss

* feat: Unify markdown/raw-text rendering under one setting with migration

* fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards

* feat: Clamp and style numeric settings inputs from registry bounds
2026-08-07 20:20:01 +02:00
Titaniumtown f8e30266d2 sycl: coalesce the ssm_conv window loads (#26612)
test-backend-ops perf -o SSM_CONV on an Arc Pro B70, interleaved A/B against
master, 6 reps, us/run:

  ne_a=[515,3328,1,1] ne_b=[4,3328,1,1]   n_t=512     97.68 -> 52.95   1.85x
  ne_a=[937,8192,1,1] ne_b=[4,8192,1,1]   n_t=934    516.16 -> 276.13  1.87x
  ne_a=[4,3328,1,1]   ne_b=[4,3328,1,1]   n_t=1        2.73 -> 2.71    flat

llama-bench on qwen35 27B Q4_K - Medium (48 of its 64 blocks run ssm_conv),
-ngl 99 -fa 1 -ctk f16 -ctv f16, interleaved passes of r=3:

  -b 2048 -ub 2048  pp2048  1045.1 / 1043.5 / 1043.7 -> 1069.5 / 1066.3 / 1065.9  +2.2%
  -b 2048 -ub 512   pp2048   771.8 /  772.7          ->  785.5 /  786.6           +1.8%
  -b 2048 -ub 512   tg128     23.81 /  23.88         ->   23.87 /  23.86          flat
2026-08-07 21:09:32 +03:00
robertomeroni a194a75b7e metal : fix NORM/RMS_NORM for row lengths that leave a partial simdgroup (#26708)
ggml_metal_op_norm sized the threadgroup with
`nth = std::min(nth, args.ne00_t)`, which can leave nth not a multiple of
the simdgroup size. The kernels finish their row reduction with a
cross-simdgroup step where each lane of the last simdgroup reads one
per-simdgroup partial sum out of shmem_f32:

    if (tiisg == 0) { shmem_f32[sgitg] = sumf; }
    threadgroup_barrier(mem_flags::mem_threadgroup);
    sumf = shmem_f32[tiisg];
    sumf = simd_sum(sumf);

When the last simdgroup is partial it has fewer lanes than the
threadgroup has simdgroups, so the tail of the partial sums is never
read and the row sum is too small. For ne00_t = 33 nth becomes 33: two
simdgroups, but only one lane in the second, so one of the two partial
sums is dropped. The mean and variance are then wrong for the whole row.

Round ne00_t up to a whole number of simdgroups instead. Rounding up
rather than dropping the clamp keeps the threadgroup as small as
possible: deleting the line would raise nth to the next power of two
(ne00_t = 544 -> 1024 instead of 544), which costs idle lanes on 26 row
lengths below 8192 that were already correct, including 1536 and 3584.

GGML_OP_NORM is affected as well as GGML_OP_RMS_NORM - both dispatch
through ggml_metal_op_norm.

No mainstream LLM hidden size hits this: ne00_t is ne00/4 on the
vectorized path, so 4096, 8192, 2048 and friends all give a multiple of
32. It is reachable from other norm shapes, e.g. 320-channel norms.

Add NORM and RMS_NORM cases for ne0 = 33, 132 and 260 across the
existing eps values. 33 exercises the scalar path and 132/260 the
vectorized one, since only those divide by 4.

Before, on M3 Pro:

    test-backend-ops test -b MTL0 -o NORM        25/50
    test-backend-ops test -b MTL0 -o RMS_NORM    26/51

After:

    test-backend-ops test -b MTL0 -o NORM        50/50
    test-backend-ops test -b MTL0 -o RMS_NORM    51/51
    test-backend-ops test -b MTL0                13943/13943
2026-08-07 21:09:07 +03:00
Aleksander Grygier 23634783c5 ui: Filesystem @mentions for Chat Form (#26715)
* base : @-mention picker foundation - glob search, picker nav, highlight

* feat : @-mention file/folder picker and mention badges in message bubbles

* fix: Imports

* feat : wire the @-mention picker into the chat form

* fix: Bound the glob-search result cache key and prune stale entries
2026-08-07 18:45:54 +02:00
Xuan-Son Nguyen 4cb22cd537 mtmd: fix longest_edge ignoring min/max pixels (#26638)
* mtmd: fix longest_edge ignoring min/max pixels

* nits
2026-08-07 18:05:15 +02:00
Georgi Gerganov 4cf5cab65d sync : ggml 2026-08-07 17:11:25 +03:00
Georgi Gerganov 933f46f3cb ggml : bump version to 0.19.0 (ggml/1581) 2026-08-07 17:11:25 +03:00
Daniel Bevenius 9ba73fd1f5 server : clarify comment in eval_llama_cmpl_schema [no ci] [no release] (#26720) 2026-08-07 15:39:33 +02:00
Emanuil Rusev f4f7758cae webui: load the model selected via ?model= when ?load=true (#26707)
* webui: load the model selected via ?model=

Opening the WebUI with ?model= selects the model but doesn't load it. The load only starts when you send your first message, so you wait for it then.

This loads it as soon as the page opens, while you're still typing your prompt. It's what the model dropdown already does, and it isn't awaited, so the UI still works while the model loads.

This is the path the Llama macOS app uses to open the WebUI, so it's a common way in.

* webui: gate the load behind ?load=true

Loading on landing is opt-in, so a plain ?model= link behaves as before and doesn't allocate memory on its own.

* webui: name the chat URL params

Collects the query params the chat routes read into a URL_PARAMS constant, instead of repeating the literals across three files. NEW_CHAT_PARAM folds into it.
2026-08-07 15:31:40 +02:00
Niklas Wenzel 34e9ee57f5 ui: set npm min-release-age to protect against supply-chain attacks (#26711)
* ui: set npm `min-release-age` to protect against supply-chain attacks

* ui: bump to 7 days
2026-08-07 14:53:51 +02:00
Xuan-Son Nguyen dff15d4ac9 server: (router) add LRU scheduler (#26572)
* add lru_sched

* handle coalescing (req leaves waiting queue)

* add tests

* fix stream case

* address review comments
2026-08-07 14:46:53 +02:00
Xuan-Son Nguyen e1470ee6a2 server: (router) do not evict busy models (#26567) 2026-08-07 14:39:59 +02:00
Pascal 217df17ac3 mtmd: stop feeding the text stream again during Qwen3-TTS generation (#26706)
The reference implementation has two mutually exclusive prompt layouts.
In non streaming mode the prefill carries the whole utterance text plus
tts_eos summed with codec_pad, and the trailing text hidden collapses to
a single tts_pad row. In streaming mode the prefill carries only the
first text token and the trailing rows stream the rest of the text
followed by tts_eos.

The pipeline built the non streaming prefill but the streaming overlay,
so the talker saw the utterance a second time during generation and read
it twice before emitting codec_eos.

The overlay is now the single tts_pad row that matches the prefill.
2026-08-07 13:32:52 +02:00
Kilian Hu cb26014d96 ggml : add aarch64 HWCAP fallbacks and fix fp16 variant detection (#25554)
* ggml : add fallback definitions for missing aarch64 HWCAP bits

* ggml : require HWCAP_ASIMDHP for the aarch64 fp16 cpu variants

Also rename has_fp16_va to has_fp16, the field gates the whole FEAT_FP16
extension, scalar and vector half-precision arithmetic together.
2026-08-07 14:07:10 +03:00
Pascal 82bb48500a ui: read model modalities from the router model list (#26709)
* ui: read model modalities from the router model list

The router advertises input modalities for every model, loaded or not.
Reading them at list build time lets the UI accept image and audio
uploads for a model selected through ?model=, which has no /props yet.

* enum
2026-08-07 12:07:58 +02:00
Masato Nakasaka 42e98813e4 Mitigate crashing issue on Windows MSYS2 UCRT64 environment (GCC 16.1.0) (#26555) 2026-08-07 11:17:16 +02:00
Chris Lee fc3f10b389 sycl: fix UE4M3 parsing (#25608)
The NVFP4 quantization format stores a scaling factor for every group of
16 weights, packed into a single UE4M3 byte.

The SYCL GPU code was converting these scale values using the E4M3 path,
but that's *signed*, and these are unsigned values.
2026-08-07 08:28:53 +03:00
Titaniumtown 6b5c2efb4e sycl: *glu flat path (#26354)
* tests: add SWIGLU perf cases

perf mode had no GLU coverage. Adds SWIGLU at 17408 columns, 512 and
2048 tokens, f16 and f32, with the operands both fused and split.

* sycl: consolidate fused-GLU kernels

They differed only in which op_* they called, so take the op as an argument and share a common launcher.
Their block sizes were all 256, so launch geometry is unchanged;
SYCL_GELU_BLOCK_SIZE and SYCL_SILU_BLOCK_SIZE lose their last users so are dropped.

* sycl: contiguous fast path for the fused GLU ops

o0 == n and o1 == n collapse the de-interleave index math to the
identity, so dispatch a flat kernel in that case. It fires for
ggml_glu_split with packed operands; a fused [gate|up] tensor keeps the
strided path. test-backend-ops perf -o SWIGLU on an Arc Pro B70: split
+14% f16 and +4% f32, fused unchanged.
2026-08-07 08:24:40 +03:00
Neo Zhang 31558dbb76 sycl : Support DSv4 OPs: LIGHTNING_INDEXER,DSV4_HC_COMB,DSV4_HC_POST,DSV4_HC_PRE (#26568)
* support DSv4 OPs: LIGHTNING_INDEXER,DSV4_HC_COMB,DSV4_HC_POST,DSV4_HC_PREwq

* update ops.md

* fix format issue
2026-08-07 08:22:23 +03:00
Neo Zhang c1f4109898 sycl : update guide Q&A and script for device setting (#26442) 2026-08-07 08:18:47 +03:00
Neo Zhang eef5f3e343 sycl : fix error Error OP FLASH_ATTN_EXT on arc770 (#26441) 2026-08-07 08:17:56 +03:00
Neo Zhang c074cb3f76 sycl : enhance OP set_rows to support all missed data types (#26515)
* support fp16 to fp16/fp32

* support all missed data types in set_rows

* refactor the code to support all data types
2026-08-07 07:52:52 +03:00
David Friehs 5b87ed30f8 cuda: fix warnings for unused variable/function (#26688) 2026-08-07 07:51:56 +03:00
Niklas Wenzel d8d9887228 ci: abort if build requirements are missing (#26368)
1. Abort CI if build requirements are missing.
2. Add check to make sure Git LFS has been configured.
3. Add trailing newlines to log messages.
2026-08-07 07:50:48 +03:00
143 changed files with 32315 additions and 1955 deletions
+22 -9
View File
@@ -643,39 +643,52 @@ function gg_sum_rerank_tiny {
function gg_check_build_requirements {
if ! command -v git &> /dev/null; then
gg_printf 'git not found, please install'
gg_printf 'git not found, please install\n'
exit 1
fi
if ! command -v git-lfs &> /dev/null; then
gg_printf 'git-lfs not found, please install'
gg_printf 'git-lfs not found, please install\n'
exit 1
fi
if ! git config --get filter.lfs.clean &> /dev/null; then
gg_printf 'git-lfs not initialized, please run `git lfs install`\n'
exit 1
fi
if ! command -v wget &> /dev/null; then
gg_printf 'wget not found, please install'
gg_printf 'wget not found, please install\n'
exit 1
fi
if ! command -v python3 &> /dev/null; then
gg_printf 'python3 not found, please install'
gg_printf 'python3 not found, please install\n'
exit 1
fi
if ! command -v pip3 &> /dev/null; then
gg_printf 'pip3 not found, please install'
gg_printf 'pip3 not found, please install\n'
exit 1
fi
if ! python3 -m ensurepip --help &> /dev/null; then
gg_printf 'ensurepip not found, please install python3-venv package'
gg_printf 'ensurepip not found, please install python3-venv package\n'
exit 1
fi
if ! command -v cmake &> /dev/null; then
gg_printf 'cmake not found, please install'
gg_printf 'cmake not found, please install\n'
exit 1
fi
if ! command -v ccache &> /dev/null; then
gg_printf 'ccache not found, please consider installing for faster builds'
gg_printf 'ccache not found, please consider installing for faster builds\n'
fi
if ! command -v ctest &> /dev/null; then
gg_printf 'ctest not found, please install'
gg_printf 'ctest not found, please install\n'
exit 1
fi
}
+10
View File
@@ -3308,6 +3308,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
params.server_tools = parse_csv_row(value);
}
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS"));
add_opt(common_arg(
{"--tools-runtime"}, "OPTION",
"experimental: run tools in a separate runtime environment (default: none, use host environment)\n"
"available options:\n"
" 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n"
" 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit\n",
[](common_params & params, const std::string & value) {
params.server_tools_runtime = value;
}
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS_RUNTIME"));
add_opt(common_arg(
{"--mcp-servers-config"}, "PATH",
"experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)\n"
+1
View File
@@ -655,6 +655,7 @@ struct common_params {
// enable built-in tools
std::vector<std::string> server_tools;
std::string server_tools_runtime;
// MCP server configs (Cursor-compatible JSON)
std::string mcp_servers_config; // path to JSON file with MCP server definitions
+42
View File
@@ -449,6 +449,8 @@ Or
use 1 SYCL GPUs: [0] with Max compute units:512
```
User can use the device management in [docs/multi-gpu.md](https://github.com/ggml-org/llama.cpp/blob/master/docs/multi-gpu.md), like parameter `--device SYCL0,SYCL1` to assign one or more devices.
## Windows
### Install GPU driver
@@ -763,6 +765,7 @@ Or
use 1 SYCL GPUs: [0] with Max compute units:512
```
User can use the device management in [docs/multi-gpu.md](https://github.com/ggml-org/llama.cpp/blob/master/docs/multi-gpu.md), like parameter `--device SYCL0,SYCL1` to assign one or more devices.
## Environment Variable
@@ -895,6 +898,45 @@ Pass these via `CXXFLAGS` or add a one-off `#define` to enable a flag on the spo
set UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1
```
- When I set `SYCL_CACHE_PERSISTENT=1` in running time, I meet crash.
`SYCL_CACHE_PERSISTENT=1` is not recommended by llama.cpp SYCL backend.
When cache is enabled, SYCL runtime will try to cache and reuse JIT-compiled binaries.
We find some AI will tell user this cmd to speed up SYCL backend. It only speeds up the startup to skip the JIT process, instead of running speed.
It will bring negative impact when the SYCL binary file is changed frequently in your running environment. The new & old codes mix will lead to crash.
Compare to the benefit, it has brought more failed cases.
If you are not familiar with the SYCL compiler principle of JIT and AOT, please don't use it.
To restore, you need to remove the local cache: `~/.cache/libsycl_cache/` and execute `unset SYCL_CACHE_PERSISTENT` in running time.
- How to use iGPU and dGPU in same time?
1. Detect the devices in your running time.
```
source /opt/intel/oneapi/setvars.sh
./build/bin/llama-server --list-devices
or
./build/bin/llama-cli --list-devices
./build/bin/llama-bench --list-devices
./build/bin/llama-completion --list-devices
Available devices:
SYCL0: Intel(R) Arc(TM) A770 Graphics (15473 MiB, 15473 MiB free)
SYCL1: Intel(R) UHD Graphics 770 (59675 MiB, 44986 MiB free)
```
The dGPU will be in the head of this list and iGPU will be the end.
If not all GPUs are listed, please check the env var: ONEAPI_DEVICE_SELECTOR and unset it.
2. Set the iGPU and dGPU
Set the iGPU and dGPU by `./build/bin/llama-server --device SYCL0,SYCL1,SYCLxxx`.
### **GitHub contribution**:
Please add the `[SYCL]` prefix/tag in issues/PRs titles to help the SYCL contributors to check/address them without delay.
+6 -6
View File
@@ -15,7 +15,7 @@ Legend:
| Operation | BLAS | CANN | CPU | CUDA | ET | MTL | OpenCL | SYCL | Vulkan | WebGPU | ZenDNN | zDNN |
|-----------|------|------|------|------|------|------|------|------|------|------|------|------|
| ABS | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| ACC | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | 🟡 | ✅ | ❌ | ❌ | ❌ |
| ACC | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | | ✅ | ❌ | ❌ | ❌ |
| ADD | ❌ | ✅ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| ADD1 | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| ADD_ID | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -41,9 +41,9 @@ Legend:
| DIAG | ❌ | ❌ | ✅ | ✅ | 🟡 | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| DIAG_MASK_INF | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | 🟡 | ✅ | ✅ | ❌ | ❌ | ❌ |
| DIV | ❌ | ✅ | ✅ | ✅ | ❌ | 🟡 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| DSV4_HC_COMB | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DSV4_HC_POST | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DSV4_HC_PRE | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DSV4_HC_COMB | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DSV4_HC_POST | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DSV4_HC_PRE | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| DUP | ❌ | ✅ | ✅ | 🟡 | ❌ | 🟡 | 🟡 | ✅ | ✅ | ❌ | ❌ | ❌ |
| ELU | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| EXP | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -59,7 +59,7 @@ Legend:
| GELU | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | 🟡 | ✅ | ✅ | ✅ | ❌ | ❌ |
| GELU_ERF | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | 🟡 | ✅ | ✅ | ✅ | ❌ | ❌ |
| GELU_QUICK | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | 🟡 | ✅ | ✅ | ✅ | ❌ | ❌ |
| GET_ROWS | ❌ | 🟡 | ✅ | 🟡 | 🟡 | 🟡 | 🟡 | | ✅ | 🟡 | ❌ | ❌ |
| GET_ROWS | ❌ | 🟡 | ✅ | 🟡 | 🟡 | 🟡 | 🟡 | 🟡 | ✅ | 🟡 | ❌ | ❌ |
| GET_ROWS_BACK | ❌ | ❌ | 🟡 | 🟡 | ❌ | ❌ | ❌ | ❌ | 🟡 | ❌ | ❌ | ❌ |
| GROUP_NORM | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| HARDSIGMOID | ❌ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
@@ -68,7 +68,7 @@ Legend:
| IM2COL_3D | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| L2_NORM | ❌ | ✅ | ✅ | ✅ | 🟡 | ✅ | ❌ | ✅ | ✅ | 🟡 | ❌ | ❌ |
| LEAKY_RELU | ❌ | ✅ | ✅ | ✅ | ❌ | 🟡 | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| LIGHTNING_INDEXER | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| LIGHTNING_INDEXER | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | ❌ | ❌ | ❌ | ❌ |
| LOG | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ |
| MEAN | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| MUL | ❌ | ✅ | ✅ | ✅ | 🟡 | 🟡 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
+22870 -671
View File
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -12,6 +12,7 @@ This script processes files with specified options.
Options:
-h, --help Display this help message and exit.
-d, --device <value> Set SYCL devices (default: SYCL0).
-c, --context <value> Set context length. Bigger need more memory.
-p, --promote <value> Prompt to start generation with.
-m, --model <value> Full model file path.
@@ -41,10 +42,16 @@ MODEL_FILE=../models/Qwen3.5-4B-Q4_0.gguf
NGL=99
CONTEXT=4096
GGML_SYCL_DEVICE=-1
SYCL_DEVICES="SYCL0"
SPLIT_MODE=layer
LOG_VERBOSE=3
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--device)
SYCL_DEVICES="$2"
shift
shift
;;
-c|--context)
CONTEXT=$2
# Shift twice to consume both the option flag and its value
@@ -95,8 +102,6 @@ while [[ $# -gt 0 ]]; do
esac
done
source /opt/intel/oneapi/setvars.sh
#export GGML_SYCL_DEBUG=1
@@ -107,17 +112,19 @@ source /opt/intel/oneapi/setvars.sh
export UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1
echo "UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=${UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS}"
echo "ONEAPI_DEVICE_SELECTOR=${ONEAPI_DEVICE_SELECTOR}"
if [ $GGML_SYCL_DEVICE -ne -1 ]; then
echo "Use $GGML_SYCL_DEVICE as main GPU"
#use signle GPU only
GPUS_SETTING="-mg $GGML_SYCL_DEVICE -sm ${SPLIT_MODE}"
echo "ONEAPI_DEVICE_SELECTOR=${ONEAPI_DEVICE_SELECTOR}"
else
echo "Use all Intel GPUs, including iGPU & dGPU"
echo "Use Intel GPUs: ${SYCL_DEVICES}"
GPUS_SETTING="-sm ${SPLIT_MODE}"
fi
fi
echo "run cmd: ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --mmap --host 0.0.0.0 --port 8000"
ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --mmap --host 0.0.0.0 --port 8000
echo "run cmd: ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --device ${SYCL_DEVICES} --mmap --host 0.0.0.0 --port 8000"
ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --device ${SYCL_DEVICES} --mmap --host 0.0.0.0 --port 8000
+12 -4
View File
@@ -12,6 +12,7 @@ This script processes files with specified options.
Options:
-h, --help Display this help message and exit.
-d, --device <value> Set SYCL devices (default: SYCL0).
-c, --context <value> Set context length. Bigger need more memory.
-p, --promote <value> Prompt to start generation with.
-m, --model <value> Full model file path.
@@ -42,10 +43,16 @@ MODEL_FILE=../models/llama-2-7b.Q4_0.gguf
NGL=99
CONTEXT=4096
GGML_SYCL_DEVICE=-1
SYCL_DEVICES="SYCL0"
SPLIT_MODE=layer
LOG_VERBOSE=3
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--device)
SYCL_DEVICES="$2"
shift
shift
;;
-c|--context)
CONTEXT=$2
# Shift twice to consume both the option flag and its value
@@ -115,16 +122,17 @@ source /opt/intel/oneapi/setvars.sh
export UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1
echo "UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=${UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS}"
echo "ONEAPI_DEVICE_SELECTOR=${ONEAPI_DEVICE_SELECTOR}"
if [ $GGML_SYCL_DEVICE -ne -1 ]; then
echo "Use $GGML_SYCL_DEVICE as main GPU"
#use signle GPU only
GPUS_SETTING="-mg $GGML_SYCL_DEVICE -sm ${SPLIT_MODE}"
echo "ONEAPI_DEVICE_SELECTOR=${ONEAPI_DEVICE_SELECTOR}"
else
echo "Use all Intel GPUs, including iGPU & dGPU"
echo "Use Intel GPUs: ${SYCL_DEVICES}"
GPUS_SETTING="-sm ${SPLIT_MODE}"
fi
echo "run cmd: ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -no-cnv -p "${INPUT_PROMPT}" -n 200 -e -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --mmap "
ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -no-cnv -p "${INPUT_PROMPT}" -n 200 -e -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --mmap
echo "run cmd: ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -no-cnv -p "${INPUT_PROMPT}" -n 200 -e -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --device ${SYCL_DEVICES} --mmap "
ZES_ENABLE_SYSMAN=1 ${BIN_FILE} -m ${MODEL_FILE} -no-cnv -p "${INPUT_PROMPT}" -n 200 -e -ngl ${NGL} -s ${SEED} -c ${CONTEXT} ${GPUS_SETTING} -lv ${LOG_VERBOSE} --device ${SYCL_DEVICES} --mmap
+23 -5
View File
@@ -13,6 +13,7 @@ set "MODEL_FILE=..\models\Qwen3.5-4B-Q4_0.gguf"
set "NGL=99"
set "CONTEXT=4096"
set "GGML_SYCL_DEVICE=-1"
set "SYCL_DEVICES=SYCL0"
set "SPLIT_MODE=layer"
set "LOG_VERBOSE=3"
@@ -36,6 +37,21 @@ if /I "%~1"=="--context" (
goto parse_args
)
if /I "%~1"=="-d" (
if "%~2"=="" goto missing_value
set "SYCL_DEVICES=%~2"
shift
shift
goto parse_args
)
if /I "%~1"=="--device" (
if "%~2"=="" goto missing_value
set "SYCL_DEVICES=%~2"
shift
shift
goto parse_args
)
if /I "%~1"=="-m" (
if "%~2"=="" goto missing_value
set "MODEL_FILE=%~2"
@@ -130,6 +146,7 @@ echo This script processes files with specified options.
echo.
echo Options:
echo -h, --help Display this help message and exit.
echo -d, --device ^<value^> Set SYCL devices (default: SYCL0).
echo -c, --context ^<value^> Set context length. Bigger need more memory.
echo -m, --model ^<value^> Full model file path.
echo -mg,--main-gpu ^<value^> Set main GPU ID (0 - n) for single GPU mode.
@@ -160,19 +177,20 @@ REM Support malloc device memory more than 4GB.
set "UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1"
echo UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=%UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS%
echo ONEAPI_DEVICE_SELECTOR=%ONEAPI_DEVICE_SELECTOR%
if not "%GGML_SYCL_DEVICE%"=="-1" (
echo Use %GGML_SYCL_DEVICE% as main GPU
REM Use single GPU only.
set "GPUS_SETTING=-mg %GGML_SYCL_DEVICE% -sm %SPLIT_MODE%"
echo ONEAPI_DEVICE_SELECTOR=%ONEAPI_DEVICE_SELECTOR%
) else (
echo Use all Intel GPUs, including iGPU ^& dGPU
) else (
echo Use Intel GPUs: %SYCL_DEVICES%
set "GPUS_SETTING=-sm %SPLIT_MODE%"
)
echo run cmd: ZES_ENABLE_SYSMAN=1 %BIN_FILE% -m "%MODEL_FILE%" -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --mmap --host 0.0.0.0 --port 8000
echo run cmd: ZES_ENABLE_SYSMAN=1 %BIN_FILE% -m "%MODEL_FILE%" -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --device %SYCL_DEVICES% --mmap --host 0.0.0.0 --port 8000
set "ZES_ENABLE_SYSMAN=1"
%BIN_FILE% -m "%MODEL_FILE%" -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --mmap --host 0.0.0.0 --port 8000
%BIN_FILE% -m "%MODEL_FILE%" -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --device "%SYCL_DEVICES%" --mmap --host 0.0.0.0 --port 8000
endlocal
+24 -5
View File
@@ -19,6 +19,7 @@ set "MODEL_FILE=..\models\llama-2-7b.Q4_0.gguf"
set "NGL=99"
set "CONTEXT=4096"
set "GGML_SYCL_DEVICE=-1"
set "SYCL_DEVICES=SYCL0"
set "SPLIT_MODE=layer"
set "LOG_VERBOSE=3"
@@ -42,6 +43,21 @@ if /I "%~1"=="--context" (
goto parse_args
)
if /I "%~1"=="-d" (
if "%~2"=="" goto missing_value
set "SYCL_DEVICES=%~2"
shift
shift
goto parse_args
)
if /I "%~1"=="--device" (
if "%~2"=="" goto missing_value
set "SYCL_DEVICES=%~2"
shift
shift
goto parse_args
)
if /I "%~1"=="-p" (
if "%~2"=="" goto missing_value
set "INPUT_PROMPT=%~2"
@@ -151,6 +167,7 @@ echo This script processes files with specified options.
echo.
echo Options:
echo -h, --help Display this help message and exit.
echo -d, --device ^<value^> Set SYCL devices (default: SYCL0).
echo -c, --context ^<value^> Set context length. Bigger need more memory.
echo -p, --promote ^<value^> Prompt to start generation with.
echo -m, --model ^<value^> Full model file path.
@@ -182,19 +199,21 @@ REM Support malloc device memory more than 4GB.
set "UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=1"
echo UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS=%UR_L0_ENABLE_RELAXED_ALLOCATION_LIMITS%
echo ONEAPI_DEVICE_SELECTOR=%ONEAPI_DEVICE_SELECTOR%
if not "%GGML_SYCL_DEVICE%"=="-1" (
echo Use %GGML_SYCL_DEVICE% as main GPU
REM Use single GPU only.
set "GPUS_SETTING=-mg %GGML_SYCL_DEVICE% -sm %SPLIT_MODE%"
echo ONEAPI_DEVICE_SELECTOR=%ONEAPI_DEVICE_SELECTOR%
) else (
echo Use all Intel GPUs, including iGPU ^& dGPU
)
else (
echo Use Intel GPUs: %SYCL_DEVICES%
set "GPUS_SETTING=-sm %SPLIT_MODE%"
)
echo run cmd: ZES_ENABLE_SYSMAN=1 %BIN_FILE% -m %MODEL_FILE% -no-cnv -p "%INPUT_PROMPT%" -n 200 -e -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --mmap
echo run cmd: ZES_ENABLE_SYSMAN=1 %BIN_FILE% -m %MODEL_FILE% -no-cnv -p "%INPUT_PROMPT%" -n 200 -e -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --device %SYCL_DEVICES% --mmap
set "ZES_ENABLE_SYSMAN=1"
%BIN_FILE% -m "%MODEL_FILE%" -no-cnv -p "%INPUT_PROMPT%" -n 200 -e -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --mmap
%BIN_FILE% -m "%MODEL_FILE%" -no-cnv -p "%INPUT_PROMPT%" -n 200 -e -ngl %NGL% -s %SEED% -c %CONTEXT% %GPUS_SETTING% -lv %LOG_VERBOSE% --device "%SYCL_DEVICES%" --mmap
endlocal
+2 -2
View File
@@ -4,8 +4,8 @@ project("ggml" C CXX ASM)
### GGML Version
set(GGML_VERSION_MAJOR 0)
set(GGML_VERSION_MINOR 18)
set(GGML_VERSION_PATCH 1)
set(GGML_VERSION_MINOR 19)
set(GGML_VERSION_PATCH 0)
set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/")
+19 -3
View File
@@ -8,6 +8,22 @@
#include <sys/sysctl.h>
#endif
#if !defined(HWCAP_FPHP)
#define HWCAP_FPHP (1 << 9)
#endif
#if !defined(HWCAP_ASIMDHP)
#define HWCAP_ASIMDHP (1 << 10)
#endif
#if !defined(HWCAP_ASIMDDP)
#define HWCAP_ASIMDDP (1 << 20)
#endif
#if !defined(HWCAP_SVE)
#define HWCAP_SVE (1 << 22)
#endif
#if !defined(HWCAP2_SVE2)
#define HWCAP2_SVE2 (1 << 1)
#endif
@@ -23,7 +39,7 @@
struct aarch64_features {
// has_neon not needed, aarch64 has NEON guaranteed
bool has_dotprod = false;
bool has_fp16_va = false;
bool has_fp16 = false;
bool has_sve = false;
bool has_sve2 = false;
bool has_i8mm = false;
@@ -36,7 +52,7 @@ struct aarch64_features {
uint32_t hwcap2 = getauxval(AT_HWCAP2);
has_dotprod = !!(hwcap & HWCAP_ASIMDDP);
has_fp16_va = !!(hwcap & HWCAP_FPHP);
has_fp16 = !!(hwcap & HWCAP_FPHP) && !!(hwcap & HWCAP_ASIMDHP);
has_sve = !!(hwcap & HWCAP_SVE);
has_sve2 = !!(hwcap2 & HWCAP2_SVE2);
has_i8mm = !!(hwcap2 & HWCAP2_I8MM);
@@ -75,7 +91,7 @@ static int ggml_backend_cpu_aarch64_score() {
score += 1<<1;
#endif
#ifdef GGML_USE_FP16_VECTOR_ARITHMETIC
if (!af.has_fp16_va) { return 0; }
if (!af.has_fp16) { return 0; }
score += 1<<2;
#endif
#ifdef GGML_USE_SVE
+22 -22
View File
@@ -253,9 +253,9 @@ static void ggml_cpy_f32_q8_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK8_0 == 0);
const int64_t num_blocks = ne / QK8_0;
const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q8_0, QK8_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q8_0, QK8_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -264,9 +264,9 @@ static void ggml_cpy_q8_0_f32_cuda(
const int64_t ne00, const int64_t ne01, const int64_t ne02, const int64_t nb00, const int64_t nb01, const int64_t nb02,
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q8_0_f32, QK8_0><<<num_blocks, 1, 0, stream>>>
cpy_q_f32<cpy_blck_q8_0_f32, QK8_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -276,9 +276,9 @@ static void ggml_cpy_f32_q4_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_0 == 0);
const int64_t num_blocks = ne / QK4_0;
const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q4_0, QK4_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q4_0, QK4_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -289,9 +289,9 @@ static void ggml_cpy_q4_0_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_0, QK4_0>, QK4_0><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_0, QK4_0>, QK4_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -302,9 +302,9 @@ static void ggml_cpy_f32_q4_1_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_1 == 0);
const int64_t num_blocks = ne / QK4_1;
const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q4_1, QK4_1><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q4_1, QK4_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -315,9 +315,9 @@ static void ggml_cpy_q4_1_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_1, QK4_1>, QK4_1><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_1, QK4_1>, QK4_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -328,9 +328,9 @@ static void ggml_cpy_f32_q5_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK5_0 == 0);
const int64_t num_blocks = ne / QK5_0;
const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q5_0, QK5_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q5_0, QK5_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -341,9 +341,9 @@ static void ggml_cpy_q5_0_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_0, QK5_0>, QK5_0><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_0, QK5_0>, QK5_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -354,9 +354,9 @@ static void ggml_cpy_f32_q5_1_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK5_1 == 0);
const int64_t num_blocks = ne / QK5_1;
const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q5_1, QK5_1><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q5_1, QK5_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -367,9 +367,9 @@ static void ggml_cpy_q5_1_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_1, QK5_1>, QK5_1><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_1, QK5_1>, QK5_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -380,9 +380,9 @@ static void ggml_cpy_f32_iq4_nl_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_NL == 0);
const int64_t num_blocks = ne / QK4_NL;
const int64_t num_blocks = (ne/QK4_NL + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_iq4_nl, QK4_NL><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_iq4_nl, QK4_NL><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
+89 -1
View File
@@ -2651,6 +2651,52 @@ static bool ggml_cuda_should_fuse_rope_set_rows(const ggml_tensor * rope,
return true;
}
static bool ggml_cuda_should_fuse_rms_norm_mul_rope(const ggml_tensor * rms_norm,
const ggml_tensor * mul,
const ggml_tensor * rope) {
if (rms_norm->op != GGML_OP_RMS_NORM || mul->op != GGML_OP_MUL || rope->op != GGML_OP_ROPE) {
return false;
}
if (rms_norm->src[0]->type != GGML_TYPE_F32 || rms_norm->type != GGML_TYPE_F32 ||
mul->src[0]->type != GGML_TYPE_F32 || mul->src[1]->type != GGML_TYPE_F32 ||
mul->type != GGML_TYPE_F32 || rope->type != GGML_TYPE_F32) {
return false;
}
if (rope->src[0] != mul) {
return false;
}
//if rms norm is the B operand, then we don't handle broadcast
if (rms_norm == mul->src[1] && !ggml_are_same_shape(mul->src[0], rms_norm)) {
return false;
}
if (!ggml_are_same_shape(rms_norm, mul)) {
return false;
}
//rms_norm kernel assumes contiguous rows
if (!ggml_is_contiguous_rows(rms_norm->src[0]) ||
!ggml_is_contiguous_rows(mul->src[0]) || !ggml_is_contiguous_rows(mul->src[1])) {
return false;
}
// the fused kernel handles the norm/neox rope modes only
const int mode = ((const int32_t *) rope->op_params)[2];
if (mode != GGML_ROPE_TYPE_NORMAL && mode != GGML_ROPE_TYPE_NEOX) {
return false;
}
const int n_dims = ((const int32_t *) rope->op_params)[1];
if (n_dims % 2 != 0 || rope->src[0]->ne[0] % 2 != 0) {
return false;
}
return true;
}
// match gated_delta_net + the strided cpy that scatters its state snapshots into the cache
// (slot i -> rollback group i, slot 0 newest), so the kernel can write them and skip the cpy.
static int ggml_cuda_try_gdn_cache_fusion(
@@ -2980,6 +3026,36 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph,
}
}
std::initializer_list<enum ggml_op> rms_norm_mul_rope_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE };
std::initializer_list<enum ggml_op> rms_norm_mul_rope_set_rows_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS };
if (is_equal(rms_norm_mul_rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 4 })) {
const ggml_tensor * rms_norm = cgraph->nodes[node_idx];
const ggml_tensor * mul = cgraph->nodes[node_idx + 1];
const ggml_tensor * rope = cgraph->nodes[node_idx + 2];
const ggml_tensor * view = cgraph->nodes[node_idx + 3];
const ggml_tensor * set_rows = cgraph->nodes[node_idx + 4];
if (ggml_check_edges(cgraph, node_idx, {{1, 0, 0}, {2, 0, 1}, {3, 0, 2}, {4, 0, 3}}) &&
ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope) &&
ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) {
int out_nodes[] = { node_idx + 4 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
}
if (is_equal(rms_norm_mul_rope_ops, ops) && ggml_can_fuse(cgraph, node_idx, ops)) {
const ggml_tensor * rms_norm = cgraph->nodes[node_idx];
const ggml_tensor * mul = cgraph->nodes[node_idx + 1];
const ggml_tensor * rope = cgraph->nodes[node_idx + 2];
if (ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope)) {
int out_nodes[] = { node_idx + 2 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
return false;
}
std::initializer_list<enum ggml_op> rope_set_rows_ops = { GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS };
if (is_equal(rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 2 })) {
@@ -2988,7 +3064,8 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph,
const ggml_tensor * set_rows = cgraph->nodes[node_idx + 2];
if (ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) {
return true;
int out_nodes[] = { node_idx + 2 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
}
@@ -3840,6 +3917,16 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph
return fused_node_count - 1;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }, {})) {
ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], cgraph->nodes[i + 4]);
return 4;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }, {})) {
ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], nullptr);
return 2;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ADD }, {})) {
ggml_cuda_op_rms_norm_fused_add(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]);
return 2;
@@ -5209,6 +5296,7 @@ static bool ggml_backend_cuda_device_offload_op(ggml_backend_dev_t dev, const gg
static ggml_backend_event_t ggml_backend_cuda_device_event_new(ggml_backend_dev_t dev) {
#ifdef GGML_CUDA_NO_PEER_COPY
GGML_UNUSED(dev);
return nullptr;
#else
ggml_backend_cuda_device_context * dev_ctx = (ggml_backend_cuda_device_context *)dev->context;
+1 -1
View File
@@ -8,7 +8,6 @@ struct __builtin_align__(32) float8 {
float x; float y; float z; float w;
float p; float q; float r; float s;
};
#endif
#if CUDART_VERSION >= 12080
static __device__ __forceinline__ float nvfp4_native_scale_error(
@@ -49,6 +48,7 @@ static __device__ __forceinline__ float nvfp4_native_scale_error(
return err;
}
#endif // CUDART_VERSION >= 12080
#endif // defined(BLACKWELL_MMA_AVAILABLE)
__launch_bounds__(CUDA_QUANTIZE_BLOCK_SIZE, 1)
static __global__ void quantize_q8_1(
+235
View File
@@ -670,3 +670,238 @@ void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst)
void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rope, ggml_tensor * set_rows) {
ggml_cuda_op_rope_impl<true>(ctx, rope, set_rows);
}
// fused RMS_NORM + MUL + ROPE (+ VIEW + SET_ROWS)
// one block per row: block_reduce gives the norm scale, then each thread applies mul and rope to the elements it owns
template <int block_size, bool has_ff, typename D>
static __global__ void rms_norm_mul_rope_f32(
const float * x, D * dst, const int ncols,
const int64_t s01, const int64_t s02, const int64_t s03,
const int64_t s1, const int64_t s2, const int64_t s3,
const float eps,
const float * mul,
const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03,
const uint3 mul_ncols_packed, const uint3 mul_nrows_packed,
const uint3 mul_nchannels_packed, const uint3 mul_nsamples_packed,
const int n_dims, const int32_t * pos,
const float freq_scale, const float ext_factor, const float attn_factor,
const rope_corr_dims corr_dims, const float theta_scale,
const float * freq_factors,
const int64_t * row_indices, const int set_rows_stride,
const bool is_neox) {
ggml_cuda_pdl_lc();
const int row = blockIdx.x;
const int channel = blockIdx.y;
const int sample = blockIdx.z;
const int tid = threadIdx.x;
x += sample*s03 + channel*s02 + row*s01;
const uint32_t mul_row = fastmodulo(row, mul_nrows_packed);
const uint32_t mul_channel = fastmodulo(channel, mul_nchannels_packed);
const uint32_t mul_sample = fastmodulo(sample, mul_nsamples_packed);
mul += mul_sample*mul_s03 + mul_channel*mul_s02 + mul_row*mul_s01;
float tmp = 0.0f;
ggml_cuda_pdl_sync();
for (int col = tid; col < ncols; col += block_size) {
const float xi = x[col];
tmp += xi * xi;
}
extern __shared__ float s_sum[];
tmp = block_reduce<block_reduce_method::SUM, block_size>(tmp, s_sum);
const float scale = rsqrtf(tmp/ncols + eps);
int64_t idst = sample*s3 + channel*s2 + row*s1;
if (set_rows_stride != 0) {
idst = row*s1 + row_indices[channel]*set_rows_stride;
}
dst += idst;
for (int i0 = 2*tid; i0 < ncols; i0 += 2*block_size) {
int ix0;
int ix1;
if (is_neox && i0 < n_dims) {
ix0 = i0/2;
ix1 = i0/2 + n_dims/2;
} else {
ix0 = i0 + 0;
ix1 = i0 + 1;
}
const float x0 = scale * x[ix0] * mul[fastmodulo(ix0, mul_ncols_packed)];
const float x1 = scale * x[ix1] * mul[fastmodulo(ix1, mul_ncols_packed)];
if (i0 >= n_dims) {
dst[ix0] = ggml_cuda_cast<D>(x0);
dst[ix1] = ggml_cuda_cast<D>(x1);
continue;
}
const float theta_base = pos[channel]*powf(theta_scale, i0/2.0f);
const float freq_factor = has_ff ? freq_factors[i0/2] : 1.0f;
float cos_theta;
float sin_theta;
rope_yarn<true>(theta_base/freq_factor, freq_scale, corr_dims, i0, ext_factor, attn_factor, cos_theta, sin_theta);
dst[ix0] = ggml_cuda_cast<D>(x0*cos_theta - x1*sin_theta);
dst[ix1] = ggml_cuda_cast<D>(x0*sin_theta + x1*cos_theta);
}
}
template <typename D>
static void rms_norm_mul_rope_cuda(
const float * x, D * dst,
const int ncols, const int nrows, const int nchannels, const int nsamples,
const int64_t s01, const int64_t s02, const int64_t s03,
const int64_t s1, const int64_t s2, const int64_t s3,
const float eps,
const float * mul,
const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03,
const uint32_t mul_ncols, const uint32_t mul_nrows,
const uint32_t mul_nchannels, const uint32_t mul_nsamples,
const int n_dims, const int32_t * pos,
const float freq_scale, const float freq_base, const float ext_factor, const float attn_factor,
const rope_corr_dims corr_dims,
const float * freq_factors,
const int64_t * row_indices, const int set_rows_stride,
const bool is_neox, cudaStream_t stream) {
GGML_ASSERT(ncols % 2 == 0);
const dim3 blocks_num(nrows, nchannels, nsamples);
const float theta_scale = powf(freq_base, -2.0f/n_dims);
const uint3 mul_ncols_packed = init_fastdiv_values(mul_ncols);
const uint3 mul_nrows_packed = init_fastdiv_values(mul_nrows);
const uint3 mul_nchannels_packed = init_fastdiv_values(mul_nchannels);
const uint3 mul_nsamples_packed = init_fastdiv_values(mul_nsamples);
if (ncols < 1024) {
const dim3 block_dims(256, 1, 1);
const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream};
if (freq_factors == nullptr) {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, false, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
} else {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, true, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
}
} else {
const dim3 block_dims(1024, 1, 1);
const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream};
if (freq_factors == nullptr) {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, false, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
} else {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, true, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
}
}
}
void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx,
ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows) {
const ggml_tensor * x = rms_norm->src[0];
const ggml_tensor * mul_src = mul->src[0] == rms_norm ? mul->src[1] : mul->src[0];
float eps = 0.0f;
memcpy(&eps, rms_norm->op_params, sizeof(float));
GGML_ASSERT(eps >= 0.0f);
GGML_ASSERT(x->type == GGML_TYPE_F32);
GGML_ASSERT(mul_src->type == GGML_TYPE_F32);
GGML_ASSERT(rope->type == GGML_TYPE_F32);
void * dst_d = rope->data;
ggml_type dst_type = rope->type;
const int64_t * row_indices = nullptr;
int set_rows_stride = 0;
if (set_rows != nullptr) {
dst_d = set_rows->data;
dst_type = set_rows->type;
row_indices = (const int64_t *) set_rows->src[1]->data;
set_rows_stride = set_rows->nb[1] / ggml_type_size(set_rows->type);
}
const int n_dims = ((const int32_t *) rope->op_params)[1];
const int mode = ((const int32_t *) rope->op_params)[2];
const int n_ctx_orig = ((const int32_t *) rope->op_params)[4];
float freq_base;
float freq_scale;
float ext_factor;
float attn_factor;
float beta_fast;
float beta_slow;
memcpy(&freq_base, (const int32_t *) rope->op_params + 5, sizeof(float));
memcpy(&freq_scale, (const int32_t *) rope->op_params + 6, sizeof(float));
memcpy(&ext_factor, (const int32_t *) rope->op_params + 7, sizeof(float));
memcpy(&attn_factor, (const int32_t *) rope->op_params + 8, sizeof(float));
memcpy(&beta_fast, (const int32_t *) rope->op_params + 9, sizeof(float));
memcpy(&beta_slow, (const int32_t *) rope->op_params + 10, sizeof(float));
const bool is_neox = mode & GGML_ROPE_TYPE_NEOX;
const int32_t * pos = (const int32_t *) rope->src[1]->data;
const float * freq_factors = rope->src[2] != nullptr ? (const float *) rope->src[2]->data : nullptr;
rope_corr_dims corr_dims;
ggml_rope_yarn_corr_dims(n_dims, n_ctx_orig, freq_base, beta_fast, beta_slow, corr_dims.v);
const size_t ts0 = ggml_type_size(x->type);
GGML_ASSERT(x->nb[0] == ts0);
const int64_t s01 = x->nb[1] / ts0;
const int64_t s02 = x->nb[2] / ts0;
const int64_t s03 = x->nb[3] / ts0;
const size_t ts_mul = ggml_type_size(mul_src->type);
GGML_ASSERT(mul_src->nb[0] == ts_mul);
const int64_t mul_s01 = mul_src->nb[1] / ts_mul;
const int64_t mul_s02 = mul_src->nb[2] / ts_mul;
const int64_t mul_s03 = mul_src->nb[3] / ts_mul;
const size_t ts_dst = ggml_type_size(rope->type);
const int64_t s1 = rope->nb[1] / ts_dst;
const int64_t s2 = rope->nb[2] / ts_dst;
const int64_t s3 = rope->nb[3] / ts_dst;
cudaStream_t stream = ctx.stream();
if (dst_type == GGML_TYPE_F32) {
rms_norm_mul_rope_cuda((const float *) x->data, (float *) dst_d,
x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps,
(const float *) mul_src->data, mul_s01, mul_s02, mul_s03,
mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3],
n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims,
freq_factors, row_indices, set_rows_stride, is_neox, stream);
} else if (dst_type == GGML_TYPE_F16) {
rms_norm_mul_rope_cuda((const float *) x->data, (half *) dst_d,
x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps,
(const float *) mul_src->data, mul_s01, mul_s02, mul_s03,
mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3],
n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims,
freq_factors, row_indices, set_rows_stride, is_neox, stream);
} else {
GGML_ABORT("fatal error");
}
}
+2
View File
@@ -7,3 +7,5 @@ void ggml_cuda_op_rope(ggml_backend_cuda_context & ctx, ggml_tensor * dst);
void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst);
void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * dst, ggml_tensor * set_rows);
void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows);
+1 -1
View File
@@ -3816,7 +3816,7 @@ int ggml_metal_op_norm(ggml_metal_op_t ctx, int idx) {
}
nth = std::min(nth, ggml_metal_pipeline_max_theads_per_threadgroup(pipeline));
nth = std::min(nth, args.ne00_t);
nth = std::min(nth, (args.ne00_t + 31)/32*32);
const size_t smem = pipeline.smem;
+14 -3
View File
@@ -1022,9 +1022,20 @@ static T block_reduce(T val, T * shared_vals, int block_size_template) {
}
static __dpct_inline__ float ggml_sycl_ue4m3_to_fp32(uint8_t x) {
const uint32_t bits = x * (x != 0x7F && x != 0xFF);
const __nv_fp8_e4m3 xf = *reinterpret_cast<const __nv_fp8_e4m3 *>(&bits);
return static_cast<float>(xf) / 2;
// UE4M3 is unsigned: 4 exp bits (bias 7), 3 mantissa bits, no sign, no NaN.
// exp == 0xF is a valid exponent (256-448 range), not NaN.
if (x == 0 || x == 0x7F) {
return 0.0f;
}
const int exp = (x >> 3) & 0xF;
const int man = x & 0x7;
float raw;
if (exp == 0) {
raw = man * (1.0f / 8.0f) * sycl::pow(2.0f, -6.0f);
} else {
raw = (1.0f + man / 8.0f) * sycl::pow(2.0f, (float) exp - 7.0f);
}
return raw * 0.5f;
}
#endif // GGML_SYCL_COMMON_HPP
+280
View File
@@ -0,0 +1,280 @@
#include "ggml-impl.h"
#include "dsv4-hc.hpp"
#include <cmath>
static constexpr int DSV4_HC = 4;
static void dsv4_hc_pre_f32_sycl(
const float * x, const float * weights, float * dst,
int64_t n_embd, int64_t hc, int64_t n_tokens,
int64_t sx0, int64_t sx1, int64_t sx2,
int64_t sw0, int64_t sw1,
int64_t sd0, int64_t sd1,
queue_ptr stream) {
const int64_t nr = n_embd * n_tokens;
const int64_t block_size = 256;
const int64_t num_blocks = (nr + block_size - 1) / block_size;
stream->parallel_for(
sycl::nd_range<1>(sycl::range<1>(num_blocks * block_size), sycl::range<1>(block_size)),
[=](sycl::nd_item<1> item) {
const int64_t ir = item.get_global_id(0);
if (ir >= nr) {
return;
}
const int64_t i0 = ir % n_embd;
const int64_t it = ir / n_embd;
float sum = x[i0*sx0 + it*sx2] * weights[it*sw1];
for (int64_t ih = 1; ih < hc; ++ih) {
const float xv = x[i0*sx0 + ih*sx1 + it*sx2];
const float wv = weights[ih*sw0 + it*sw1];
sum += xv * wv;
}
dst[i0*sd0 + it*sd1] = sum;
});
}
static void dsv4_hc_comb_norm_cols(float * comb, float eps) {
for (int idst = 0; idst < DSV4_HC; ++idst) {
float sum = eps;
for (int isrc = 0; isrc < DSV4_HC; ++isrc) {
sum += comb[idst + DSV4_HC*isrc];
}
const float inv_sum = 1.0f / sum;
for (int isrc = 0; isrc < DSV4_HC; ++isrc) {
comb[idst + DSV4_HC*isrc] *= inv_sum;
}
}
}
static void dsv4_hc_comb_norm_rows(float * comb, float eps) {
for (int isrc = 0; isrc < DSV4_HC; ++isrc) {
float sum = eps;
for (int idst = 0; idst < DSV4_HC; ++idst) {
sum += comb[idst + DSV4_HC*isrc];
}
const float inv_sum = 1.0f / sum;
for (int idst = 0; idst < DSV4_HC; ++idst) {
comb[idst + DSV4_HC*isrc] *= inv_sum;
}
}
}
static void dsv4_hc_comb_f32_sycl(
const float * mixes,
const float * scale,
const float * base,
float * dst,
int64_t n_tokens,
int64_t sm0,
int64_t sm1,
int64_t ss0,
int64_t sb0,
int64_t sd0,
int64_t sd1,
int64_t sd2,
float eps,
int32_t n_iter,
queue_ptr stream) {
constexpr int comb_offset = 2*DSV4_HC;
const int64_t block_size = 256;
const int64_t num_blocks = (n_tokens + block_size - 1) / block_size;
stream->parallel_for(
sycl::nd_range<1>(sycl::range<1>(num_blocks * block_size), sycl::range<1>(block_size)),
[=](sycl::nd_item<1> item_ct1) {
const int64_t it = item_ct1.get_global_id(0);
if (it >= n_tokens) {
return;
}
const float scale_comb = scale[2*ss0];
float comb[DSV4_HC*DSV4_HC];
for (int isrc = 0; isrc < DSV4_HC; ++isrc) {
float max = -INFINITY;
for (int idst = 0; idst < DSV4_HC; ++idst) {
const int idx = idst + DSV4_HC*isrc;
const float v = mixes[(comb_offset + idx)*sm0 + it*sm1] * scale_comb + base[(comb_offset + idx)*sb0];
comb[idx] = v;
max = fmaxf(max, v);
}
float sum = 0.0f;
for (int idst = 0; idst < DSV4_HC; ++idst) {
const int idx = idst + DSV4_HC*isrc;
const float v = expf(comb[idx] - max);
comb[idx] = v;
sum += v;
}
const float inv_sum = 1.0f / sum;
for (int idst = 0; idst < DSV4_HC; ++idst) {
const int idx = idst + DSV4_HC*isrc;
comb[idx] = comb[idx] * inv_sum + eps;
}
}
dsv4_hc_comb_norm_cols(comb, eps);
for (int32_t i = 1; i < n_iter; ++i) {
dsv4_hc_comb_norm_rows(comb, eps);
dsv4_hc_comb_norm_cols(comb, eps);
}
for (int isrc = 0; isrc < DSV4_HC; ++isrc) {
for (int idst = 0; idst < DSV4_HC; ++idst) {
const int idx = idst + DSV4_HC*isrc;
dst[idst*sd0 + isrc*sd1 + it*sd2] = comb[idx];
}
}
});
}
static void dsv4_hc_post_f32_sycl(
const float * x, const float * residual, const float * post, const float * comb, float * dst,
int64_t n_embd, int64_t hc, int64_t n_tokens,
int64_t sx0, int64_t sx1,
int64_t sr0, int64_t sr1, int64_t sr2,
int64_t sp0, int64_t sp1,
int64_t sc0, int64_t sc1, int64_t sc2,
int64_t sd0, int64_t sd1, int64_t sd2,
queue_ptr stream) {
const int64_t nr = n_embd * hc * n_tokens;
const int64_t block_size = 256;
const int64_t num_blocks = (nr + block_size - 1) / block_size;
stream->parallel_for(
sycl::nd_range<1>(sycl::range<1>(num_blocks * block_size), sycl::range<1>(block_size)),
[=](sycl::nd_item<1> item) {
const int64_t ir = item.get_global_id(0);
if (ir >= nr) {
return;
}
const int64_t i0 = ir % n_embd;
const int64_t idst = (ir / n_embd) % hc;
const int64_t it = ir / (n_embd * hc);
float sum = x[i0*sx0 + it*sx1] * post[idst*sp0 + it*sp1];
for (int64_t isrc = 0; isrc < hc; ++isrc) {
sum += residual[i0*sr0 + isrc*sr1 + it*sr2] * comb[idst*sc0 + isrc*sc1 + it*sc2];
}
dst[i0*sd0 + idst*sd1 + it*sd2] = sum;
});
}
void ggml_sycl_op_dsv4_hc_pre(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
scope_op_debug_print scope_dbg_print(__func__, dst, /*num_src=*/2);
const ggml_tensor * x = dst->src[0];
const ggml_tensor * weights = dst->src[1];
GGML_ASSERT(x->type == GGML_TYPE_F32);
GGML_ASSERT(weights->type == GGML_TYPE_F32);
GGML_ASSERT(dst->type == GGML_TYPE_F32);
GGML_TENSOR_LOCALS(size_t, nbx, x, nb);
GGML_TENSOR_LOCALS(size_t, nbw, weights, nb);
GGML_TENSOR_LOCALS(size_t, nbd, dst, nb);
const int64_t n_embd = x->ne[0];
const int64_t hc = x->ne[1];
const int64_t n_tokens = x->ne[2];
queue_ptr stream = ctx.stream();
dsv4_hc_pre_f32_sycl(
(const float *) x->data, (const float *) weights->data, (float *) dst->data,
n_embd, hc, n_tokens,
nbx0 / sizeof(float), nbx1 / sizeof(float), nbx2 / sizeof(float),
nbw0 / sizeof(float), nbw1 / sizeof(float),
nbd0 / sizeof(float), nbd1 / sizeof(float),
stream);
}
void ggml_sycl_op_dsv4_hc_comb(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
scope_op_debug_print scope_dbg_print(__func__, dst, /*num_src=*/3);
const ggml_tensor * mixes = dst->src[0];
const ggml_tensor * scale = dst->src[1];
const ggml_tensor * base = dst->src[2];
GGML_ASSERT(mixes->type == GGML_TYPE_F32);
GGML_ASSERT(scale->type == GGML_TYPE_F32);
GGML_ASSERT(base->type == GGML_TYPE_F32);
GGML_ASSERT(dst->type == GGML_TYPE_F32);
constexpr int64_t hc_mix_dim = (2 + DSV4_HC)*DSV4_HC;
GGML_ASSERT(mixes->ne[0] == hc_mix_dim);
GGML_ASSERT(dst->ne[0] == DSV4_HC);
GGML_ASSERT(dst->ne[1] == DSV4_HC);
GGML_ASSERT(dst->ne[2] == mixes->ne[1]);
GGML_ASSERT(scale->ne[0] >= 3);
GGML_ASSERT(base->ne[0] == hc_mix_dim);
GGML_TENSOR_LOCALS(size_t, nbm, mixes, nb);
GGML_TENSOR_LOCALS(size_t, nbs, scale, nb);
GGML_TENSOR_LOCALS(size_t, nbb, base, nb);
GGML_TENSOR_LOCALS(size_t, nbd, dst, nb);
const int64_t n_tokens = mixes->ne[1];
const float eps = ggml_get_op_params_f32(dst, 0);
const int32_t n_iter = ggml_get_op_params_i32(dst, 1);
queue_ptr stream = ctx.stream();
dsv4_hc_comb_f32_sycl(
(const float *) mixes->data, (const float *) scale->data, (const float *) base->data, (float *) dst->data,
n_tokens,
nbm0 / sizeof(float), nbm1 / sizeof(float),
nbs0 / sizeof(float),
nbb0 / sizeof(float),
nbd0 / sizeof(float), nbd1 / sizeof(float), nbd2 / sizeof(float),
eps, n_iter, stream);
}
void ggml_sycl_op_dsv4_hc_post(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
scope_op_debug_print scope_dbg_print(__func__, dst, /*num_src=*/4);
const ggml_tensor * x = dst->src[0];
const ggml_tensor * residual = dst->src[1];
const ggml_tensor * post = dst->src[2];
const ggml_tensor * comb = dst->src[3];
GGML_ASSERT(x->type == GGML_TYPE_F32);
GGML_ASSERT(residual->type == GGML_TYPE_F32);
GGML_ASSERT(post->type == GGML_TYPE_F32);
GGML_ASSERT(comb->type == GGML_TYPE_F32);
GGML_ASSERT(dst->type == GGML_TYPE_F32);
GGML_TENSOR_LOCALS(size_t, nbx, x, nb);
GGML_TENSOR_LOCALS(size_t, nbr, residual, nb);
GGML_TENSOR_LOCALS(size_t, nbp, post, nb);
GGML_TENSOR_LOCALS(size_t, nbc, comb, nb);
GGML_TENSOR_LOCALS(size_t, nbd, dst, nb);
const int64_t n_embd = x->ne[0];
const int64_t n_tokens = x->ne[1];
const int64_t hc = residual->ne[1];
queue_ptr stream = ctx.stream();
dsv4_hc_post_f32_sycl(
(const float *) x->data, (const float *) residual->data,
(const float *) post->data, (const float *) comb->data, (float *) dst->data,
n_embd, hc, n_tokens,
nbx0 / sizeof(float), nbx1 / sizeof(float),
nbr0 / sizeof(float), nbr1 / sizeof(float), nbr2 / sizeof(float),
nbp0 / sizeof(float), nbp1 / sizeof(float),
nbc0 / sizeof(float), nbc1 / sizeof(float), nbc2 / sizeof(float),
nbd0 / sizeof(float), nbd1 / sizeof(float), nbd2 / sizeof(float),
stream);
}
+10
View File
@@ -0,0 +1,10 @@
#ifndef GGML_SYCL_DSV4_HC_HPP
#define GGML_SYCL_DSV4_HC_HPP
#include "common.hpp"
void ggml_sycl_op_dsv4_hc_pre(ggml_backend_sycl_context & ctx, ggml_tensor * dst);
void ggml_sycl_op_dsv4_hc_comb(ggml_backend_sycl_context & ctx, ggml_tensor * dst);
void ggml_sycl_op_dsv4_hc_post(ggml_backend_sycl_context & ctx, ggml_tensor * dst);
#endif // GGML_SYCL_DSV4_HC_HPP
+65 -93
View File
@@ -420,53 +420,31 @@ static void clamp(const T * x, T * dst, const float min, const float max, const
}
}
template<typename T>
static void gated_op_fused_geglu(const T * x, const T * g, T * dst, const uint64_t k, const sycl::uint3 n_fd, const uint64_t o0, const uint64_t o1, const sycl::nd_item<1> &item_ct1) {
template<typename T, typename F>
static void unary_gated_op_flat_kernel(const T * x, const T * g, T * dst, const uint64_t k, const sycl::nd_item<1> & item_ct1, F func) {
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
dst[i] = func(x[i]) * g[i];
}
}
template<typename T, typename F>
static void unary_gated_op_generic_kernel(
const T * x,
const T * g,
T * dst,
const uint64_t k,
const sycl::uint3 n_fd,
const uint64_t o0,
const uint64_t o1,
const sycl::nd_item<1> & item_ct1,
F func) {
// rows of n columns at strides o0 and o1: two halves of one fused tensor, or two tensors
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
const sycl::uint2 rc = fast_div_modulo((uint32_t) i, n_fd);
const int64_t j0 = rc.x() * o0 + rc.y();
const int64_t j1 = o0 == o1 ? j0 : rc.x() * o1 + rc.y();
dst[i] = op_gelu(x[j0]) * g[j1];
}
}
template<typename T>
static void gated_op_fused_reglu(const T * x, const T * g, T * dst, const uint64_t k, const sycl::uint3 n_fd, const uint64_t o0, const uint64_t o1, const sycl::nd_item<1> &item_ct1) {
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
const sycl::uint2 rc = fast_div_modulo((uint32_t) i, n_fd);
const int64_t j0 = rc.x() * o0 + rc.y();
const int64_t j1 = o0 == o1 ? j0 : rc.x() * o1 + rc.y();
dst[i] = op_relu(x[j0]) * g[j1];
}
}
template<typename T>
static void gated_op_fused_swiglu(const T * x, const T * g, T * dst, const uint64_t k, const sycl::uint3 n_fd, const uint64_t o0, const uint64_t o1, const sycl::nd_item<1> &item_ct1) {
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
const sycl::uint2 rc = fast_div_modulo((uint32_t) i, n_fd);
const int64_t j0 = rc.x() * o0 + rc.y();
const int64_t j1 = o0 == o1 ? j0 : rc.x() * o1 + rc.y();
dst[i] = op_silu(x[j0]) * g[j1];
}
}
template<typename T>
static void gated_op_fused_geglu_erf(const T * x, const T * g, T * dst, const uint64_t k, const sycl::uint3 n_fd, const uint64_t o0, const uint64_t o1, const sycl::nd_item<1> &item_ct1) {
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
const sycl::uint2 rc = fast_div_modulo((uint32_t) i, n_fd);
const int64_t j0 = rc.x() * o0 + rc.y();
const int64_t j1 = o0 == o1 ? j0 : rc.x() * o1 + rc.y();
dst[i] = op_gelu_erf(x[j0]) * g[j1];
}
}
template<typename T>
static void gated_op_fused_geglu_quick(const T * x, const T * g, T * dst, const uint64_t k, const sycl::uint3 n_fd, const uint64_t o0, const uint64_t o1, const sycl::nd_item<1> &item_ct1) {
SYCL_GLOBAL_ID_LOOP(k, item_ct1) {
const sycl::uint2 rc = fast_div_modulo((uint32_t) i, n_fd);
const int64_t j0 = rc.x() * o0 + rc.y();
const int64_t j1 = o0 == o1 ? j0 : rc.x() * o1 + rc.y();
dst[i] = op_gelu_quick(x[j0]) * g[j1];
dst[i] = func(x[j0]) * g[j1];
}
}
@@ -670,6 +648,35 @@ static inline void ggml_sycl_op_unary(
});
}
template<typename F>
static inline void ggml_sycl_op_unary_gated(
ggml_backend_sycl_context & ctx, ggml_tensor * dst, F func) {
dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[func](const auto * x_ptr, const auto * g_ptr, auto * dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = (uint32_t) ceil_div(k, SYCL_GLU_BLOCK_SIZE);
const sycl::nd_range<1> launch_range(num_blocks * sycl::range<1>(SYCL_GLU_BLOCK_SIZE),
sycl::range<1>(SYCL_GLU_BLOCK_SIZE));
// o0 == n and o1 == n make the index math the identity, so index flat
// note: not ggml_is_contiguous - a fused [gate|up] src0 is contiguous with o0 == 2n
if (o0 == n && o1 == n) {
main_stream->parallel_for(launch_range,
[=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
unary_gated_op_flat_kernel(x_ptr, g_ptr, dst_ptr, k, item_ct1, func);
});
} else {
// launch-invariant divisor, and only this path needs it
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(launch_range,
[=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
unary_gated_op_generic_kernel(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1, func);
});
}
});
}
static inline void ggml_sycl_op_arange(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
GGML_ASSERT(dst->type == GGML_TYPE_F32);
@@ -967,42 +974,21 @@ static inline void ggml_sycl_op_acc(ggml_backend_sycl_context & ctx, ggml_tensor
}
static inline void ggml_sycl_op_geglu(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
ggml_sycl_detail::dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[](const auto* x_ptr, const auto* g_ptr, auto* dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = ceil_div(k, SYCL_GELU_BLOCK_SIZE);
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(
sycl::nd_range<1>((num_blocks * sycl::range<1>(SYCL_GELU_BLOCK_SIZE)),
sycl::range<1>(SYCL_GELU_BLOCK_SIZE)), [=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
gated_op_fused_geglu(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1);
});
});
ggml_sycl_detail::ggml_sycl_op_unary_gated(ctx, dst, [](auto x) {
return op_gelu(x);
});
}
static inline void ggml_sycl_op_reglu(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
ggml_sycl_detail::dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[](const auto* x_ptr, const auto* g_ptr, auto* dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = ceil_div((uint32_t)k, SYCL_RELU_BLOCK_SIZE); // Using RELU block size for reglu
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(
sycl::nd_range<1>((num_blocks * sycl::range<1>(SYCL_RELU_BLOCK_SIZE)),
sycl::range<1>(SYCL_RELU_BLOCK_SIZE)), [=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
gated_op_fused_reglu(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1);
});
});
ggml_sycl_detail::ggml_sycl_op_unary_gated(ctx, dst, [](auto x) {
return op_relu(x);
});
}
static inline void ggml_sycl_op_swiglu(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
ggml_sycl_detail::dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[](const auto* x_ptr, const auto* g_ptr, auto* dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = ceil_div((uint32_t)k, SYCL_SILU_BLOCK_SIZE); // Using SILU block size for swiglu
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(
sycl::nd_range<1>((num_blocks * sycl::range<1>(SYCL_SILU_BLOCK_SIZE)),
sycl::range<1>(SYCL_SILU_BLOCK_SIZE)), [=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
gated_op_fused_swiglu(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1);
});
});
ggml_sycl_detail::ggml_sycl_op_unary_gated(ctx, dst, [](auto x) {
return op_silu(x);
});
}
__dpct_inline__ float ggml_sycl_op_swiglu_oai_single(float x, float g, float alpha = 1.702f, float limit = 7.0f) {
@@ -1097,29 +1083,15 @@ void ggml_sycl_op_swiglu_oai(ggml_backend_sycl_context & ctx, ggml_tensor * dst)
}
static inline void ggml_sycl_op_geglu_erf(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
ggml_sycl_detail::dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[](const auto* x_ptr, const auto* g_ptr, auto* dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = ceil_div(k, SYCL_GELU_BLOCK_SIZE);
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(
sycl::nd_range<1>((num_blocks * sycl::range<1>(SYCL_GELU_BLOCK_SIZE)),
sycl::range<1>(SYCL_GELU_BLOCK_SIZE)), [=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
gated_op_fused_geglu_erf(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1);
});
});
ggml_sycl_detail::ggml_sycl_op_unary_gated(ctx, dst, [](auto x) {
return op_gelu_erf(x);
});
}
static inline void ggml_sycl_op_geglu_quick(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
ggml_sycl_detail::dispatch_ggml_sycl_op_fused_glu(ctx, dst,
[](const auto* x_ptr, const auto* g_ptr, auto* dst_ptr, uint64_t k, uint64_t n, uint64_t o0, uint64_t o1, queue_ptr main_stream) {
const uint32_t num_blocks = ceil_div(k, SYCL_GELU_BLOCK_SIZE);
const sycl::uint3 n_fd = init_fastdiv_values((uint32_t) n);
main_stream->parallel_for(
sycl::nd_range<1>((num_blocks * sycl::range<1>(SYCL_GELU_BLOCK_SIZE)),
sycl::range<1>(SYCL_GELU_BLOCK_SIZE)), [=](sycl::nd_item<1> item_ct1) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
gated_op_fused_geglu_quick(x_ptr, g_ptr, dst_ptr, k, n_fd, o0, o1, item_ct1);
});
});
ggml_sycl_detail::ggml_sycl_op_unary_gated(ctx, dst, [](auto x) {
return op_gelu_quick(x);
});
}
+18 -16
View File
@@ -73,6 +73,7 @@ static void flash_attn_ext_vec(const char* __restrict__ Q,
const int32_t nb31,
const int32_t nb32,
const int64_t nb33) {
#ifdef SYCL_FLASH_ATTN
// Skip unused kernel variants for faster compilation:
@@ -469,7 +470,6 @@ static void flash_attn_ext_vec(const char* __restrict__ Q,
}
}
item_ct1.barrier(sycl::access::fence_space::local_space);
#pragma unroll
@@ -591,22 +591,24 @@ void ggml_sycl_flash_attn_ext_vec_case_impl(ggml_backend_sycl_context & ctx, ggm
const auto arch = ggml_sycl_info().devices[ctx.device].hw_info.arch;
const int nthreads = ggml_sycl_fattn_vec_get_nthreads_device(arch);
// 256 threads would overflow the 64 KB work-group local memory at D == 512, so keep 128 there.
if (D <= 256 && nthreads == 256) {
constexpr int nthreads_hw = 256;
constexpr int nwarps = nthreads_hw / warp_size;
launch_fattn<D, cols_per_block, 1,
flash_attn_ext_vec<D, cols_per_block, type_K, type_V,
use_logit_softcap, warp_size, nthreads_hw>, warp_size>(
ctx, dst, nwarps, nbytes_shared, D, need_f16_K, need_f16_V, false);
} else {
constexpr int nthreads_hw = 128;
constexpr int nwarps = nthreads_hw / warp_size;
launch_fattn<D, cols_per_block, 1,
flash_attn_ext_vec<D, cols_per_block, type_K, type_V,
use_logit_softcap, warp_size, nthreads_hw>, warp_size>(
ctx, dst, nwarps, nbytes_shared, D, need_f16_K, need_f16_V, false);
if constexpr (D <= 256) {
if (nthreads == 256) {
constexpr int nthreads_hw = 256;
constexpr int nwarps = nthreads_hw / warp_size;
launch_fattn<D, cols_per_block, 1,
flash_attn_ext_vec<D, cols_per_block, type_K, type_V,
use_logit_softcap, warp_size, nthreads_hw>, warp_size>(
ctx, dst, nwarps, nbytes_shared, D, need_f16_K, need_f16_V, false);
return;
}
}
constexpr int nthreads_hw = 128;
constexpr int nwarps = nthreads_hw / warp_size;
launch_fattn<D, cols_per_block, 1,
flash_attn_ext_vec<D, cols_per_block, type_K, type_V,
use_logit_softcap, warp_size, nthreads_hw>, warp_size>(
ctx, dst, nwarps, nbytes_shared, D, need_f16_K, need_f16_V, false);
}
template <int D, int type_K, int type_V>
+38 -8
View File
@@ -62,6 +62,8 @@
#include "ggml-sycl/repeat_back.hpp"
#include "ggml-sycl/set_rows.hpp"
#include "ggml-sycl/set.hpp"
#include "ggml-sycl/dsv4-hc.hpp"
#include "ggml-sycl/lightning-indexer.hpp"
#include "ggml-sycl/conv2d.hpp"
#include "ggml-sycl/conv2d-dw.hpp"
#include "ggml-sycl/conv2d-transpose.hpp"
@@ -4942,6 +4944,18 @@ static bool ggml_sycl_compute_forward(ggml_backend_sycl_context & ctx, struct gg
case GGML_OP_SET_ROWS:
ggml_sycl_op_set_rows(ctx, dst);
break;
case GGML_OP_DSV4_HC_PRE:
ggml_sycl_op_dsv4_hc_pre(ctx, dst);
break;
case GGML_OP_DSV4_HC_COMB:
ggml_sycl_op_dsv4_hc_comb(ctx, dst);
break;
case GGML_OP_DSV4_HC_POST:
ggml_sycl_op_dsv4_hc_post(ctx, dst);
break;
case GGML_OP_LIGHTNING_INDEXER:
ggml_sycl_op_lightning_indexer(ctx, dst);
break;
case GGML_OP_DUP:
ggml_sycl_dup(ctx, dst);
break;
@@ -5795,17 +5809,33 @@ static bool do_ggml_backend_sycl_device_supports_op(ggml_backend_dev_t dev, cons
case GGML_OP_SET_ROWS:
{
auto res = ((op->type == GGML_TYPE_F32 || op->type == GGML_TYPE_F16 || op->type == GGML_TYPE_BF16 ||
op->type == GGML_TYPE_Q8_0 || op->type == GGML_TYPE_Q5_1 || op->type == GGML_TYPE_Q5_0 ||
op->type == GGML_TYPE_Q1_0 ||
op->type == GGML_TYPE_Q4_1 || op->type == GGML_TYPE_Q4_0 || op->type == GGML_TYPE_IQ4_NL ||
op->type == GGML_TYPE_MXFP4 || op->type == GGML_TYPE_NVFP4) &&
op->src[0]->type == GGML_TYPE_F32 &&
(op->src[1]->type == GGML_TYPE_I64 || op->src[1]->type == GGML_TYPE_I32));
auto res = (op->src[0]->type == GGML_TYPE_F32 || op->src[0]->type == GGML_TYPE_F16 ||
op->src[0]->type == GGML_TYPE_BF16) &&
(op->src[1]->type == GGML_TYPE_I64 || op->src[1]->type == GGML_TYPE_I32);
return res;
}
break;
case GGML_OP_DSV4_HC_PRE:
return op->src[0]->type == GGML_TYPE_F32 && op->src[1]->type == GGML_TYPE_F32 &&
op->type == GGML_TYPE_F32;
case GGML_OP_DSV4_HC_COMB:
return op->src[0]->type == GGML_TYPE_F32 && op->src[1]->type == GGML_TYPE_F32 &&
op->src[2]->type == GGML_TYPE_F32 && op->type == GGML_TYPE_F32;
case GGML_OP_DSV4_HC_POST:
return op->src[0]->type == GGML_TYPE_F32 && op->src[1]->type == GGML_TYPE_F32 &&
op->src[2]->type == GGML_TYPE_F32 && op->src[3]->type == GGML_TYPE_F32 &&
op->type == GGML_TYPE_F32;
case GGML_OP_LIGHTNING_INDEXER:
return op->src[0]->type == GGML_TYPE_F32 &&
(op->src[1]->type == GGML_TYPE_F16 || op->src[1]->type == GGML_TYPE_F32 ||
op->src[1]->type == GGML_TYPE_BF16 || op->src[1]->type == GGML_TYPE_Q8_0 ||
op->src[1]->type == GGML_TYPE_Q5_1 || op->src[1]->type == GGML_TYPE_Q5_0 ||
op->src[1]->type == GGML_TYPE_Q4_1 || op->src[1]->type == GGML_TYPE_Q4_0 ||
op->src[1]->type == GGML_TYPE_IQ4_NL) &&
op->src[2]->type == GGML_TYPE_F32 &&
op->src[3]->type == GGML_TYPE_F16 &&
op->type == GGML_TYPE_F32 &&
op->src[0]->ne[0] == WARP_SIZE * 8;
case GGML_OP_CPY:
{
ggml_type src0_type = op->src[0]->type;
+197
View File
@@ -0,0 +1,197 @@
#include "lightning-indexer.hpp"
#include "dequantize.hpp"
static void lightning_indexer_f32_sycl(
const char * q, const char * k, const char * w, const char * m, float * dst,
int64_t n_embd, int64_t n_head, int64_t n_batch, int64_t n_stream, int64_t n_kv,
int64_t nem3,
int64_t nbq1, int64_t nbq2, int64_t nbq3,
int64_t nbk2, int64_t nbk3,
int64_t nbw1, int64_t nbw3,
int64_t nbm1, int64_t nbm3,
int64_t nb1, int64_t nb3,
ggml_type k_type,
queue_ptr stream) {
constexpr int64_t LANES = WARP_SIZE;
constexpr int64_t ELEMS_PER_LANE = 8;
constexpr int64_t ROWS_PER_BLOCK = 4;
constexpr int64_t BLOCK_SIZE = ROWS_PER_BLOCK * LANES;
const int64_t n_rows = n_batch * n_stream * n_kv;
const int64_t n_blocks = (n_rows + ROWS_PER_BLOCK - 1) / ROWS_PER_BLOCK;
stream->parallel_for(
sycl::nd_range<1>(
sycl::range<1>(n_blocks * BLOCK_SIZE),
sycl::range<1>(BLOCK_SIZE)),
[=](sycl::nd_item<1> item) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
const int64_t ir = item.get_global_id(0);
const int64_t lane = ir % LANES;
const int64_t row = ir / LANES;
if (row >= n_rows) {
return;
}
const int64_t i_bs = row / n_kv;
const int64_t i_kv = row % n_kv;
const int64_t i_batch = i_bs / n_stream;
const int64_t i_stream = i_bs % n_stream;
// load K row slice into registers (row is contiguous, nbk0 == type size)
const char * k_base = k + i_kv*nbk2 + i_stream*nbk3;
float k_local[ELEMS_PER_LANE];
if (k_type == GGML_TYPE_F16) {
const sycl::half * k_row = (const sycl::half *) k_base;
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
k_local[j] = static_cast<float>(k_row[lane*ELEMS_PER_LANE + j]);
}
} else if (k_type == GGML_TYPE_F32) {
const float * k_row = (const float *) k_base;
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
k_local[j] = k_row[lane*ELEMS_PER_LANE + j];
}
} else {
const int64_t lane_base = lane * ELEMS_PER_LANE;
switch (k_type) {
case GGML_TYPE_BF16: {
const sycl::ext::oneapi::bfloat16 * k_row = (const sycl::ext::oneapi::bfloat16 *) k_base;
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
k_local[j] = static_cast<float>(k_row[lane_base + j]);
}
} break;
case GGML_TYPE_Q4_0:
case GGML_TYPE_Q4_1:
case GGML_TYPE_Q5_0:
case GGML_TYPE_Q5_1: {
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
const int64_t idx = lane_base + j;
const int64_t ib = idx / QK4_0;
const int iqs = idx % (QK4_0/2);
dfloat2 kv;
if (k_type == GGML_TYPE_Q4_0) {
dequantize_q4_0(k_base, ib, iqs, kv);
} else if (k_type == GGML_TYPE_Q4_1) {
dequantize_q4_1(k_base, ib, iqs, kv);
} else if (k_type == GGML_TYPE_Q5_0) {
dequantize_q5_0(k_base, ib, iqs, kv);
} else {
dequantize_q5_1(k_base, ib, iqs, kv);
}
k_local[j] = (idx % QK4_0) < (QK4_0/2) ? static_cast<float>(kv.x()) : static_cast<float>(kv.y());
}
} break;
case GGML_TYPE_Q8_0: {
#pragma unroll
for (int64_t pair = 0; pair < ELEMS_PER_LANE / 2; ++pair) {
const int64_t elem0 = lane_base + 2 * pair;
dfloat2 kv;
dequantize_q8_0(k_base, elem0 / QK8_0, elem0 % QK8_0, kv);
k_local[2 * pair + 0] = static_cast<float>(kv.x());
k_local[2 * pair + 1] = static_cast<float>(kv.y());
}
} break;
case GGML_TYPE_IQ4_NL: {
#pragma unroll
for (int64_t pair = 0; pair < ELEMS_PER_LANE / 2; ++pair) {
const int64_t elem0 = lane_base + 2 * pair;
dfloat2 kv;
dequantize_iq4_nl(k_base, elem0 / QK4_NL, elem0 % QK4_NL, kv);
k_local[2 * pair + 0] = static_cast<float>(kv.x());
k_local[2 * pair + 1] = static_cast<float>(kv.y());
}
} break;
default:
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
k_local[j] = 0.0f;
}
break;
}
}
const char * q_base = q + i_batch*nbq2 + i_stream*nbq3;
const float * w_base = (const float *) (w + i_batch*nbw1 + i_stream*nbw3);
float score = 0.0f;
for (int64_t h = 0; h < n_head; ++h) {
const float * q_row = (const float *) (q_base + h*nbq1);
float dot = 0.0f;
#pragma unroll
for (int64_t j = 0; j < ELEMS_PER_LANE; ++j) {
const int64_t i = lane*ELEMS_PER_LANE + j;
if (i < n_embd) {
dot += q_row[i] * k_local[j];
}
}
dot = sycl::reduce_over_group(item.get_sub_group(), dot, sycl::plus<float>());
if (lane == 0) {
score += sycl::max(dot, 0.0f) * w_base[h];
}
}
if (lane == 0) {
const sycl::half * m_base = (const sycl::half *) (m + i_batch*nbm1 + (i_stream % nem3)*nbm3);
// flat-index store: storing through a strided base pointer
// hangs/misroutes writes on this stack when n_batch*n_stream > 1
const int64_t dst_idx = i_kv + i_batch*(nb1/sizeof(float)) + i_stream*(nb3/sizeof(float));
dst[dst_idx] = score + static_cast<float>(m_base[i_kv]);
}
});
}
void ggml_sycl_op_lightning_indexer(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
scope_op_debug_print scope_dbg_print(__func__, dst, /*num_src=*/4);
const ggml_tensor * q = dst->src[0];
const ggml_tensor * k = dst->src[1];
const ggml_tensor * w = dst->src[2]; // weights
const ggml_tensor * m = dst->src[3]; // mask
GGML_ASSERT(dst->type == GGML_TYPE_F32);
GGML_ASSERT( q->type == GGML_TYPE_F32);
GGML_ASSERT( w->type == GGML_TYPE_F32);
GGML_ASSERT( m->type == GGML_TYPE_F16);
GGML_ASSERT(k->type == GGML_TYPE_F16 || k->type == GGML_TYPE_F32 || k->type == GGML_TYPE_BF16 ||
k->type == GGML_TYPE_Q8_0 || k->type == GGML_TYPE_Q5_1 || k->type == GGML_TYPE_Q5_0 ||
k->type == GGML_TYPE_Q4_1 || k->type == GGML_TYPE_Q4_0 || k->type == GGML_TYPE_IQ4_NL);
GGML_TENSOR_LOCALS(int64_t, neq, q, ne);
GGML_TENSOR_LOCALS(size_t, nbq, q, nb);
GGML_TENSOR_LOCALS(int64_t, nek, k, ne);
GGML_TENSOR_LOCALS(size_t, nbk, k, nb);
GGML_TENSOR_LOCALS(size_t, nbw, w, nb);
GGML_TENSOR_LOCALS(int64_t, nem, m, ne);
GGML_TENSOR_LOCALS(size_t, nbm, m, nb);
GGML_TENSOR_LOCALS(int64_t, ne, dst, ne);
GGML_TENSOR_LOCALS(size_t, nb, dst, nb);
// input rows must be contiguous
GGML_ASSERT(nbq0 == ggml_type_size(q->type));
GGML_ASSERT(nbk0 == ggml_type_size(k->type));
GGML_ASSERT(nbm0 == ggml_type_size(m->type));
GGML_ASSERT(nb0 == ggml_type_size(dst->type));
const int64_t n_embd = neq0;
const int64_t n_head = neq1;
const int64_t n_batch = neq2;
const int64_t n_stream = neq3;
const int64_t n_kv = nek2;
GGML_ASSERT(n_embd == WARP_SIZE * 8);
lightning_indexer_f32_sycl(
(const char *) q->data, (const char *) k->data,
(const char *) w->data, (const char *) m->data, (float *) dst->data,
n_embd, n_head, n_batch, n_stream, n_kv, nem3,
nbq1, nbq2, nbq3,
nbk2, nbk3,
nbw1, nbw3,
nbm1, nbm3,
nb1, nb3,
k->type,
ctx.stream());
}
+8
View File
@@ -0,0 +1,8 @@
#ifndef GGML_SYCL_LIGHTNING_INDEXER_HPP
#define GGML_SYCL_LIGHTNING_INDEXER_HPP
#include "common.hpp"
void ggml_sycl_op_lightning_indexer(ggml_backend_sycl_context & ctx, ggml_tensor * dst);
#endif // GGML_SYCL_LIGHTNING_INDEXER_HPP
-2
View File
@@ -20,8 +20,6 @@
#define MATRIX_ROW_PADDING 512 // last row of quant. matrices is a multiple of this to avoid out-of-bounds memory accesses
#define SYCL_COL2IM_1D_BLOCK_SIZE 256
#define SYCL_GELU_BLOCK_SIZE 256
#define SYCL_SILU_BLOCK_SIZE 256
#define SYCL_TANH_BLOCK_SIZE 256
#define SYCL_RELU_BLOCK_SIZE 256
#define SYCL_HARDSIGMOID_BLOCK_SIZE 256
+344 -16
View File
@@ -1,6 +1,10 @@
#include "set_rows.hpp"
#include "cpy.hpp"
#include "ggml-quants.h"
#include <vector>
namespace utils {
template<typename T>
static constexpr bool is_arithmetic_v() {
@@ -20,7 +24,17 @@ convert (const char* src, char* dst) {
*reinterpret_cast<TOut*>(dst) = dst_val;
}
template <typename TIdx, typename blockType, int qk, cpy_kernel_t cpyblck>
#ifdef GGML_SYCL_HAS_BF16
// sycl::vec::convert does not provide a half -> bfloat16 path, so route through float.
template<>
inline void convert<sycl::half, sycl::ext::oneapi::bfloat16>(const char* src, char* dst) {
const float tmp = sycl::vec<sycl::half, 1>(*reinterpret_cast<const sycl::half*>(src))
.template convert<float, sycl::rounding_mode::automatic>()[0];
*reinterpret_cast<sycl::ext::oneapi::bfloat16*>(dst) = sycl::ext::oneapi::bfloat16(tmp);
}
#endif
template <typename TIn, typename TIdx, typename blockType, int qk, cpy_kernel_t cpyblck>
static void set_rows_sycl_q(const char * __restrict__ src0_d,
const TIdx * __restrict__ src1_d,
blockType * __restrict__ dst_d,
@@ -68,13 +82,22 @@ static void set_rows_sycl_q(const char * __restrict__ src0_d,
const int64_t i11 = i02 % ne11;
const int64_t i10 = i01;
const size_t src_offset = calculate_offset<3>({ nb01, nb02, nb03 }, { i01, i02, i03 });
const char * src_block = src0_d + src_offset + i00 * sizeof(float);
const char * src_block = src0_d + src_offset + i00 * sizeof(TIn);
const size_t src1_offset = calculate_offset<3>({ nb10, nb11, nb12 }, { i10, i11, i12 });
const int64_t dst_row = src1_d[src1_offset / sizeof(TIdx)];
const size_t dst_offset =
calculate_offset<3>({ nb1, nb2, nb3 }, { dst_row, i02, i03 }) + (i00 / qk) * sizeof(blockType);
char * dst_block = reinterpret_cast<char *>(reinterpret_cast<char *>(dst_d) + dst_offset);
cpyblck(src_block, dst_block);
if constexpr (std::is_same_v<TIn, float>) {
cpyblck(src_block, dst_block);
} else {
float src_block_f32[qk];
const TIn * src_block_t = reinterpret_cast<const TIn *>(src_block);
for (int j = 0; j < qk; ++j) {
src_block_f32[j] = (float) src_block_t[j];
}
cpyblck(reinterpret_cast<const char *>(src_block_f32), dst_block);
}
});
GGML_UNUSED(ne10);
GGML_UNUSED(ne13);
@@ -82,6 +105,139 @@ static void set_rows_sycl_q(const char * __restrict__ src0_d,
GGML_UNUSED(nb13);
}
template<typename blockType>
using quantize_row_qk_t = void (*)(const float *, blockType *, int64_t);
using quantize_rows_f_t = size_t (*)(const float *, void *, int64_t, int64_t, const float *);
template <typename TIn, typename TIdx, typename blockType, int qk, quantize_row_qk_t<blockType> quantize_row>
static void set_rows_sycl_qk_host(
const ggml_tensor * src0,
const ggml_tensor * src1,
ggml_tensor * dst,
const int64_t ne00,
const int64_t ne01,
const int64_t ne02,
const int64_t ne03,
const int64_t ne11,
const int64_t ne12,
const size_t nb01,
const size_t nb02,
const size_t nb03,
const size_t nb10,
const size_t nb11,
const size_t nb12,
const size_t nb1,
const size_t nb2,
const size_t nb3,
queue_ptr stream) {
GGML_ASSERT(ne00 % qk == 0);
const size_t src0_bytes = ggml_nbytes(src0);
const size_t src1_bytes = ggml_nbytes(src1);
std::vector<char> src0_host(src0_bytes);
std::vector<char> src1_host(src1_bytes);
stream->memcpy(src0_host.data(), src0->data, src0_bytes);
stream->memcpy(src1_host.data(), src1->data, src1_bytes);
stream->wait();
std::vector<float> src_row_f32(ne00);
const int64_t nblocks = ne00 / qk;
std::vector<blockType> dst_row_q(nblocks);
for (int64_t i03 = 0; i03 < ne03; ++i03) {
for (int64_t i02 = 0; i02 < ne02; ++i02) {
for (int64_t i01 = 0; i01 < ne01; ++i01) {
const int64_t i12 = i03 % ne12;
const int64_t i11 = i02 % ne11;
const int64_t i10 = i01;
const size_t src1_offset = calculate_offset<3>({ nb10, nb11, nb12 }, { i10, i11, i12 });
const int64_t dst_row = *(const TIdx *) (src1_host.data() + src1_offset);
const size_t src0_row_offset = calculate_offset<3>({ nb01, nb02, nb03 }, { i01, i02, i03 });
const TIn * src_row = reinterpret_cast<const TIn *>(src0_host.data() + src0_row_offset);
for (int64_t i00 = 0; i00 < ne00; ++i00) {
src_row_f32[i00] = (float) src_row[i00];
}
quantize_row(src_row_f32.data(), dst_row_q.data(), ne00);
const size_t dst_offset = calculate_offset<3>({ nb1, nb2, nb3 }, { dst_row, i02, i03 });
stream->memcpy((char *) dst->data + dst_offset, dst_row_q.data(), nblocks * sizeof(blockType));
stream->wait();
}
}
}
}
template <typename TIn, typename TIdx, typename blockType, int qk, quantize_rows_f_t quantize_rows>
static void set_rows_sycl_iq_host(
const ggml_tensor * src0,
const ggml_tensor * src1,
ggml_tensor * dst,
const int64_t ne00,
const int64_t ne01,
const int64_t ne02,
const int64_t ne03,
const int64_t ne11,
const int64_t ne12,
const size_t nb01,
const size_t nb02,
const size_t nb03,
const size_t nb10,
const size_t nb11,
const size_t nb12,
const size_t nb1,
const size_t nb2,
const size_t nb3,
queue_ptr stream) {
GGML_ASSERT(ne00 % qk == 0);
const size_t src0_bytes = ggml_nbytes(src0);
const size_t src1_bytes = ggml_nbytes(src1);
std::vector<char> src0_host(src0_bytes);
std::vector<char> src1_host(src1_bytes);
stream->memcpy(src0_host.data(), src0->data, src0_bytes);
stream->memcpy(src1_host.data(), src1->data, src1_bytes);
stream->wait();
std::vector<float> src_row_f32(ne00);
const int64_t nblocks = ne00 / qk;
std::vector<blockType> dst_row_q(nblocks);
for (int64_t i03 = 0; i03 < ne03; ++i03) {
for (int64_t i02 = 0; i02 < ne02; ++i02) {
for (int64_t i01 = 0; i01 < ne01; ++i01) {
const int64_t i12 = i03 % ne12;
const int64_t i11 = i02 % ne11;
const int64_t i10 = i01;
const size_t src1_offset = calculate_offset<3>({ nb10, nb11, nb12 }, { i10, i11, i12 });
const int64_t dst_row = *(const TIdx *) (src1_host.data() + src1_offset);
const size_t src0_row_offset = calculate_offset<3>({ nb01, nb02, nb03 }, { i01, i02, i03 });
const TIn * src_row = reinterpret_cast<const TIn *>(src0_host.data() + src0_row_offset);
for (int64_t i00 = 0; i00 < ne00; ++i00) {
src_row_f32[i00] = (float) src_row[i00];
}
quantize_rows(src_row_f32.data(), dst_row_q.data(), 1, ne00, nullptr);
const size_t dst_offset = calculate_offset<3>({ nb1, nb2, nb3 }, { dst_row, i02, i03 });
stream->memcpy((char *) dst->data + dst_offset, dst_row_q.data(), nblocks * sizeof(blockType));
stream->wait();
}
}
}
}
template<typename TIn, typename TIdx, typename TOut>
static void k_set_rows(
const char * __restrict__ src0, const TIdx * __restrict__ src1, char * __restrict__ dst,
@@ -200,31 +356,194 @@ static void set_rows_sycl(ggml_backend_sycl_context & ctx, const ggml_tensor * s
break;
#endif
case GGML_TYPE_Q8_0:
set_rows_sycl_q<TIdx, block_q8_0, QK8_0, cpy_blck_f32_q8_0>(src0_d, src1_d, (block_q8_0 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q8_0, QK8_0, cpy_blck_f32_q8_0>(
src0_d, src1_d, (block_q8_0 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q1_0:
set_rows_sycl_q<TIdx, block_q1_0, QK1_0, cpy_blck_f32_q1_0>(src0_d, src1_d, (block_q1_0 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q1_0, QK1_0, cpy_blck_f32_q1_0>(
src0_d, src1_d, (block_q1_0 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q2_0:
set_rows_sycl_q<TIn, TIdx, block_q2_0, QK2_0, cpy_blck_f32_q2_0>(
src0_d, src1_d, (block_q2_0 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q5_1:
set_rows_sycl_q<TIdx, block_q5_1, QK5_1, cpy_blck_f32_q5_1>(src0_d, src1_d, (block_q5_1 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q5_1, QK5_1, cpy_blck_f32_q5_1>(
src0_d, src1_d, (block_q5_1 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q5_0:
set_rows_sycl_q<TIdx, block_q5_0, QK5_0, cpy_blck_f32_q5_0>(src0_d, src1_d, (block_q5_0 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q5_0, QK5_0, cpy_blck_f32_q5_0>(
src0_d, src1_d, (block_q5_0 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q4_1:
set_rows_sycl_q<TIdx, block_q4_1, QK4_1, cpy_blck_f32_q4_1>(src0_d, src1_d, (block_q4_1 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q4_1, QK4_1, cpy_blck_f32_q4_1>(
src0_d, src1_d, (block_q4_1 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q4_0:
set_rows_sycl_q<TIdx, block_q4_0, QK4_0, cpy_blck_f32_q4_0>(src0_d, src1_d, (block_q4_0 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_q4_0, QK4_0, cpy_blck_f32_q4_0>(
src0_d, src1_d, (block_q4_0 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_IQ4_NL:
set_rows_sycl_q<TIdx, block_iq4_nl, QK4_NL, cpy_blck_f32_iq4_nl>(src0_d, src1_d, (block_iq4_nl *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_iq4_nl, QK4_NL, cpy_blck_f32_iq4_nl>(
src0_d, src1_d, (block_iq4_nl *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_MXFP4:
set_rows_sycl_q<TIdx, block_mxfp4, QK_MXFP4, cpy_blck_f32_mxfp4>(src0_d, src1_d, (block_mxfp4 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_mxfp4, QK_MXFP4, cpy_blck_f32_mxfp4>(
src0_d, src1_d, (block_mxfp4 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_NVFP4:
set_rows_sycl_q<TIdx, block_nvfp4, QK_NVFP4, cpy_blck_f32_nvfp4>(src0_d, src1_d, (block_nvfp4 *)dst->data, ne00, ne01, ne02, ne03, ne10, ne11, ne12, ne13, nb00, nb01, nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
set_rows_sycl_q<TIn, TIdx, block_nvfp4, QK_NVFP4, cpy_blck_f32_nvfp4>(
src0_d, src1_d, (block_nvfp4 *) dst->data, ne00, ne01, ne02, ne03,
ne10, ne11, ne12, ne13, nb00, nb01,
nb02, nb03, nb10, nb11, nb12, nb13, nb1, nb2, nb3, stream);
break;
case GGML_TYPE_Q2_K:
set_rows_sycl_qk_host<TIn, TIdx, block_q2_K, QK_K, quantize_row_q2_K_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_Q3_K:
set_rows_sycl_qk_host<TIn, TIdx, block_q3_K, QK_K, quantize_row_q3_K_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_Q4_K:
set_rows_sycl_qk_host<TIn, TIdx, block_q4_K, QK_K, quantize_row_q4_K_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_Q5_K:
set_rows_sycl_qk_host<TIn, TIdx, block_q5_K, QK_K, quantize_row_q5_K_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_Q6_K:
set_rows_sycl_qk_host<TIn, TIdx, block_q6_K, QK_K, quantize_row_q6_K_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ2_XXS:
set_rows_sycl_iq_host<TIn, TIdx, block_iq2_xxs, QK_K, quantize_iq2_xxs>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ2_XS:
set_rows_sycl_iq_host<TIn, TIdx, block_iq2_xs, QK_K, quantize_iq2_xs>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ2_S:
set_rows_sycl_iq_host<TIn, TIdx, block_iq2_s, QK_K, quantize_iq2_s>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ3_XXS:
set_rows_sycl_qk_host<TIn, TIdx, block_iq3_xxs, QK_K, quantize_row_iq3_xxs_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ3_S:
set_rows_sycl_qk_host<TIn, TIdx, block_iq3_s, QK_K, quantize_row_iq3_s_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ1_S:
set_rows_sycl_iq_host<TIn, TIdx, block_iq1_s, QK_K, quantize_iq1_s>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ1_M:
set_rows_sycl_iq_host<TIn, TIdx, block_iq1_m, QK_K, quantize_iq1_m>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
case GGML_TYPE_IQ4_XS:
set_rows_sycl_qk_host<TIn, TIdx, block_iq4_xs, QK_K, quantize_row_iq4_xs_ref>(
src0, src1, dst,
ne00, ne01, ne02, ne03,
ne11, ne12,
nb01, nb02, nb03,
nb10, nb11, nb12,
nb1, nb2, nb3,
stream);
break;
default:
GGML_ABORT("Unsupported tensor type!");
@@ -237,12 +556,21 @@ void ggml_sycl_op_set_rows(ggml_backend_sycl_context & ctx, ggml_tensor * dst) {
const ggml_tensor * src0 = dst->src[0];
const ggml_tensor * src1 = dst->src[1];
GGML_ASSERT(dst->src[0]->type == GGML_TYPE_F32);
GGML_ASSERT(dst->src[0]->type == GGML_TYPE_F32 || dst->src[0]->type == GGML_TYPE_F16);
GGML_ASSERT(dst->src[1]->type == GGML_TYPE_I64 || dst->src[1]->type == GGML_TYPE_I32);
if (src1->type == GGML_TYPE_I64) {
set_rows_sycl<float, int64_t>(ctx, src0, src1, dst);
// dispatch on the index type (src1) and the source value type (src0)
if (src0->type == GGML_TYPE_F16) {
if (src1->type == GGML_TYPE_I64) {
set_rows_sycl<sycl::half, int64_t>(ctx, src0, src1, dst);
} else {
set_rows_sycl<sycl::half, int32_t>(ctx, src0, src1, dst);
}
} else {
set_rows_sycl<float, int32_t>(ctx, src0, src1, dst);
if (src1->type == GGML_TYPE_I64) {
set_rows_sycl<float, int64_t>(ctx, src0, src1, dst);
} else {
set_rows_sycl<float, int32_t>(ctx, src0, src1, dst);
}
}
}
+7 -3
View File
@@ -36,9 +36,13 @@ static void kernel_ssm_conv(
return;
}
const int channel = static_cast<int>(idx % d_inner);
const int token = static_cast<int>((idx / d_inner) % n_t);
const int seq = static_cast<int>(idx / (static_cast<size_t>(d_inner) * static_cast<size_t>(n_t)));
// src has the tokens of one channel contiguous, dst has the channels of one
// token contiguous, so either the loads or the store must be strided. Indexing
// token-fastest coalesces the d_conv loads, which measured faster except for
// short, cache-resident rows.
const int token = static_cast<int>(idx % n_t);
const int channel = static_cast<int>((idx / n_t) % d_inner);
const int seq = static_cast<int>(idx / (static_cast<size_t>(n_t) * static_cast<size_t>(d_inner)));
const float *s = src_data
+ static_cast<size_t>(seq) * static_cast<size_t>(src_stride_seq)
+1 -1
View File
@@ -1 +1 @@
90951f99af1fbebef3fbdd58ff5b8715b0bb9c43
30bf8685ed4eb0a47f2b06229543327749904150
+38 -14
View File
@@ -2584,6 +2584,7 @@ struct test_rms_norm_mul_rope : public test_case {
const float eps;
const bool multi_add; // test a sequence of adds feeding into rms_norm
const bool set_rows;
const bool broadcast; // multiply by a 1D [ne0] weight, as model norm weights are
int mode;
std::string op_desc(ggml_tensor * t) override {
@@ -2594,12 +2595,12 @@ struct test_rms_norm_mul_rope : public test_case {
bool run_whole_graph() override { return true; }
std::string vars() override {
return VARS_TO_STR5(ne, eps, multi_add, set_rows, mode);
return VARS_TO_STR6(ne, eps, multi_add, set_rows, broadcast, mode);
}
test_rms_norm_mul_rope(std::array<int64_t, 4> ne, float eps = 1e-6f, bool multi_add = false,
bool set_rows = false, int mode = GGML_ROPE_TYPE_NORMAL)
: ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), mode(mode) {}
bool set_rows = false, bool broadcast = false, int mode = GGML_ROPE_TYPE_NORMAL)
: ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), broadcast(broadcast), mode(mode) {}
ggml_tensor * build_graph(ggml_context * ctx) override {
ggml_tensor * a = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, ne[0], ne[1], ne[2], 1);
@@ -2610,7 +2611,9 @@ struct test_rms_norm_mul_rope : public test_case {
a = ggml_add(ctx, ggml_add(ctx, a, b), c);
}
a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), b);
ggml_tensor * w = broadcast ? ggml_new_tensor_1d(ctx, GGML_TYPE_F32, ne[0]) : b;
a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), w);
ggml_tensor * pos = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, ne[2]);
@@ -8576,6 +8579,9 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_cpy(type_src, type_dst, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3})); // cpy not-contiguous
}
}
// quant block count not a multiple of the kernel block size
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_Q4_0, {96, 1, 1, 1}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_Q4_0, GGML_TYPE_F32, {96, 1, 1, 1}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_I32, GGML_TYPE_F32, {256, 2, 3, 4}));
@@ -8722,6 +8728,13 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, true));
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, false, true));
}
// row lengths that are not a multiple of 32, for the scalar (33) and float4 (132, 260) paths
for (uint32_t n : { 33, 132, 260 }) {
for (bool v : { false, true }) {
test_cases.emplace_back(new test_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
test_cases.emplace_back(new test_rms_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
}
}
}
// in-place tests
@@ -8746,16 +8759,18 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
for (auto multi_add : {false, true}) {
for (auto set_rows : {false, true}) {
for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) {
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope));
for (auto broadcast : {false, true}) {
for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) {
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
}
}
}
}
@@ -9747,6 +9762,15 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
static std::vector<std::unique_ptr<test_case>> make_test_cases_perf() {
std::vector<std::unique_ptr<test_case>> test_cases;
// SWIGLU at a 27B-class FFN width, fused [gate|up] vs split operands
// note: same bytes either way, so a backend that indexes them differently shows it here
for (ggml_type type : {GGML_TYPE_F16, GGML_TYPE_F32}) {
for (int64_t n_tokens : {512, 2048}) {
test_cases.emplace_back(new test_glu(GGML_GLU_OP_SWIGLU, type, { 2*17408, n_tokens, 1, 1 }, 0, false));
test_cases.emplace_back(new test_glu_split(GGML_GLU_OP_SWIGLU, type, { 17408, n_tokens, 1, 1 }, 0));
}
}
// Conv2d: K=CRS=NPQ=4096 matmul performance
uint32_t iwh_idx = 0;
uint32_t kwh_idx = 1;
+1 -1
View File
@@ -195,7 +195,7 @@ static const std::vector<std::string> dspark_dflash = {
struct plan_case {
const char * name;
const std::vector<std::string> & files;
const std::vector<std::string> files;
const char * hf_repo;
const char * hf_file;
bool sidecars; // request mmproj + mtp + dflash + eagle3 + dspark
-1
View File
@@ -54,7 +54,6 @@
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
-1
View File
@@ -137,7 +137,6 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
+11
View File
@@ -170,6 +170,17 @@ struct clip_hparams {
warmup_image_size = static_cast<int>(std::sqrt(image_max_pixels));
}
// used by longest_edge preprocessor (no model-specific value for min/max tokens)
void set_limit_image_tokens() {
const int patch_area = patch_size * patch_size * n_merge * n_merge;
if (custom_image_min_tokens > 0) {
image_min_pixels = custom_image_min_tokens * patch_area;
}
if (custom_image_max_tokens > 0) {
image_max_pixels = custom_image_max_tokens * patch_area;
}
}
void set_warmup_n_tokens(int n_tokens) {
int n_tok_per_side = static_cast<int>(std::sqrt(n_tokens));
GGML_ASSERT(n_tok_per_side * n_tok_per_side == n_tokens && "n_tokens must be n*n");
+3
View File
@@ -1434,6 +1434,7 @@ struct clip_model_loader {
// use default llava-uhd preprocessing params
get_u32(KEY_PROJ_SCALE_FACTOR, hparams.n_merge, false);
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
hparams.set_limit_image_tokens();
} break;
case PROJECTOR_TYPE_LFM2:
{
@@ -1471,6 +1472,7 @@ struct clip_model_loader {
get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false);
hparams.image_longest_edge = hparams.image_size;
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
hparams.set_limit_image_tokens();
hparams.set_warmup_n_tokens(256); // avoid OOM on warmup
} break;
case PROJECTOR_TYPE_DOTS_OCR:
@@ -1595,6 +1597,7 @@ struct clip_model_loader {
if (hparams.image_longest_edge == 0) {
hparams.image_longest_edge = 3024;
}
// note: the step3vl preprocessor slices based on a fixed window grid, so it does not support custom min/max image tokens
hparams.warmup_image_size = hparams.image_size;
} break;
case PROJECTOR_TYPE_YOUTUVL:
+5 -11
View File
@@ -112,7 +112,6 @@ public:
c2w_state.clear();
audio_pcm.clear();
overlay.clear();
overlay_idx = 0;
h_state_buf.clear();
out_buf.clear();
prompt_embd_buf.clear();
@@ -205,11 +204,9 @@ public:
top_p = inp->top_p > 0 ? inp->top_p : 1.0f;
out_type = inp->out_type;
// the text stream keeps flowing during generation: after frame k, the input adds
// trailing text row k on top of the codes embedding, then tts_eos, then tts_pad
for (int i = 3; i < n_ids - 5; i++) overlay.push_back(row(ids[(size_t) i]));
overlay.push_back(row(tts_eos));
overlay.push_back(row(tts_pad));
// the prompt above holds the whole text stream up to tts_eos, so every generated
// frame adds tts_pad on top of the codes embedding
overlay = row(tts_pad);
return 0;
}
@@ -265,9 +262,7 @@ public:
}
std::vector<float> fb(out.embd, out.embd + n_embd);
const auto & ov = overlay[std::min(overlay_idx, overlay.size() - 1)];
for (int i = 0; i < n_embd; i++) fb[(size_t) i] += ov[(size_t) i];
overlay_idx++;
for (int i = 0; i < n_embd; i++) fb[(size_t) i] += overlay[(size_t) i];
const int n_pos_per_embd = mrope ? 4 : 1;
decode_embd_batch batch_embd(fb.data(), 1, n_pos_per_embd, n_embd);
@@ -437,8 +432,7 @@ private:
std::vector<int32_t> codes_buf;
std::vector<uint8_t> c2w_state;
std::vector<float> audio_pcm;
std::vector<std::vector<float>> overlay;
size_t overlay_idx = 0;
std::vector<float> overlay;
std::vector<float> h_state_buf;
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
std::vector<char> out_buf;
+51 -47
View File
@@ -139,50 +139,46 @@ struct img_tool {
}
}
// calculate the size of the **resized** image, while preserving the aspect ratio
// the calculated size will be aligned to the nearest multiple of align_size
// if H or W size is larger than longest_edge, it will be resized to longest_edge
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int longest_edge) {
GGML_ASSERT(align_size > 0);
if (inp_size.width <= 0 || inp_size.height <= 0 || longest_edge <= 0) {
struct calc_size_opt {
int align_size = 1;
int min_pixels = 0; // 0 = disabled
int max_pixels = 0; // 0 = disabled
// applied before min/max_pixels, so min_pixels can push an edge back above longest_edge
int longest_edge = 0; // 0 = disabled
};
// calculate the size of the **resized** image, while preserving the aspect ratio and
// aligning to the nearest multiple of align_size ("smart_resize" in transformers code)
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const calc_size_opt & opts) {
GGML_ASSERT(opts.align_size > 0);
const int width = inp_size.width;
const int height = inp_size.height;
if (width <= 0 || height <= 0) {
return {0, 0};
}
float scale = std::min(static_cast<float>(longest_edge) / inp_size.width,
static_cast<float>(longest_edge) / inp_size.height);
auto round_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
auto ceil_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
auto floor_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
float target_width_f = static_cast<float>(inp_size.width) * scale;
float target_height_f = static_cast<float>(inp_size.height) * scale;
int w_bar, h_bar;
if (opts.longest_edge > 0) {
const float scale = std::min(static_cast<float>(opts.longest_edge) / width,
static_cast<float>(opts.longest_edge) / height);
w_bar = ceil_by_factor(width * scale);
h_bar = ceil_by_factor(height * scale);
} else {
// always align up first
w_bar = std::max(opts.align_size, round_by_factor(width));
h_bar = std::max(opts.align_size, round_by_factor(height));
}
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
int aligned_width = ceil_by_factor(target_width_f);
int aligned_height = ceil_by_factor(target_height_f);
return {aligned_width, aligned_height};
}
// calculate the size of the **resized** image, while preserving the aspect ratio
// the calculated size will have min_pixels <= W*H <= max_pixels
// this is referred as "smart_resize" in transformers code
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int min_pixels, const int max_pixels) {
GGML_ASSERT(align_size > 0);
const int width = inp_size.width;
const int height = inp_size.height;
auto round_by_factor = [f = align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
auto floor_by_factor = [f = align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
// always align up first
int h_bar = std::max(align_size, round_by_factor(height));
int w_bar = std::max(align_size, round_by_factor(width));
if (h_bar * w_bar > max_pixels) {
const auto beta = std::sqrt(static_cast<float>(height * width) / max_pixels);
h_bar = std::max(align_size, floor_by_factor(height / beta));
w_bar = std::max(align_size, floor_by_factor(width / beta));
} else if (h_bar * w_bar < min_pixels) {
const auto beta = std::sqrt(static_cast<float>(min_pixels) / (height * width));
if (opts.max_pixels > 0 && h_bar * w_bar > opts.max_pixels) {
const auto beta = std::sqrt(static_cast<float>(height) * width / opts.max_pixels);
h_bar = std::max(opts.align_size, floor_by_factor(height / beta));
w_bar = std::max(opts.align_size, floor_by_factor(width / beta));
} else if (opts.min_pixels > 0 && h_bar * w_bar < opts.min_pixels) {
const auto beta = std::sqrt(static_cast<float>(opts.min_pixels) / (static_cast<float>(height) * width));
h_bar = ceil_by_factor(height * beta);
w_bar = ceil_by_factor(width * beta);
}
@@ -937,9 +933,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_i
const int cur_merge = hparams.n_merge;
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
original_size,
hparams.patch_size * cur_merge,
hparams.image_min_pixels,
hparams.image_max_pixels);
{
/* align_size */ hparams.patch_size * cur_merge,
/* min_pixels */ hparams.image_min_pixels,
/* max_pixels */ hparams.image_max_pixels,
/* longest_edge */ 0,
});
img_tool::resize(img, resized_image, target_size,
hparams.image_resize_algo,
hparams.image_resize_pad,
@@ -961,8 +960,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const cl
const int cur_merge = hparams.n_merge == 0 ? 1 : hparams.n_merge;
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
original_size,
hparams.patch_size * cur_merge,
hparams.image_longest_edge);
{
/* align_size */ hparams.patch_size * cur_merge,
/* min_pixels */ std::max(0, hparams.image_min_pixels),
/* max_pixels */ std::max(0, hparams.image_max_pixels),
/* longest_edge */ hparams.image_longest_edge,
});
img_tool::resize(img, resized_image, target_size,
hparams.image_resize_algo,
hparams.image_resize_pad,
@@ -1000,8 +1003,8 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
mtmd_image_preprocessor_llava_uhd::slice_instructions inst;
const int align_size = hparams.patch_size * hparams.n_merge;
inst.overview_size = img_tool::calc_size_preserved_ratio(
original_size, align_size,
hparams.image_min_pixels, hparams.image_max_pixels);
original_size,
{ align_size, hparams.image_min_pixels, hparams.image_max_pixels, 0 });
// tile if either dimension exceeds tile_size with tolerance
const bool needs_tiling = original_size.width > tile_size * max_pixels_tolerance || original_size.height > tile_size * max_pixels_tolerance;
@@ -1109,7 +1112,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
// CITE: https://github.com/huggingface/transformers/blob/main/src/transformers/models/idefics3/image_processing_idefics3.py#L737
const clip_image_size original_size = img.get_size();
const clip_image_size refined_size = img_tool::calc_size_preserved_ratio(
original_size, hparams.image_size, hparams.image_longest_edge);
original_size,
{ hparams.image_size, std::max(0, hparams.image_min_pixels), std::max(0, hparams.image_max_pixels), hparams.image_longest_edge });
// LOG_INF("%s: original size: %d x %d, refined size: %d x %d\n",
// __func__, original_size.width, original_size.height,
// refined_size.width, refined_size.height);
+1
View File
@@ -201,6 +201,7 @@ Invoke a tool call, request body is a JSON object with:
Headers:
- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Only `docker-container:<id>` is supported for now, using an already-running container
Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
+2 -1
View File
@@ -71,7 +71,6 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-ctk, --cache-type-k TYPE` | KV cache data type for K<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_K) |
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
@@ -198,6 +197,8 @@ For the full list of features, please refer to [server's changelog](https://gith
| `--ui-config, --webui-config JSON` | JSON that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG) |
| `--ui-config-file, --webui-config-file PATH` | JSON file that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG_FILE) |
| `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)<br/>(env: LLAMA_ARG_UI_MCP_PROXY) |
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
| `--tools-runtime OPTION` | experimental: run tools in a separate runtime environment (default: none, use host environment)<br/>available options:<br/> 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit<br/> 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit<br/><br/>(env: LLAMA_ARG_TOOLS_RUNTIME) |
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime, get_info<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
| `--mcp-servers-config PATH` | experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_CONFIG) |
| `--mcp-servers-json JSON` | experimental: inline JSON with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_JSON) |
+335 -39
View File
@@ -70,6 +70,188 @@ struct server_subproc {
}
};
struct server_lru_sched {
server_lru_sched(server_models & models) : models(models) {}
bool has_capacity(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
return models.base_params.models_max <= 0
|| count_running() < (size_t) models.base_params.models_max;
}
// returns "" if no model can be given up
std::string pick_victim(std::unique_lock<std::mutex> & lk, const std::string & exclude) {
check_lock(lk);
std::string victim;
int64_t victim_last_used = 0;
for (const auto & m : models.mapping) {
if (m.first == exclude) {
continue;
}
// a busy model is mid-request, one still coming up has no request to finish
if (m.second.req_count != 0 || !m.second.meta.is_ready_or_sleep()) {
continue;
}
if (victim.empty() || m.second.meta.last_used < victim_last_used) {
victim = m.first;
victim_last_used = m.second.meta.last_used;
}
}
return victim;
}
// requests wanting the same model share one entry, so they all need only one slot
// and all get unblocked by the single load that entry performs
void join(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
if (entry_t * e = find(model_id)) {
e->n_waiters++;
SRV_INF("request for name=%s joined the queue, %d waiting\n", model_id.c_str(), e->n_waiters);
return;
}
queue.push_back({ model_id, 1, false, false });
SRV_INF("models_max reached, request for name=%s queued at position %zu\n",
model_id.c_str(), queue.size());
}
void leave(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
for (auto it = queue.begin(); it != queue.end(); ++it) {
if (it->model_id == model_id) {
if (--it->n_waiters <= 0) {
queue.erase(it); // last one waiting for this model went away
}
return;
}
}
}
bool queue_empty(std::unique_lock<std::mutex> & lk) {
check_lock(lk);
return queue.empty();
}
// true if it is this model's turn to load, and nobody is loading it yet
bool try_claim(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
if (queue.empty() || queue.front().model_id != model_id || queue.front().loading) {
return false;
}
if (!has_capacity(lk)) {
return false;
}
queue.front().loading = true;
return true;
}
// ok means the model is up: drop the entry, the other waiters just watch its status now
void claim_done(std::unique_lock<std::mutex> & lk, const std::string & model_id, bool ok) {
check_lock(lk);
for (auto it = queue.begin(); it != queue.end(); ++it) {
if (it->model_id == model_id) {
if (ok) {
queue.erase(it);
} else {
it->loading = false;
}
return;
}
}
}
// a model is on its way out for this entry, so other requests do not also give up one
void mark_slot_pending(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
check_lock(lk);
if (entry_t * e = find(model_id)) {
e->slot_pending = true;
}
}
// model_id went idle: give up its slot if a queued request needs one
// thread-safe, caller must NOT hold models.mutex
void on_model_idle(const std::string & model_id) {
if (models.base_params.models_max <= 0) {
return; // no limit, nothing is ever queued
}
{
std::unique_lock<std::mutex> lk(models.mutex);
if (queue.empty()) {
return;
}
size_t promised = 0;
bool has_unserved = false;
for (const auto & e : queue) {
if (e.needs_slot()) {
has_unserved = true;
} else {
promised++;
}
}
if (!has_unserved) {
return;
}
if ((int) count_running() - (int) promised < models.base_params.models_max) {
return; // a slot is already on its way
}
// never give up a model that a queued request wants
for (const auto & e : queue) {
if (e.model_id == model_id) {
return;
}
}
auto it = models.mapping.find(model_id);
if (it == models.mapping.end() || it->second.req_count != 0 || !it->second.meta.is_ready_or_sleep()) {
return;
}
for (auto & e : queue) {
if (!e.slot_pending) {
e.slot_pending = true;
break;
}
}
}
SRV_INF("model name=%s went idle, giving up its slot to a queued request\n", model_id.c_str());
models.unload(model_id);
}
private:
struct entry_t {
std::string model_id;
int n_waiters; // requests waiting for this model
bool slot_pending; // a model is already being evicted for this entry
bool loading; // one of the waiters is doing the load right now
// a slot is already coming, or already taken by the load in flight
bool needs_slot() const { return !slot_pending && !loading; }
};
entry_t * find(const std::string & model_id) {
for (auto & e : queue) {
if (e.model_id == model_id) {
return &e;
}
}
return nullptr;
}
void check_lock(std::unique_lock<std::mutex> & lk) {
GGML_ASSERT(lk.owns_lock() && lk.mutex() == &models.mutex);
}
size_t count_running() {
size_t count = 0;
for (const auto & m : models.mapping) {
if (m.second.meta.is_running()) {
count++;
}
}
return count;
}
server_models & models;
std::deque<entry_t> queue;
};
// short loopback budget for the resumable stream router to child JSON calls (probe, lookup,
// delete). distinct from params.timeout_read/write which only applies to the generation proxy
static constexpr int STREAM_LOOKUP_TIMEOUT_MS = 250;
@@ -229,7 +411,8 @@ server_models::server_models(
: ctx_preset(LLAMA_EXAMPLE_SERVER),
base_params(params),
base_env(get_environment()),
base_preset(ctx_preset.load_from_args(argc, argv)) {
base_preset(ctx_preset.load_from_args(argc, argv)),
sched(std::make_unique<server_lru_sched>(*this)) {
// clean up base preset
unset_reserved_args(base_preset, true);
// set binary path
@@ -241,8 +424,11 @@ server_models::server_models(
LOG_WRN("using original argv[0] as fallback: %s\n", argv[0]);
}
load_models();
debug_fake_timing = !common_get_env("LLAMA_SERVER_DEBUG_FAKE_TIMING").empty();
}
server_models::~server_models() = default;
void server_models::add_model(server_model_meta && meta) {
if (mapping.find(meta.name) != mapping.end()) {
throw std::runtime_error(string_format("model '%s' appears multiple times", meta.name.c_str()));
@@ -713,22 +899,15 @@ void server_models::unload_lru() {
return; // no limit
}
// remove one of the servers if we passed the models_max (least recently used - LRU)
std::string lru_model_name = "";
int64_t lru_last_used = ggml_time_ms();
size_t count_active = 0;
std::string lru_model_name;
{
std::unique_lock<std::mutex> lk(mutex);
for (const auto & m : mapping) {
if (m.second.meta.is_running()) {
count_active++;
if (m.second.meta.last_used < lru_last_used) {
lru_model_name = m.first;
lru_last_used = m.second.meta.last_used;
}
}
if (sched->has_capacity(lk)) {
return;
}
lru_model_name = sched->pick_victim(lk, "");
}
if (!lru_model_name.empty() && count_active >= (size_t)base_params.models_max) {
if (!lru_model_name.empty()) {
SRV_INF("models_max limit reached, removing LRU name=%s\n", lru_model_name.c_str());
unload(lru_model_name);
// wait for unload to complete
@@ -746,6 +925,11 @@ void server_models::load(const std::string & name) {
}
void server_models::load(const std::string & name, const load_options & opts) {
if (debug_fake_timing) {
// do not hold the mutex here, other requests must keep making progress
std::this_thread::sleep_for(std::chrono::seconds(2));
}
if (!opts.custom_meta.has_value()) {
if (!has_model(name)) {
throw std::runtime_error("model name=" + name + " is not found");
@@ -1138,7 +1322,7 @@ void server_models::wait(std::unique_lock<std::mutex> & lk, const std::string &
});
}
bool server_models::ensure_model_ready(const std::string & name) {
bool server_models::ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop) {
auto meta = get_meta(name);
if (!meta.has_value()) {
throw std::runtime_error("model name=" + name + " is not found");
@@ -1149,25 +1333,112 @@ bool server_models::ensure_model_ready(const std::string & name) {
if (meta->status == SERVER_MODEL_STATUS_SLEEPING) {
return false; // child is sleeping but still running; new request will wake it up
}
if (meta->status == SERVER_MODEL_STATUS_UNLOADED) {
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
load(name);
}
// wait for loading to complete
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
wait(name, [&meta](const server_model_meta & new_meta) {
if (new_meta.status != SERVER_MODEL_STATUS_LOADING) {
meta = new_meta; // update meta for final check after wait
return true;
bool queued = false;
bool did_load = false;
std::string victim;
{
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
if (it != mapping.end() && it->second.meta.status == SERVER_MODEL_STATUS_UNLOADED) {
bool has_capacity = sched->has_capacity(lk);
if (has_capacity && sched->queue_empty(lk)) {
lk.unlock();
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
load(name);
did_load = true;
} else {
// also queue when a slot looks free but others wait already, else they starve
sched->join(lk, name);
queued = true;
if (!has_capacity) {
// an idle model may sit here right now, do not wait for a request to end
victim = sched->pick_victim(lk, name);
if (!victim.empty()) {
sched->mark_slot_pending(lk, name);
}
}
}
}
return false;
});
// check final status
if (!meta.has_value() || meta->is_failed()) {
throw std::runtime_error("model name=" + name + " failed to load");
}
if (!victim.empty()) {
SRV_INF("evicting idle LRU name=%s to make room for name=%s\n", victim.c_str(), name.c_str());
unload(victim);
}
// while queued, this is also where the load happens: the head of the queue does it
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
std::unique_lock<std::mutex> lk(mutex);
auto leave_queue = [this, &queued, &lk, &name]() {
if (queued) {
sched->leave(lk, name);
queued = false;
}
};
try {
bool saw_loading = false;
while (true) {
auto it = mapping.find(name);
if (it == mapping.end()) {
break; // removed by another code path, nothing to wait for
}
const server_model_status status = it->second.meta.status;
if (status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_SLEEPING) {
break;
}
if (status == SERVER_MODEL_STATUS_DOWNLOADING || status == SERVER_MODEL_STATUS_DOWNLOADED) {
break; // do not wait on a download child
}
if (status == SERVER_MODEL_STATUS_LOADING) {
saw_loading = true;
} else if (status == SERVER_MODEL_STATUS_UNLOADED) {
if (did_load || saw_loading) {
// a spawn happened and the instance came back down
if (it->second.meta.is_failed()) {
throw std::runtime_error("model name=" + name + " failed to load");
}
break; // unloaded by another code path, caller reports "not running"
}
if (!queued) {
break; // not queued, and the load someone else started fell over
}
}
if (should_stop && should_stop()) {
// if a model was evicted for us, the free slot goes to the next waiter
throw std::runtime_error("request cancelled while waiting for model name=" + name);
}
// our turn: our model is at the head, and a slot really did free up
if (status == SERVER_MODEL_STATUS_UNLOADED && sched->try_claim(lk, name)) {
lk.unlock();
bool ok = true;
try {
SRV_INF("slot available, loading queued model name=%s\n", name.c_str());
load(name);
did_load = true;
} catch (const std::exception & e) {
// lost a race for the slot, stay in line and retry
SRV_WRN("queued load of name=%s did not go through: %s\n", name.c_str(), e.what());
ok = false;
}
lk.lock();
sched->claim_done(lk, name, ok);
if (ok) {
queued = false; // entry is gone, the other waiters watch the status now
}
continue;
}
cv.wait_for(lk, std::chrono::milliseconds(200));
}
} catch (...) {
leave_queue();
throw;
}
leave_queue();
return true;
}
@@ -1180,9 +1451,16 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
if (!meta->is_running()) {
throw std::invalid_argument("model name=" + name + " is not running");
}
if (update_last_used) {
{
std::unique_lock<std::mutex> lk(mutex);
mapping[name].meta.last_used = ggml_time_ms();
if (update_last_used) {
mapping[name].meta.last_used = ggml_time_ms();
}
mapping[name].req_count++;
}
if (debug_fake_timing) {
// sleep after req_count++, so the model counts as busy while we wait here
std::this_thread::sleep_for(std::chrono::seconds(2));
}
SRV_INF("proxying request to model %s on port %d\n", name.c_str(), meta->port);
std::string proxy_path = req.path;
@@ -1198,13 +1476,29 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
req.headers,
req.body,
req.files,
// a detached request belongs to a replay session that outlives the client socket:
// it reaches the child even when the downstream died during the load wait, the
// session buffer is the recipient and DELETE remains the stop
detached ? std::function<bool()>([]() { return false; }) : req.should_stop,
// a detached request belongs to a replay session
detached
? std::function<bool()>([]() { return false; })
: req.should_stop,
base_params.timeout_read,
base_params.timeout_write
);
proxy->cleanup = [this, name]() {
bool went_idle = false;
{
std::unique_lock<std::mutex> lk(mutex);
auto it = mapping.find(name);
if (it != mapping.end() && it->second.req_count > 0) {
it->second.req_count--;
went_idle = it->second.req_count == 0;
}
}
if (went_idle) {
sched->on_model_idle(name);
}
};
return proxy;
}
@@ -1568,7 +1862,7 @@ void server_models_routes::init_routes() {
return error_res;
}
if (autoload) {
models.ensure_model_ready(name);
models.ensure_model_ready(name, req.should_stop);
}
return models.proxy_request(req, method, name, false);
};
@@ -1588,7 +1882,9 @@ void server_models_routes::init_routes() {
// this request instead of leaving an orphan generation
std::string conv_id = server_stream_conv_id_from_headers(req.headers);
uint64_t ticket = models.conv_models.remember(conv_id, name);
bool waited = autoload && models.ensure_model_ready(name);
// a dead socket must not cancel a session request, only a stop does (checked right below)
auto should_stop = ticket == 0 ? req.should_stop : nullptr;
bool waited = autoload && models.ensure_model_ready(name, should_stop);
if (ticket != 0 && !models.conv_models.alive(conv_id, ticket)) {
SRV_INF("request for conv_id=%s cancelled while model name=%s was loading\n",
conv_id.c_str(), name.c_str());
@@ -2064,7 +2360,7 @@ server_http_proxy::server_http_proxy(
cli->set_write_timeout(timeout_read, 0); // reversed for cli (client) vs srv (server)
cli->set_read_timeout(timeout_write, 0);
this->status = 500; // to be overwritten upon response
this->cleanup = [pipe]() {
this->cleanup_pipes = [pipe]() {
pipe->close_read();
pipe->close_write();
};
+22 -4
View File
@@ -84,7 +84,6 @@ struct server_model_meta {
int exit_code = 0; // exit code of the model instance process (only valid if status == FAILED)
int stop_timeout = 0; // seconds to wait before force-killing the model instance during shutdown
mtmd_caps multimodal; // multimodal capabilities
// bool need_download = false; // whether the model needs to be downloaded before loading // TODO @ngxson: implement this
bool is_ready() const {
return status == SERVER_MODEL_STATUS_LOADED;
@@ -94,6 +93,10 @@ struct server_model_meta {
return status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_LOADING || status == SERVER_MODEL_STATUS_SLEEPING;
}
bool is_ready_or_sleep() const {
return status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_SLEEPING;
}
bool is_failed() const {
return status == SERVER_MODEL_STATUS_UNLOADED && exit_code != 0;
}
@@ -103,16 +106,19 @@ struct server_model_meta {
};
struct server_models_routes;
struct server_subproc; // defined in server-models.cpp
struct server_subproc; // defined in server-models.cpp
struct server_lru_sched; // defined in server-models.cpp
struct server_models {
friend struct server_models_routes;
friend struct server_lru_sched;
private:
struct instance_t {
std::shared_ptr<server_subproc> subproc; // shared between main thread and monitoring thread
std::thread th;
server_model_meta meta;
int req_count = 0; // number of active proxy requests
};
std::mutex mutex;
@@ -191,6 +197,12 @@ private:
std::vector<std::string> base_env;
common_preset base_preset; // base preset from llama-server CLI args
// queue of requests waiting for a models_max slot
std::unique_ptr<server_lru_sched> sched;
// if true, add some delay to simulate works (useful for testing)
bool debug_fake_timing = false;
void update_meta(const std::string & name, const server_model_meta & meta);
// unload least recently used models if the limit is reached
@@ -207,6 +219,7 @@ public:
conv_model_tracker conv_models;
server_models(const common_params & params, int argc, char ** argv);
~server_models();
server_response sse; // for real-time updates via SSE endpoint
@@ -263,7 +276,9 @@ public:
// ensure the model is in ready state (thread-safe)
// return false if model is ready
// otherwise, load the model and blocking wait until it's ready, then return true (meta may need to be refreshed)
bool ensure_model_ready(const std::string & name);
// if models_max is reached, the request waits in a queue until a slot frees up
// throws if the load fails, or if should_stop fires while waiting
bool ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop = nullptr);
// proxy an HTTP request to the model instance
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used, bool detached = false);
@@ -343,7 +358,6 @@ struct server_models_routes {
*/
struct server_http_proxy : server_http_res {
std::function<void()> cleanup = nullptr;
public:
server_http_proxy(const std::string & method,
const std::string & scheme,
const std::string & host,
@@ -357,11 +371,15 @@ public:
int32_t timeout_write
);
~server_http_proxy() {
if (cleanup_pipes) {
cleanup_pipes();
}
if (cleanup) {
cleanup();
}
}
private:
std::function<void()> cleanup_pipes = nullptr;
std::thread thread;
struct msg_t {
std::map<std::string, std::string> headers;
+1 -1
View File
@@ -519,7 +519,7 @@ task_params eval_llama_cmpl_schema(
const json & data) {
task_params params;
// Sampling parameter defaults are loaded from the global server context (but individual requests can still them)
// Sampling parameter defaults are loaded from the global server context (but individual requests can still override them)
params.sampling = params_base.sampling;
params.speculative = params_base.speculative;
params.n_keep = params_base.n_keep;
+499 -76
View File
@@ -10,12 +10,15 @@
#include <ctime>
#include <atomic>
#include <cstring>
#include <cstdint>
#include <cstdlib>
#include <algorithm>
#include <iterator>
#include <unordered_set>
#include <tuple>
#include <functional>
#include <memory>
#include <mutex>
#if defined(_WIN32)
# ifndef NOMINMAX
@@ -71,6 +74,7 @@ json server_tool::to_json() const {
{"permissions", json{
{"write", permission_write}
}},
{"uses_cwd", uses_cwd},
{"definition", get_definition()},
};
}
@@ -127,6 +131,13 @@ static int entry_depth(const std::string & rel) {
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
}
// directories that a listing reports but never descends into: they can be enormous
// lowercase only, the local walker case-folds a name before the lookup
static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
};
class tools_io {
public:
struct exec_result {
@@ -165,6 +176,85 @@ public:
const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0;
};
// shared subprocess execution helper, used by both the local and the docker-backed tools_io implementations.
// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents.
static tools_io::exec_result run_subprocess(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk,
bool combine_stderr,
const std::string & cwd = "") {
tools_io::exec_result res;
common_subproc proc;
int options = subprocess_option_no_window
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (combine_stderr) {
options |= subprocess_option_combined_stdout_stderr;
}
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
std::atomic<bool> done{false};
std::atomic<bool> timed_out{false};
std::thread timeout_thread([&]() {
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
proc.terminate();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = proc.stdout_file();
std::string output;
bool truncated = false;
if (f) {
char buf[4096];
while (fgets(buf, sizeof(buf), f) != nullptr) {
if (!truncated) {
size_t len = strlen(buf);
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
proc.terminate();
break;
}
} else {
size_t remaining = max_output - output.size();
output.append(buf, remaining);
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
truncated = true;
}
}
}
}
done.store(true);
if (timeout_thread.joinable()) {
timeout_thread.join();
}
res.exit_code = proc.join();
res.output = console_output_to_utf8(output);
res.timed_out = timed_out.load();
if (truncated) {
res.output += "\n[output truncated]";
}
return res;
}
class tools_io_basic : public tools_io {
public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
@@ -276,72 +366,7 @@ public:
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
exec_result res;
common_subproc proc;
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
std::atomic<bool> done{false};
std::atomic<bool> timed_out{false};
std::thread timeout_thread([&]() {
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
proc.terminate();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = proc.stdout_file();
std::string output;
bool truncated = false;
if (f) {
char buf[4096];
while (fgets(buf, sizeof(buf), f) != nullptr) {
if (!truncated) {
size_t len = strlen(buf);
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
proc.terminate();
break;
}
} else {
size_t remaining = max_output - output.size();
output.append(buf, remaining);
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
truncated = true;
}
}
}
}
done.store(true);
if (timeout_thread.joinable()) {
timeout_thread.join();
}
res.exit_code = proc.join();
res.output = console_output_to_utf8(output);
res.timed_out = timed_out.load();
if (truncated) {
res.output += "\n[output truncated]";
}
return res;
return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd);
}
private:
@@ -384,10 +409,8 @@ private:
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
};
static const std::unordered_set<std::string> names(
std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES));
return names;
}
@@ -450,9 +473,274 @@ private:
}
};
// timeout for auxiliary isolate calls (stat/mkdir/ls/cp helpers); exec_shell_command uses its own
// caller-controlled timeout instead, enforced separately in run()
static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds
static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB
// runs every tools_io operation as a command inside an isolate: a container, a remote host, ...
// the isolate is created, mounted, and torn down externally by the caller
// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout
class tools_io_isolate : public tools_io {
public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {}
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged.
// isolate paths are always POSIX-style ('/'), regardless of host OS.
std::string resolve(const std::string & path) const override {
if (cwd.empty() || (!path.empty() && path[0] == '/')) {
return path;
}
return cwd + "/" + path;
}
bool is_directory(const std::string & path) const override {
return shell_test("-d", resolve(path));
}
bool is_regular_file(const std::string & path) const override {
return shell_test("-f", resolve(path));
}
bool file_size(const std::string & path, uintmax_t & out_size) const override {
auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true);
if (res.exit_code != 0 || res.timed_out) return false;
try {
size_t pos;
out_size = (uintmax_t) std::stoull(res.output, &pos);
} catch (...) {
return false;
}
return true;
}
bool read_file(const std::string & path, std::string & out) const override {
// combine_stderr=false: stderr must not be spliced into raw file bytes
auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false);
if (res.exit_code != 0 || res.timed_out) return false;
out = res.output;
return true;
}
bool write_file(const std::string & path, const std::string & content) const override {
std::string abs_path = resolve(path);
std::error_code ec;
fs::path tmp_dir = fs::temp_directory_path(ec);
if (ec) return false;
static std::atomic<uint64_t> tmp_counter{0};
fs::path tmp = tmp_dir / string_format(
"llama-tools-io-isolate-%zu-%llu.tmp",
std::hash<std::thread::id>{}(std::this_thread::get_id()),
(unsigned long long) tmp_counter.fetch_add(1));
{
std::ofstream f(tmp, std::ios::binary);
if (!f) return false;
f << content;
if (!f) return false;
}
bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path});
if (ok) {
ok = upload(tmp.string(), abs_path);
}
std::error_code rm_ec;
fs::remove(tmp, rm_ec);
return ok;
}
list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override {
list_result out;
const std::string abs_base = resolve(base);
if (!is_directory(base)) {
out.err = "path does not exist or is not a directory";
return out;
}
// git ls-files cannot list directories; use the walker when they are requested
if (kind == list_kind::files) {
auto res = exec(
{"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
if (res.exit_code == 0 && !res.timed_out) {
for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) {
if (max_depth > 0 && entry_depth(rel) > max_depth) continue;
out.entries.push_back({rel, false});
}
return out;
}
}
if (kind == list_kind::dirs || kind == list_kind::all) {
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) {
out.entries.push_back({std::move(rel), true});
}
}
if (kind == list_kind::files || kind == list_kind::all) {
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) {
out.entries.push_back({std::move(rel), false});
}
}
return out;
}
// wraps the command with an in-isolate `timeout`, since killing the host-side client
// does not kill the process tree running inside the isolate
exec_result run(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
std::vector<std::string> inner = {"timeout", std::to_string(timeout_secs) + "s"};
inner.insert(inner.end(), args.begin(), args.end());
// small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly
// before the host-side supervisory timeout forcibly kills the client
return run_subprocess(
build_argv(with_cwd(inner), /*needs_stdin=*/true),
max_output, timeout_secs + 5, on_chunk, true);
}
protected:
// wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate
// a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join()
virtual std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const = 0;
// copy a host file into the isolate, `isolate_path` is absolute and its parent already exists
virtual bool upload(const std::string & host_path, const std::string & isolate_path) const = 0;
// quote `argv` into a single string that a POSIX shell re-parses into exactly `argv`
static std::string shell_quote_join(const std::vector<std::string> & argv) {
std::string out;
for (const auto & arg : argv) {
if (!out.empty()) out += ' ';
out += '\'';
for (const char c : arg) {
// a single quote cannot be escaped inside single quotes: close, escape, reopen
if (c == '\'') out += "'\\''";
else out += c;
}
out += '\'';
}
return out;
}
private:
std::string cwd;
// set the working directory in the command itself, docker's `-w` has no equivalent on every transport
// auxiliary calls do not need this, they use the absolute paths from resolve()
std::vector<std::string> with_cwd(const std::vector<std::string> & inner) const {
if (cwd.empty()) {
return inner;
}
// 127 is what a shell reports for a command it could not run
std::vector<std::string> out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd};
out.insert(out.end(), inner.begin(), inner.end());
return out;
}
exec_result exec(const std::vector<std::string> & inner, size_t max_output, bool combine_stderr) const {
return run_subprocess(
build_argv(inner, /*needs_stdin=*/false),
max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr);
}
bool shell_run(const std::vector<std::string> & inner) const {
auto res = exec(inner, 4096, true);
return res.exit_code == 0 && !res.timed_out;
}
bool shell_test(const char * flag, const std::string & path) const {
return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path});
}
static std::vector<std::string> split_lines(const std::string & text, bool strip_dot_slash) {
std::vector<std::string> result;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2);
std::replace(line.begin(), line.end(), '\\', '/');
result.push_back(line);
}
return result;
}
// one `find` pass in the isolate. junk directories stay selectable but are never descended into,
// and -mindepth/-maxdepth keep a busybox image working as well as a GNU one
std::vector<std::string> find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const {
std::string prune_expr;
for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) {
if (!prune_expr.empty()) prune_expr += " -o ";
prune_expr += std::string("-name ") + n;
}
std::string cmd = "cd \"$1\" && find . -mindepth 1";
if (max_depth > 0) {
cmd += " -maxdepth " + std::to_string(max_depth);
}
cmd += " \\( " + prune_expr + " \\) -prune";
cmd += dirs ? " -print -o -type d -print" : " -o -type f -print";
auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
truncated = truncated || res.timed_out;
return split_lines(res.output, /*strip_dot_slash=*/true);
}
};
// an already-running docker container, driven through `docker exec` and `docker cp`
class tools_io_docker : public tools_io_isolate {
public:
tools_io_docker(std::string container_id, std::string cwd = "")
: tools_io_isolate(std::move(cwd)), container_id(std::move(container_id)) {}
protected:
std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const override {
std::vector<std::string> argv = {"docker", "exec"};
if (needs_stdin) {
argv.push_back("-i");
}
argv.push_back(container_id);
argv.insert(argv.end(), inner.begin(), inner.end());
return argv;
}
bool upload(const std::string & host_path, const std::string & isolate_path) const override {
auto res = run_subprocess(
{"docker", "cp", host_path, container_id + ":" + isolate_path},
4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true);
return res.exit_code == 0 && !res.timed_out;
}
private:
std::string container_id;
};
// runtime spec used by --tools-runtime and the x-tool-runtime header
// this is the only scheme for now, ssh: and podman: can be added next to it
static const std::string SERVER_TOOL_RUNTIME_DOCKER_CONTAINER = "docker-container:";
// an empty runtime runs the tools on the host
static std::unique_ptr<tools_io> make_tools_io(const json & params) {
std::string cwd = json_value(params, "cwd", std::string());
return std::make_unique<tools_io_basic>(cwd);
std::string cwd = json_value(params, "cwd", std::string());
std::string runtime = json_value(params, "runtime", std::string());
if (runtime.empty()) {
return std::make_unique<tools_io_basic>(cwd);
}
if (runtime.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
return std::make_unique<tools_io_docker>(runtime.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()), cwd);
}
// do not fall back to the host, the caller asked for an isolate
throw std::runtime_error("unknown tool runtime: " + runtime);
}
// no '/' in pattern -> match basename at any depth; else match full relative path
@@ -476,6 +764,7 @@ struct server_tool_read_file : server_tool {
server_tool_read_file() {
name = "read_file";
display_name = "Read file";
uses_cwd = true;
permission_write = false;
}
@@ -564,6 +853,7 @@ struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search() {
name = "file_glob_search";
display_name = "File search";
uses_cwd = true;
permission_write = false;
}
@@ -678,6 +968,7 @@ struct server_tool_grep_search : server_tool {
server_tool_grep_search() {
name = "grep_search";
display_name = "Grep search";
uses_cwd = true;
permission_write = false;
}
@@ -830,6 +1121,7 @@ struct server_tool_exec_shell_command : server_tool {
server_tool_exec_shell_command() {
name = "exec_shell_command";
display_name = "Execute shell command";
uses_cwd = true;
permission_write = true;
support_stream = true;
}
@@ -861,8 +1153,11 @@ struct server_tool_exec_shell_command : server_tool {
timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT);
max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
// an isolate is always POSIX regardless of host OS, so it always gets `sh -c`
#ifdef _WIN32
std::vector<std::string> args = {"cmd", "/c", command};
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
? std::vector<std::string>{"sh", "-c", command}
: std::vector<std::string>{"cmd", "/c", command};
#else
std::vector<std::string> args = {"sh", "-c", command};
#endif
@@ -905,6 +1200,7 @@ struct server_tool_write_file : server_tool {
server_tool_write_file() {
name = "write_file";
display_name = "Write file";
uses_cwd = true;
permission_write = true;
}
@@ -947,6 +1243,7 @@ struct server_tool_edit_file : server_tool {
server_tool_edit_file() {
name = "edit_file";
display_name = "Edit file";
uses_cwd = true;
permission_write = true;
}
@@ -1335,6 +1632,7 @@ struct server_tool_get_info : server_tool {
server_tool_get_info() {
name = "get_info";
display_name = "Get Runtime Info";
uses_cwd = true;
permission_write = false;
}
@@ -1355,11 +1653,16 @@ struct server_tool_get_info : server_tool {
json invoke(json params, server_tool::stream *) const override {
auto io = make_tools_io(params);
// inside an isolate, we always use the linux command
#ifdef _WIN32
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
? std::vector<std::string>{"uname", "-a"}
: std::vector<std::string>{"cmd", "/c", "ver"};
#else
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
std::vector<std::string> args = {"uname", "-a"};
#endif
auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
// "ver" prints a blank line before the version, so the output is stripped on both ends;
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown";
@@ -1461,6 +1764,103 @@ struct server_mcp_tool : server_tool {
}
};
// owns the docker container used as the sandboxed runtime for tool invocations, as configured by
// --tools-runtime. "spawned" mode starts and stops the container itself; "existing" mode just reuses
// a container id the user already has running and never stops it.
struct server_tools_docker_runtime {
server_tools_docker_runtime(const server_tools_docker_runtime &) = delete;
explicit server_tools_docker_runtime(const std::string & spec) {
static const std::string docker_prefix = "docker:";
if (spec.rfind(docker_prefix, 0) == 0) {
spawned = true;
image = spec.substr(docker_prefix.size());
if (image.empty()) {
throw std::runtime_error("--tools-runtime docker:<image> requires an image name");
}
spawn();
} else if (spec.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
spawned = false;
container_id = spec.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size());
if (container_id.empty()) {
throw std::runtime_error("--tools-runtime docker-container:<id> requires a container id");
}
} else {
throw std::runtime_error("unknown --tools-runtime option: " + spec);
}
}
~server_tools_docker_runtime() {
if (spawned && !container_id.empty()) {
// closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it
proc.close_stdin();
proc.join();
}
}
// container id to use for the next tool call; respawns a spawned container that died on its own,
// or throws if an externally-managed one is no longer reachable
std::string get_container_id() {
std::lock_guard<std::mutex> lock(mutex);
if (!spawned) {
if (!is_running(container_id)) {
throw std::runtime_error(string_format(
"docker container \"%s\" is no longer running, restart it to keep using tools",
container_id.c_str()));
}
return container_id;
}
if (!proc.alive()) {
SRV_WRN("docker tools runtime container \"%s\" died, respawning\n", container_id.c_str());
spawn();
}
return container_id;
}
private:
bool spawned = false;
std::string image; // spawned mode only
std::string container_id;
common_subproc proc; // spawned mode only: `docker run` client that keeps the container alive
std::mutex mutex;
// spawns "docker run --rm -i <image> sh" and keeps its stdin open; the shell blocks reading stdin,
// so the container stays alive until we close it (see destructor) or it is killed from the outside
void spawn() {
std::error_code ec;
fs::path cidfile = fs::temp_directory_path(ec) / string_format(
"llama-tools-runtime-cid-%zu.tmp", std::hash<std::thread::id>{}(std::this_thread::get_id()));
fs::remove(cidfile, ec);
std::vector<std::string> args = {"docker", "run", "--rm", "-i", "--cidfile", cidfile.string(), image, "sh"};
int options = subprocess_option_no_window
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (!proc.create(args, options)) {
throw std::runtime_error("failed to spawn docker container for tools runtime (image: " + image + ")");
}
std::string cid;
for (int i = 0; i < 100 && cid.empty(); i++) {
std::ifstream f(cidfile);
if (f) std::getline(f, cid);
if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
fs::remove(cidfile, ec);
if (cid.empty()) {
proc.terminate();
throw std::runtime_error("timed out waiting for docker container to start (image: " + image + ")");
}
container_id = cid;
}
static bool is_running(const std::string & id) {
auto res = run_subprocess({"docker", "inspect", "-f", "{{.State.Running}}", id}, 16, 5, nullptr, true);
return res.exit_code == 0 && !res.timed_out && res.output.rfind("true", 0) == 0;
}
};
static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) {
for (auto & t : tools) {
if (t->name == name) {
@@ -1506,8 +1906,16 @@ static std::string get_header(const std::map<std::string, std::string> & headers
return default_value;
}
server_tools::server_tools() = default;
server_tools::~server_tools() = default;
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
server_mcp & mcp_mgr,
const std::string & tools_runtime) {
if (!tools_runtime.empty()) {
docker_runtime = std::make_unique<server_tools_docker_runtime>(tools_runtime);
}
if (!enabled_tools.empty()) {
if (!common_subproc::is_supported()) {
throw std::runtime_error("subprocess is not enabled on this build");
@@ -1590,11 +1998,26 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
bool stream = body.value("stream", false);
// accept x-tool-cwd header to override of the process
if (params.contains("cwd")) {
params.erase("cwd");
}
auto cwd = get_header(req.headers, "x-tool-cwd");
if (!cwd.empty()) {
params["cwd"] = cwd;
}
// accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:<id>";
// falls back to the --tools-runtime isolate, if configured
if (params.contains("runtime")) {
params.erase("runtime");
}
auto runtime = get_header(req.headers, "x-tool-runtime");
if (!runtime.empty()) {
params["runtime"] = runtime;
} else if (docker_runtime) {
params["runtime"] = SERVER_TOOL_RUNTIME_DOCKER_CONTAINER + docker_runtime->get_container_id();
}
server_tool & tool = find_tool(tools, tool_name, stream);
if (stream) {
+11 -1
View File
@@ -14,6 +14,7 @@ struct server_tool {
std::string display_name;
bool permission_write = false;
bool support_stream = false; // if true, output can be streamed
bool uses_cwd = false; // if true, the tool resolves paths and runs against the working directory
virtual ~server_tool() = default;
virtual json get_definition() const = 0;
@@ -30,6 +31,8 @@ struct server_tool {
json to_json() const;
};
struct server_tools_docker_runtime; // impl detail, defined in server-tools.cpp
struct server_tools {
std::vector<std::unique_ptr<server_tool>> tools;
@@ -37,9 +40,16 @@ struct server_tools {
server_response queue_res;
std::atomic<int> res_id{0};
// set when --tools-runtime is configured; owns the docker container used to run tools, if any
std::unique_ptr<server_tools_docker_runtime> docker_runtime;
void setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr);
server_mcp & mcp_mgr,
const std::string & tools_runtime);
server_http_context::handler_t handle_get;
server_http_context::handler_t handle_post;
server_tools();
~server_tools();
};
+4 -1
View File
@@ -338,7 +338,7 @@ int llama_server(common_params & params, int argc, char ** argv) {
if (!params.server_tools.empty() || !mcp_mgr.empty()) {
try {
tools.setup(params.server_tools, mcp_mgr);
tools.setup(params.server_tools, mcp_mgr, params.server_tools_runtime);
} catch (const std::exception & e) {
SRV_ERR("tools setup failed: %s\n", e.what());
return 1;
@@ -348,6 +348,9 @@ int llama_server(common_params & params, int argc, char ** argv) {
if (!params.server_tools.empty()) {
warn_names.push_back("built-in tools (experimental)");
}
if (!params.server_tools_runtime.empty()) {
warn_names.push_back("tools runtime (experimental)");
}
if (!mcp_mgr.empty()) {
warn_names.push_back("MCP servers (experimental)");
}
+2 -2
View File
@@ -15,7 +15,7 @@ def stop_server_after_each_test():
server.stop()
@pytest.fixture(scope="module", autouse=True)
def do_something():
@pytest.fixture(scope="session", autouse=True)
def load_server_presets():
# this will be run once per test session, before any tests
ServerPreset.load_all()
+3 -3
View File
@@ -14,10 +14,10 @@ fi
if [ $# -lt 1 ]
then
if [[ "${SLOW_TESTS:-0}" == 1 ]]; then
pytest -v -x
pytest --durations=30 -v -x
else
pytest -v -x -m "not slow"
pytest --durations=30 -v -x -m "not slow"
fi
else
pytest "$@"
pytest --durations=30 "$@"
fi
+152 -2
View File
@@ -85,7 +85,7 @@ def _wait_for_model_status(model_id: str, desired: set[str], timeout: int = 60)
last_status = _get_model_status(model_id)
if last_status in desired:
return last_status
time.sleep(1)
time.sleep(0.01)
raise AssertionError(
f"Timed out waiting for {model_id} to reach {desired}, last status: {last_status}"
)
@@ -145,6 +145,156 @@ def test_router_models_max_evicts_lru():
assert _get_model_status(first) == "unloaded"
# server_lru_sched tests (relying on LLAMA_SERVER_DEBUG_FAKE_TIMING)
MODEL_A = "ggml-org/tinygemma3-GGUF:Q8_0"
MODEL_B = "ggml-org/test-model-stories260K:F32"
MODEL_C = "ggml-org/test-model-stories260K-infill:F32"
def _tokenize(model_id: str, timeout: float | None = DEFAULT_REQUEST_TIMEOUT) -> ServerResponse:
return server.make_request(
"POST", "/tokenize", data={"model": model_id, "content": "hello world"}, timeout=timeout
)
class _Bg:
"""runs one request in a thread, keeps its result, error and finish time"""
def __init__(self, fn):
self.result = None
self.error: Exception | None = None
self.done_at: float = 0.0
self._thread = threading.Thread(target=self._run, args=(fn,), daemon=True)
def _run(self, fn):
try:
self.result = fn()
except Exception as e:
self.error = e
self.done_at = time.time()
def start(self):
self._thread.start()
return self
def join(self, timeout: int = 180):
self._thread.join(timeout)
assert not self._thread.is_alive(), "background request did not finish in time"
return self
def assert_ok(self, what: str):
assert self.error is None, f"{what} raised {self.error!r}"
assert self.result is not None and self.result.status_code == 200, \
f"{what} failed: {self.result.status_code if self.result else None} {self.result.body if self.result else None}"
def test_router_queue_does_not_evict_busy_model():
"""a request that finds no free slot waits, and the model serving a request survives it"""
global server
server.models_max = 1
server.start()
_load_model_and_wait(MODEL_A, timeout=120)
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
time.sleep(0.5) # let the request reach the child and take the only slot
# no slot free and MODEL_A is busy, so this queues instead of evicting mid-request
queued = _Bg(lambda: _tokenize(MODEL_B)).start()
busy.join()
queued.join()
# had MODEL_A been evicted while serving, its own request would have died
busy.assert_ok("request against the busy model")
queued.assert_ok("queued request")
_wait_for_model_status(MODEL_B, {"loaded"}, timeout=120)
assert _get_model_status(MODEL_A) == "unloaded"
def test_router_queue_coalesces_requests_for_same_model():
"""many requests for one missing model share a slot, so only one model is given up"""
global server
server.models_max = 2
server.start()
_load_model_and_wait(MODEL_A, timeout=120)
_load_model_and_wait(MODEL_B, timeout=120)
# keep MODEL_A busy so MODEL_B is the only model that can be given up
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
time.sleep(0.5)
waiters = [_Bg(lambda: _tokenize(MODEL_C)).start() for _ in range(3)]
busy.join()
for w in waiters:
w.join()
busy.assert_ok("request against the busy model")
for i, w in enumerate(waiters):
w.assert_ok(f"queued request {i}")
_wait_for_model_status(MODEL_C, {"loaded"}, timeout=120)
# one entry for 3 requests means one eviction: MODEL_B goes, MODEL_A is left alone.
# without coalescing the leftover entries still ask for a slot,
# and MODEL_A is taken too as soon as it goes idle
assert _get_model_status(MODEL_A) == "loaded"
assert _get_model_status(MODEL_B) == "unloaded"
def test_router_queue_client_disconnect_keeps_model():
"""a client that leaves while queued must not cost a running model its slot"""
global server
server.models_max = 1
server.start()
_load_model_and_wait(MODEL_A, timeout=120)
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
time.sleep(0.5)
# queues behind MODEL_A, then gives up long before MODEL_A goes idle
with pytest.raises(requests.exceptions.RequestException):
_tokenize(MODEL_B, timeout=1)
busy.join()
busy.assert_ok("request against the busy model")
# nobody is waiting anymore, so MODEL_A keeps its slot
time.sleep(3)
assert _get_model_status(MODEL_A) == "loaded"
assert _get_model_status(MODEL_B) == "unloaded"
def test_router_queue_is_fifo():
"""the queue is served in arrival order"""
global server
server.models_max = 1
server.start()
_load_model_and_wait(MODEL_A, timeout=120)
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
time.sleep(0.5)
first = _Bg(lambda: _tokenize(MODEL_B)).start()
time.sleep(1) # keep the arrival order unambiguous
second = _Bg(lambda: _tokenize(MODEL_C)).start()
busy.join()
first.join()
second.join()
busy.assert_ok("request against the busy model")
first.assert_ok("first queued request")
second.assert_ok("second queued request")
assert first.done_at < second.done_at, "queue was not served in arrival order"
def test_router_no_models_autoload():
global server
server.no_models_autoload = True
@@ -310,7 +460,7 @@ def _wait_for_sse_event(collected: list, event_type: str, model: str, timeout: i
while time.time() < deadline:
if any(e.get("event") == event_type and e.get("model") == model for e in collected):
return True
time.sleep(0.5)
time.sleep(0.01)
return False
@@ -1,4 +1,6 @@
import os
import shutil
import subprocess
import pytest
from utils import *
@@ -146,6 +148,95 @@ def test_tools_builtin_cwd_header():
os.remove(marker_path)
def _docker_unavailable_reason() -> str | None:
"""None if docker can be used to run a container, otherwise the reason it can't."""
docker_bin = shutil.which("docker")
if docker_bin is None:
return "docker is not installed"
try:
subprocess.run([docker_bin, "info"], capture_output=True, timeout=5, check=True)
except Exception as e:
return f"docker daemon is not usable: {e}"
return None
@pytest.fixture
def docker_container():
reason = _docker_unavailable_reason()
if reason is not None:
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
proc = subprocess.run(
["docker", "run", "-d", "--rm", "busybox", "sleep", "300"],
capture_output=True, text=True,
)
if proc.returncode != 0:
pytest.skip(f"failed to start docker container: {proc.stderr.strip()}") # ty: ignore[too-many-positional-arguments, invalid-argument-type]
container_id = proc.stdout.strip()
try:
yield container_id
finally:
subprocess.run(["docker", "rm", "-f", container_id], capture_output=True)
def test_tools_builtin_runtime_header(docker_container: str):
global server
server.start()
headers = {"x-tool-runtime": f"docker-container:{docker_container}", "x-tool-cwd": "/tmp"}
write_res = call_tool("write_file", {"path": "test.log", "content": "hello docker\n"}, headers=headers)
assert write_res["result"] == "file written successfully"
read_res = call_tool("read_file", {"path": "test.log"}, headers=headers)
assert read_res["plain_text_response"] == "hello docker\n"
exec_res = call_tool("exec_shell_command", {"command": "cat test.log"}, headers=headers)
assert "hello docker" in exec_res["plain_text_response"]
def test_tools_builtin_runtime_header_unknown_scheme():
global server
server.start()
# an unknown runtime must fail, never silently fall back to running on the host
res = server.make_request("POST", "/tools",
data={"tool": "exec_shell_command", "params": {"command": "echo hi"}},
headers={"x-tool-runtime": "ssh:example.com"})
assert res.status_code == 500, res.body
assert "unknown tool runtime" in str(res.body)
def test_tools_builtin_docker_runtime_cleans_up_spawned_container():
reason = _docker_unavailable_reason()
if reason is not None:
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
global server
server.server_tools_runtime = "docker:busybox"
server.start()
# exec_shell_command runs inside the container spawned for --tools-runtime; docker sets
# the container's hostname to its own short id, so this also tells us which one to check
res = call_tool("exec_shell_command", {"command": "hostname"})
container_id = res["plain_text_response"].splitlines()[0].strip()
assert len(container_id) >= 8, res
running = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", container_id],
capture_output=True, text=True,
)
assert running.returncode == 0 and running.stdout.strip() == "true", running.stderr
server.stop()
# a clean server shutdown must stop and remove the container it spawned (it runs with --rm),
# not leave it behind as an abandoned child
leftover = subprocess.run(["docker", "inspect", container_id], capture_output=True, text=True)
assert leftover.returncode != 0, f"container {container_id} was not cleaned up after server exit"
def test_tools_builtin_edit_file_rejects_overlapping_edits():
global server
server.start()
+12 -3
View File
@@ -115,6 +115,7 @@ class ServerProcess:
backend_sampling: bool = False
gcp_compat: bool = False
server_tools: str | None = None
server_tools_runtime: str | None = None
mcp_servers_config: str | None = None
mcp_servers_json: str | None = None
cors_origins: str | None = None
@@ -132,7 +133,10 @@ class ServerProcess:
self.external_server = "DEBUG_EXTERNAL" in os.environ
def start(self, timeout_seconds: int = DEFAULT_HTTP_TIMEOUT) -> None:
env = {**os.environ}
env = {
**os.environ,
"LLAMA_SERVER_DEBUG_FAKE_TIMING": "1",
}
if "LLAMA_CACHE" not in os.environ:
env["LLAMA_CACHE"] = "tmp"
if self.external_server:
@@ -267,6 +271,8 @@ class ServerProcess:
server_args.append("--ui-mcp-proxy")
if self.server_tools:
server_args.extend(["--tools", self.server_tools])
if self.server_tools_runtime:
server_args.extend(["--tools-runtime", self.server_tools_runtime])
if self.mcp_servers_config:
server_args.extend(["--mcp-servers-config", self.mcp_servers_config])
if self.mcp_servers_json:
@@ -303,6 +309,7 @@ class ServerProcess:
# wait for server to start
start_time = time.time()
last_print_time = start_time
while time.time() - start_time < timeout_seconds:
try:
response = self.make_request("GET", "/health", headers={
@@ -317,8 +324,10 @@ class ServerProcess:
if self.process.poll() is not None:
raise RuntimeError(f"Server process died with return code {self.process.returncode}")
print(f"Waiting for server to start...")
time.sleep(0.5)
if time.time() - last_print_time >= 1.0:
print(f"Waiting for server to start...")
last_print_time = time.time()
time.sleep(0.01)
raise TimeoutError(f"Server did not start within {timeout_seconds} seconds")
def stop(self) -> None:
+5 -2
View File
@@ -179,17 +179,20 @@ int main(int argc, char ** argv) {
const char * data = nullptr;
size_t data_len = 0;
int64_t n_samples = 0;
const int64_t t_wav_start_us = ggml_time_us();
if (gen.get_output(&sample_rate, &data, &data_len, &n_samples) != 0) {
LOG_ERR("get_output failed\n");
return 1;
}
const double t_wav_s = (ggml_time_us() - t_wav_start_us) / 1e6;
LOG_INF("generated %d frames, %zu bytes of WAV audio (%d Hz)\n", n_frames, data_len, sample_rate);
const double t_prompt_s = (t_gen_start_us - t_prompt_start_us) / 1e6;
const double t_total_s = t_prompt_s + t_gen_s;
const double t_total_s = t_prompt_s + t_gen_s + t_wav_s;
const double audio_s = sample_rate > 0 ? (double) n_samples / sample_rate : 0.0;
LOG_INF("timings: prompt eval %.2fs + generation %.2fs = total %.2fs\n", t_prompt_s, t_gen_s, t_total_s);
LOG_INF("timings: prompt eval %.2fs + generation %.2fs + vocoder %.2fs = total %.2fs\n",
t_prompt_s, t_gen_s, t_wav_s, t_total_s);
LOG_INF(" output audio = %.2fs (audio time = %.2fx process time)\n", audio_s, t_total_s > 0 ? audio_s / t_total_s : 0.0);
FILE * f = fopen(params.out_file.c_str(), "wb");
if (!f) {
+1
View File
@@ -1,2 +1,3 @@
engine-strict=true
ignore-scripts=true
min-release-age=7
+2 -4
View File
@@ -143,10 +143,8 @@ declare global {
idxThemeStyle?: number;
idxCodeBlock?: number;
// File System Access API - missing from older DOM lib versions.
// Used by ChatFormWorkingDirectory's native folder picker. Feature availability
// is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
showDirectoryPicker: (options?: {
// File System Access API - not in the DOM lib and unavailable in some browsers
showDirectoryPicker?: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
@@ -2,6 +2,7 @@
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormContenteditable,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
@@ -14,9 +15,7 @@
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX,
RESOURCE_TRIGGER_PREFIX
PROMPT_CONTENT_SEPARATOR
} from '$lib/constants';
import {
ContentPartType,
@@ -39,8 +38,25 @@
activeConversation,
pendingCwd
} from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
import type {
FileMentionEntry,
GetPromptResult,
MCPPromptInfo,
MCPResourceInfo,
PromptMessage
} from '$lib/types';
import {
buildMentionInsertion,
containsCodeSpan,
containsFileMentionLink,
findCommandToken,
findMentionToken,
isIMEComposing,
isOffsetInCodeBlock,
parseClipboardContent,
uuid
} from '$lib/utils';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
AudioRecorder,
convertToWav,
@@ -97,29 +113,67 @@
}: Props = $props();
// Component References
// Shared handle of the two input renderers (textarea + contenteditable).
type ChatInputHandle = {
focus(): void;
resetHeight(): void;
getElement(): HTMLElement | undefined;
getCaretOffset(): number;
setCaretOffset(offset: number): void;
};
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let inputRef: ChatInputHandle | undefined = $state(undefined);
// Render-mode gate: the plain textarea by default, the contenteditable
// while the buffer carries a `file://` mention link or a complete code
// span (badges and code chips need a DOM the textarea cannot provide).
// Demotes back once neither remains.
let useContenteditable = $state(false);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isInlineResourcePickerOpen = $state(false);
let resourceSearchQuery = $state('');
// Invisible anchor at the form's top edge so the mention/WD popovers
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
async function handleWorkingDirectoryChange(value: string | null) {
await conversationsStore.setCwd(value);
const pickers = useChatFormPickers({
getValue: () => value,
setValue: (v) => {
value = v;
onValueChange?.(v);
},
getCaretOffset: () => inputRef?.getCaretOffset(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
getShowModelSelector: () => showModelSelector,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
getCwd: () => cwd,
getServerHome: () => toolsStore.serverHome ?? null,
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
getPickersRef: () => pickersRef
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(value?.trim() || null);
await chatStore.recordCwdChange(newDir?.trim() || null);
}
}
@@ -166,23 +220,45 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
// Caret offset restored after a renderer swap. Callers that mutate
// `value` themselves (e.g. the mention picker) pin the target offset
// BEFORE the assignment; otherwise the swap effect snapshots the
// current caret.
let pendingCaretOffset = 0;
let caretOffsetPinned = false;
function queueCaretRestore() {
queueMicrotask(() => {
inputRef?.focus();
inputRef?.setCaretOffset(pendingCaretOffset);
caretOffsetPinned = false;
});
}
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) {
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
}
useContenteditable = wantContenteditable;
queueCaretRestore();
});
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
// Defer so the closing popover's focus scope tears down first - bits-ui
// yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => textareaRef?.focus());
}
export function focus() {
textareaRef?.focus();
inputRef?.focus();
}
export function resetTextareaHeight() {
textareaRef?.resetHeight();
inputRef?.resetHeight();
}
export function openModelSelector() {
@@ -216,46 +292,10 @@
}
}
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
} else if (
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
hasServers &&
mcpStore.hasResourcesCapability(perChatOverrides)
) {
isInlineResourcePickerOpen = true;
resourceSearchQuery = value.slice(1);
isPromptPickerOpen = false;
promptSearchQuery = '';
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (pickersRef?.handleKeydown(event)) {
return;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
// Pickers consume navigation/escape keys first; when consumed, skip
// the enter-to-submit logic below.
if (pickers.handleKeydown(event)) {
return;
}
@@ -263,6 +303,15 @@
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
// Caret inside a fenced code block (closed, or still open
// while being typed): Enter adds a line, never submits. The
// contenteditable consumes this case locally; this gate
// covers the plain textarea, where skipping submit lets the
// native newline through.
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
return;
}
if (sendOnEnter || isModifier) {
event.preventDefault();
@@ -332,7 +381,7 @@
}
setTimeout(() => {
textareaRef?.focus();
inputRef?.focus();
}, 10);
return;
@@ -359,13 +408,7 @@
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
pickers.closePromptPicker();
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
@@ -384,7 +427,7 @@
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
inputRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
@@ -426,39 +469,36 @@
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
// Deferred so the closing popover's focus scope tears down first -
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => inputRef?.focus());
}
function handleInlineResourcePickerClose() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
// Splice the mention link in place of the `@<query>` token. Uses the
// live cursor, not a stale snapshot - the token may have been edited.
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
function handleInlineResourceSelect() {
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
// otherwise the effect clobbers it with the textarea's selection
// at promotion time (browser-dependent: usually reset to 0).
pendingCaretOffset = built.caretOffset;
caretOffsetPinned = true;
value = built.newValue;
onValueChange?.(built.newValue);
// Already in contenteditable mode: no renderer flip, so the swap
// effect's caret restore never runs.
if (useContenteditable) {
queueCaretRestore();
}
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
textareaRef?.focus();
}
function handleBrowseResources() {
isInlineResourcePickerOpen = false;
resourceSearchQuery = '';
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isResourceDialogOpen = true;
}
async function handleMicClick() {
@@ -503,19 +543,32 @@
>
<ChatFormPickers
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isInlineResourcePickerOpen}
{resourceSearchQuery}
onPromptPickerClose={handlePromptPickerClose}
onInlineResourcePickerClose={handleInlineResourcePickerClose}
onInlineResourceSelect={handleInlineResourceSelect}
isCommandPickerOpen={pickers.isCommandPickerOpen}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
isPromptPickerOpen={pickers.isPromptPickerOpen}
promptSearchQuery={pickers.promptSearchQuery}
isMentionPickerOpen={pickers.isMentionPickerOpen}
mentionQuery={pickers.mentionQuery}
{mentionAnchor}
scopePath={pickers.mentionScopePath}
onPromptPickerClose={pickers.handlePromptPickerClose}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionOpened={() => inputRef?.focus()}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onInlineResourceBrowse={handleBrowseResources}
/>
<div
bind:this={mentionAnchor}
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
aria-hidden="true"
></div>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
@@ -534,20 +587,36 @@
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
{#if useContenteditable}
<ChatFormContenteditable
class="px-5 py-1.5 md:pt-0 mb-0.5"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{:else}
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{/if}
{#if mcpHasResourceAttachments()}
<ChatFormMcpResourcesList
@@ -574,7 +643,7 @@
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
@@ -582,11 +651,15 @@
<ContextGaugePopup />
{#if toolsStore.builtinTools.length > 0}
{#if toolsStore.hasEnabledCwdTools}
<ChatFormWorkingDirectory
directory={cwd}
isOpen={pickers.isWorkingDirectoryPickerOpen}
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
onChange={handleWorkingDirectoryChange}
onClose={refocusInput}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
{disabled}
/>
{/if}
@@ -0,0 +1,788 @@
<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { isMobile } from '$lib/stores/viewport.svelte';
import { ColorMode } from '$lib/enums';
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
import {
badgeAwareWordJump,
buildFragment,
domMatchesTokens,
highlightCode,
isIMEComposing,
isOffsetInCodeBlock,
leadingBadgeEdgeOffset,
rangeToTextOffset,
serializeContent,
SourceHistory,
stripBlockBoundaryLineBreaks,
syncCodeBlockHatches,
tokenizeContent,
textOffsetToRange
} from '$lib/utils';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
}
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
value = $bindable('')
}: Props = $props();
let rootElement: HTMLDivElement | undefined = $state();
let lastEmittedValue = '';
let isComposing = $state(false);
// Undo/redo in source space: the imperative token rebuilds destroy the
// browser's native undo stack.
const history = new SourceHistory();
// Browsers disagree on what an empty contenteditable contains (`<br>`,
// `<div><br></div>`, or nothing), so emptiness is decided by the
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
if (!rootElement) return;
const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
}
function renderTokens(tokens: ContentToken[]) {
if (!rootElement) return;
const caret = rangeToTextOffset(rootElement, safeRange());
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.replaceChildren(buildFragment(tokens));
syncCodeBlockHatches(rootElement);
highlightCodeBlocks(rootElement);
restoreCaret(caret);
resizeHeight();
syncEmptyState();
}
// Last highlighted source segment per block element - typing inside
// a block re-highlights only when the segment actually changed.
const highlightedSegments = new WeakMap<HTMLElement, string>();
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
/**
* Apply syntax highlighting to a code block element's CONTENT. The
* fence lines stay plain text, and the blank padding that
* `highlightCode` trims is re-added as plain text, so the element's
* textContent stays byte-exact with the source segment. Replaces
* the element's children - callers restore the caret afterwards.
* Returns false when nothing changed.
*/
function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? '';
if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment);
if (!open) return false;
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
el.replaceChildren(
document.createTextNode(prefix + leading),
tpl.content.cloneNode(true),
document.createTextNode(trailing + '```')
);
highlightedSegments.set(el, segment);
return true;
}
function highlightCodeBlocks(root: HTMLElement) {
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
highlightCodeBlockElement(el);
}
}
/**
* Re-highlight the code block the caret sits in after an edit.
* Skipped when the block's segment is unchanged since its last
* highlight, so edits outside blocks cost nothing.
*/
function rehighlightCaretCodeBlock() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
let node: Node | null = range.startContainer;
if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null;
}
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) {
restoreCaret(caret);
}
return;
}
node = node.parentNode;
}
}
/**
* Is the caret inside a fenced code block region? Source-level
* (not DOM-level) so the still-OPEN fence counts too: while the
* user is typing a block, no closing ``` exists yet and the
* buffer is plain text with no block element to find. Root-level
* caret positions right at a closed block's edge (escape
* hatches, element boundaries restored by `textOffsetToRange`)
* resolve past the closing fence, so they count as OUTSIDE.
*/
function caretInCodeBlock(): boolean {
if (!rootElement) return false;
return isOffsetInCodeBlock(
serializeContent(rootElement),
rangeToTextOffset(rootElement, safeRange())
);
}
/**
* hljs theme for the highlighted code blocks. Mirrors
* SyntaxHighlightedCode.svelte: one shared style element
* (deduped via the data attribute) swapped on mode change.
*/
function loadHighlightTheme(isDark: boolean) {
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
$effect(() => {
loadHighlightTheme(mode.current === ColorMode.DARK);
});
function safeRange(): Range | null {
if (!rootElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
return null;
}
return range;
}
function restoreCaret(offset: number, extend = false) {
if (!rootElement) return;
const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection();
if (!selection) return;
if (extend && selection.anchorNode) {
selection.setBaseAndExtent(
selection.anchorNode,
selection.anchorOffset,
target.startContainer,
target.startOffset
);
return;
}
selection.removeAllRanges();
selection.addRange(target);
}
function resizeHeight() {
if (!rootElement) return;
rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`;
}
function recordHistory(newGroup: boolean) {
if (!rootElement) return;
history.push(
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
Date.now(),
newGroup
);
}
/**
* Re-emit the current markdown source value to the parent, then
* reconcile the DOM against the token stream: when a code span
* was just completed or broken, the token boundaries no longer
* match the element structure and the DOM is rebuilt (caret
* preserved through the source-offset mapping).
*/
function processInput(inputType?: string) {
if (isComposing || !rootElement) return;
syncEmptyState();
resizeHeight();
// Shift+Enter right after a code block leaves an all-newline
// text node (the fence's separator line plus Chromium's
// artificial end-of-buffer line break). Strip both so the caret
// lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange());
if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret);
} else {
const source = serializeContent(rootElement);
let end = caret;
// the caret must end up after the inserted \n; some browsers
// leave it before (stuck at the end of the old line). A
// preceding \n means it already sits past the break
// (Chromium's artificial trailing newline) - leave it.
if (source[end] === '\n' && source[end - 1] !== '\n') {
end += 1;
restoreCaret(end);
}
// a line break at the buffer end renders only with a second,
// artificial trailing \n: a lone trailing \n is collapsed, so
// the new line is invisible and the next typed character
// consumes it. Append it when missing - unless the trailing
// \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element.
let last = rootElement.lastChild;
while (last && last.nodeName === 'BR') last = last.previousSibling;
if (
end === source.length &&
source.endsWith('\n') &&
source[source.length - 2] !== '\n' &&
last?.nodeType === Node.TEXT_NODE
) {
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.appendChild(document.createTextNode('\n'));
restoreCaret(source.length);
resizeHeight();
}
}
}
syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement);
syncEmptyState(serialized);
if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits
// (paste, newline, cut, autocorrect) start a new undo group.
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
lastEmittedValue = serialized;
value = serialized;
// Rebuild when token boundaries shifted (a code span was just
// completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized);
if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens);
// The rebuild can re-shape the DOM in a way that changes the
// serialization (e.g. Chromium merged trailing text into the
// block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement);
if (reserialized !== serialized) {
lastEmittedValue = reserialized;
value = reserialized;
}
} else {
rehighlightCaretCodeBlock();
}
onInput?.();
}
function handleInput(event: Event) {
processInput((event as InputEvent).inputType);
}
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
processInput();
}
/**
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
* the buffer end varies across browsers (a lone trailing \n that the
* renderer collapses, or a <br> that the hatch sync strips), which
* can leave the caret stuck on the old line; splitting the text node
* ourselves keeps the DOM shape - and the caret - deterministic.
* `processInput` then appends the artificial trailing \n when the
* break lands at the buffer end.
*/
function insertLineBreak() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
if (!range.collapsed) {
range.deleteContents();
}
const container = range.startContainer;
const offset = range.startOffset;
const nl = document.createTextNode('\n');
// a break at the very end of a code block exits the block (the
// new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null;
if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode;
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const tail = document.createRange();
tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length);
if (tail.toString().length === 0) exitBlock = node;
break;
}
node = node.parentNode;
}
}
if (exitBlock) {
exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
if (offset === 0) {
text.before(nl);
} else if (offset === text.length) {
text.after(nl);
} else {
text.splitText(offset).before(nl);
}
} else {
container.insertBefore(nl, container.childNodes[offset] ?? null);
}
const selection = window.getSelection();
const after = document.createRange();
after.setStartAfter(nl);
after.collapse(true);
selection?.removeAllRanges();
selection?.addRange(after);
processInput('insertLineBreak');
}
/**
* Arrow escape to the line BEFORE a leading code block. Native
* caret movement has no position above a buffer-starting block,
* so a transient `<br>` hatch is created on demand: it gives the
* caret a visible line, is consumed by the first character typed
* on it, and is removed again when the caret leaves (see
* handleSelectionChange). Returns true when the caret was moved.
*/
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
if (!rootElement) return false;
// a hatch already exists - native movement handles it
if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild;
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
const range = safeRange();
if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first
// character for ArrowLeft, anywhere on its first line for
// ArrowUp
if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range);
if (key === 'ArrowLeft') {
if (caret !== 0) return false;
} else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
return true;
}
/**
* Remove the transient leading hatch once the caret leaves it.
* The hatch only exists to give the caret a line above a leading
* code block; with the caret anywhere else the empty line would
* just be visual noise. Typing on the hatch line consumes it via
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
* new text node takes its place before the block).
*/
function handleSelectionChange() {
if (!rootElement) return;
const first = rootElement.firstChild;
if (first?.nodeName !== 'BR') return;
const second = first.nextSibling;
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
const range = safeRange();
const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0;
if (!onHatch) {
first.remove();
}
}
/**
* Undo/redo is replayed from source snapshots (the token rebuilds
* destroy the native undo stack). Arrow keys around badges are
* repaired locally: a badge is a non-editable island, so plain
* ArrowLeft after a leading badge has no native previous position
* and word jumps overshoot it by a word.
*
* Plain Enter inside a fenced code block (closed, or still open
* while being typed) acts as Shift+Enter and adds a line instead of
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
* create the transient before-block hatch.
*/
function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (isUndo || isRedo) {
event.preventDefault();
const current = {
value: lastEmittedValue,
caret: rangeToTextOffset(rootElement, safeRange())
};
const entry = isUndo ? history.undo(current) : history.redo(current);
if (entry) applyHistoryEntry(entry);
return;
}
}
if (
event.key === 'Enter' &&
event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
!disabled &&
!caretInCodeBlock() &&
safeRange()
) {
// Own the break outside code blocks: native end-of-buffer
// behavior varies across browsers and can leave the caret
// stuck on the old line (see insertLineBreak).
event.preventDefault();
insertLineBreak();
return;
}
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
caretInCodeBlock()
) {
// The native plain-Enter path must never run: it splits the
// buffer into `<div>` wrappers that `serializeContent` cannot
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
// text node) and fires `input` synchronously, so the usual
// re-tokenize/re-highlight follows.
event.preventDefault();
document.execCommand('insertLineBreak');
return;
}
if (
rootElement &&
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault();
return;
}
}
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
const isPlainLeft =
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
if (isWordJump || isPlainLeft) {
const source = serializeContent(rootElement);
const caret = rangeToTextOffset(rootElement, safeRange());
const target = isWordJump
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
: leadingBadgeEdgeOffset(source, caret);
if (target !== null) {
event.preventDefault();
restoreCaret(target, event.shiftKey);
return;
}
}
}
onKeydown?.(event);
}
// lastEmittedValue is set before `value` so the sync effect treats the
// change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return;
renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value;
value = entry.value;
onInput?.();
restoreCaret(entry.caret);
}
/**
* Plain-text paste. preventDefault + manual insertText keeps the
* browser from producing stray `<div>` wrappers mid-paste; insertText
* fires `input` synchronously, so `processInput` re-tokenizes the
* buffer and rebuilds when the pasted text carries badge or code
* tokens.
*/
function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain');
if (pasted && pasted.length > 0) {
event.preventDefault();
// Snap a collapsed caret through the offset mapping first: at
// element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace.
const range = safeRange();
if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range));
}
document.execCommand('insertText', false, pasted);
}
}
// The parent's paste handler runs first and preventDefaults when it
// consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) {
onPaste?.(event);
if (!event.defaultPrevented) {
handlePasteEvent(event);
}
}
// The selection as markdown SOURCE (each badge contributes its full
// `[name](file://...)` link), so copy/cut carry raw markdown and
// pasting back re-renders the badges. Null for collapsed/outside
// selections - native clipboard behavior is fine there.
function selectionSourceSlice(): { text: string; range: Range } | null {
if (!rootElement) return null;
const range = safeRange();
if (!range || range.collapsed) return null;
const startRange = range.cloneRange();
startRange.collapse(true);
const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range);
return { text: source.slice(start, end), range };
}
function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
}
function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
// preventDefault suppresses the native deletion, so remove the
// selection manually and re-emit.
slice.range.deleteContents();
processInput('deleteByCut');
}
onMount(() => {
// untrack: the DOM is managed manually from input events, so the
// initial render must not subscribe to the value.
renderTokens(tokenizeContent(untrack(() => value)));
lastEmittedValue = untrack(() => value ?? '');
resizeHeight();
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
rootElement?.focus({ preventScroll: true });
}
});
onDestroy(() => {
document.removeEventListener('selectionchange', handleSelectionChange);
});
// External `value` updates. When incoming === lastEmittedValue the
// change came from our own input, so leave the DOM alone - the
// browser already owns the right shape.
$effect(() => {
const incoming = value ?? '';
if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
renderTokens(tokenizeContent(incoming));
lastEmittedValue = incoming;
});
export function getElement() {
return rootElement;
}
export function getCaretOffset(): number {
if (!rootElement) return 0;
return rangeToTextOffset(rootElement, safeRange());
}
// Focus first: `selection.addRange` requires it on some browsers.
export function setCaretOffset(offset: number) {
if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true });
}
restoreCaret(offset);
}
export function focus() {
if (isMobile.current) return;
rootElement?.focus({ preventScroll: true });
}
export function resetHeight() {
if (rootElement) {
rootElement.style.height = '';
resizeHeight();
}
}
</script>
<div class="flex-1 {className}">
<div
bind:this={rootElement}
contenteditable={!disabled}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
aria-placeholder={placeholder}
data-placeholder={placeholder}
tabindex={disabled ? -1 : 0}
class={[
'chat-form-contenteditable text-md min-h-12 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
oncopy={handleCopy}
oncut={handleCut}
></div>
</div>
<style>
/* pre-wrap is load-bearing: without it Chromium collapses \n in
text nodes and converts them to spaces while typing */
.chat-form-contenteditable {
white-space: pre-wrap;
}
.chat-form-contenteditable:global([data-empty='true'])::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
/* Inline code - mirrors markdown-content.css */
.chat-form-contenteditable :global(code[data-code-token='inline']) {
background: var(--muted);
color: var(--muted-foreground);
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.875rem;
}
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
.chat-form-contenteditable :global(code[data-code-token='block']) {
display: block;
margin: 0.25rem 0;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
border-radius: 0.75rem;
background: var(--code-background);
color: var(--code-foreground);
font-size: 0.875rem;
line-height: 1.3;
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
* The parent owns the "dismissed token, don't act until it changes"
* snapshot, so this picker just renders and reports selection.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
commands: ChatFormCommand[];
onClose: () => void;
onSelect: (command: ChatFormCommand) => void;
}
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
trimmedQuery
? commands.filter(
(c) =>
c.name.toLowerCase().includes(trimmedQuery) ||
c.description.toLowerCase().includes(trimmedQuery) ||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
)
: commands
);
function firstEnabledIndex(): number {
return filteredCommands.findIndex((c) => !c.disabled);
}
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
if (n === 0) return -1;
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx;
}
return -1;
}
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => filteredCommands.length,
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index])
});
$effect(() => {
if (isOpen) {
nav.reset(firstEnabledIndex());
}
});
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
return;
}
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
});
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
onSelect(command);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open command picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredCommands}
isLoading={false}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
emptyMessage="No matching command"
itemKey={(command) => command.name}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(command, index, isSelected)}
{@const Icon = commandIcon[command.action]}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
disabled={command.disabled}
onclick={() => handleSelect(command)}
onmouseenter={() => {
if (!command.disabled) nav.setHover(index);
}}
>
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-mono text-sm font-medium">/{command.name}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
{command.description}
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -0,0 +1,269 @@
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { FileMentionEntry } from '$lib/types';
import {
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
HOME_TILDE,
SEARCH_DEBOUNCE_MS
} from '$lib/constants';
/**
* Floating file/folder mention picker. The chat input is the search
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
* call scoped to `scopePath`. The parent owns the "dismissed token,
* don't re-open until it changes" snapshot.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
customAnchor?: HTMLElement | null;
scopePath?: string | null;
onClose: () => void;
onSelect: (entry: FileMentionEntry) => void;
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
onOpened?: () => void;
}
let {
class: className = '',
isOpen,
query,
customAnchor = null,
scopePath = null,
onClose,
onSelect,
onOpened
}: Props = $props();
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => displayedItems.length,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
// When the server does not expose file_glob_search (started without
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
);
let searchResults = $state<FileMentionEntry[]>([]);
let searchError = $state<string | null>(null);
// Coerce the depth setting to a positive integer; an invalid value
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(config().mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
});
const home = $derived(toolsStore.serverHome);
// A smaller window than the WD picker suffices: entries are ranked client-side.
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen && fileSearchEnabled,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
// A trailing path separator targets a directory, so also list its
// children. Accept both `/` and `\`.
const res = await runGlobSearchWithChildren(
query,
scopePath ?? home ?? HOME_TILDE,
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
);
if (!isCurrent()) return;
if (res.error) {
searchResults = [];
searchError = res.error;
return;
}
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
path: e.path,
name: e.name,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
}
});
const trimmedQuery = $derived((query ?? '').trim());
const displayedItems = $derived(searchResults);
const emptyMessage = $derived.by(() => {
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
$effect(() => {
if (isOpen) {
nav.reset(0);
}
});
$effect(() => {
if (isOpen) onOpened?.();
});
$effect(() => {
const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
return;
}
search.setLoading(true);
search.run(q);
});
function handleSelect(entry: FileMentionEntry) {
onSelect(entry);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
// Always consume Enter while the picker is open - even with no
// result yet (skeletons) or no matches - so the chat form's
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
return true;
}
return nav.handleKeydown(event);
}
</script>
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
We open programmatically via `open={isOpen}`, so it is inert
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
side="top"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
>
<ChatFormPickerList
items={displayedItems}
isLoading={search.isSearching}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
{emptyMessage}
itemKey={(entry) => entry.type + ':' + entry.path}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleSelect(entry)}
onmouseenter={() => nav.setHover(index)}
>
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
<Icon
class={[
'mt-0.5 h-4 w-4 shrink-0',
entry.type === FileMentionEntryType.DIRECTORY
? 'text-amber-500'
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</Popover.Content>
</Popover.Root>
@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
interface Props {
@@ -11,11 +12,19 @@
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
// Omit to distinguish "haven't searched yet" from "search returned nothing".
emptyMessage?: string;
autofocus?: boolean;
inputRef?: HTMLInputElement | null;
onSearchClose?: () => void;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
skeletonCount?: number;
footer?: Snippet;
// Counter bumped by the picker on keyboard nav; scrolls the selected
// row into view without scrolling on hover or result replacement.
scrollTrigger?: number;
}
let {
@@ -25,49 +34,69 @@
searchQuery = $bindable(),
showSearchInput,
searchPlaceholder = 'Search...',
emptyMessage = 'No items available',
emptyMessage,
autofocus = false,
inputRef = $bindable(null),
onSearchClose,
itemKey,
item,
skeleton,
footer
skeletonCount = 6,
footer,
scrollTrigger
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
$effect(() => {
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedElement = listContainer.querySelector(
`[data-picker-index="${selectedIndex}"]`
) as HTMLElement;
let listPaddingTop = $derived(
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
getTrigger: () => scrollTrigger,
getContainer: () => listContainer,
getIndex: () => selectedIndex,
getCount: () => items.length,
dataIndex: 'picker'
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
<SearchInput
{autofocus}
placeholder={searchPlaceholder}
bind:value={searchQuery}
bind:ref={inputRef}
onClose={onSearchClose}
/>
</div>
{/if}
<div
bind:this={listContainer}
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
>
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{:else}
<div aria-busy="true" aria-live="polite" class="flex flex-col">
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
</div>
</div>
{/each}
</div>
{/if}
{:else if items && items.length === 0}
{#if emptyMessage}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
{:else if items.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
@@ -3,21 +3,34 @@
interface Props {
isSelected?: boolean;
disabled?: boolean;
onclick: () => void;
onmouseenter?: () => void;
dataIndex?: number;
children: Snippet;
class?: string;
}
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
let {
class: className = '',
isSelected = false,
disabled = false,
onclick,
onmouseenter,
dataIndex,
children
}: Props = $props();
</script>
<button
type="button"
data-picker-index={dataIndex}
{disabled}
{onclick}
{onmouseenter}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''}"
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
>
{@render children()}
</button>
@@ -42,6 +42,7 @@
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
preventScroll={false}
onkeydown={onKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
@@ -45,6 +45,9 @@
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
// nav but not on hover or result changes.
let scrollTrigger = $state(0);
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
@@ -295,6 +298,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
}
return true;
@@ -304,6 +308,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
}
return true;
@@ -400,6 +405,7 @@
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
{scrollTrigger}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
@@ -1,237 +0,0 @@
<script lang="ts">
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { KeyboardKey } from '$lib/enums';
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
import { FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import {
ChatFormPickerPopover,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerItemHeader,
ChatFormPickerListItemSkeleton
} from '$lib/components/app/chat';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onResourceSelect?: (resource: MCPResourceInfo) => void;
onBrowse?: () => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onResourceSelect,
onBrowse
}: Props = $props();
let resources = $state<MCPResourceInfo[]>([]);
let isLoading = $state(false);
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadResources();
selectedIndex = 0;
}
});
$effect(() => {
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
selectedIndex = 0;
}
});
async function loadResources() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
resources = [];
return;
}
await mcpStore.fetchAllResources();
resources = mcpResourceStore.getAllResourceInfos();
} catch (error) {
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
resources = [];
} finally {
isLoading = false;
}
}
function handleResourceClick(resource: MCPResourceInfo) {
mcpStore.attachResource(resource.uri);
onResourceSelect?.(resource);
onClose?.();
}
function isResourceAttached(uri: string): boolean {
return mcpResourceStore.isAttached(uri);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
onClose?.();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredResources.length;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredResources.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
}
return true;
}
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (filteredResources[selectedIndex]) {
handleResourceClick(filteredResources[selectedIndex]);
}
return true;
}
return false;
}
let filteredResources = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedResources = [...resources].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedResources;
return sortedResources.filter(
(resource) =>
resource.name.toLowerCase().includes(query) ||
resource.title?.toLowerCase().includes(query) ||
resource.description?.toLowerCase().includes(query) ||
resource.uri.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(resources.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open resource picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredResources}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search resources..."
emptyMessage="No MCP resources available"
itemKey={(resource) => resource.serverName + ':' + resource.uri}
>
{#snippet item(resource, index, isSelected)}
{@const server = serverSettingsMap.get(resource.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleResourceClick(resource)}
>
<ChatFormPickerItemHeader
{server}
{serverLabel}
title={resource.title || resource.name}
description={resource.description}
>
{#snippet titleExtra()}
{#if isResourceAttached(resource.uri)}
<span
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
>
attached
</span>
{/if}
{/snippet}
{#snippet subtitle()}
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
{resource.uri}
</p>
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton />
{/snippet}
{#snippet footer()}
{#if onBrowse && resources.length > 3}
<Button
class="fixed right-3 bottom-3"
type="button"
onclick={onBrowse}
variant="secondary"
size="sm"
>
<FolderOpen class="h-3 w-3" />
Browse all
</Button>
{/if}
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -1,16 +1,30 @@
<script lang="ts">
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import type {
ChatFormCommand,
FileMentionEntry,
GetPromptResult,
MCPPromptInfo
} from '$lib/types';
interface Props {
isCommandPickerOpen?: boolean;
commandQuery?: string;
commands?: ChatFormCommand[];
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isInlineResourcePickerOpen?: boolean;
resourceSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onCommandPickerClose?: () => void;
onCommandSelect?: (command: ChatFormCommand) => void;
onPromptPickerClose?: () => void;
onInlineResourcePickerClose?: () => void;
onInlineResourceSelect?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
onMentionSelect?: (entry: FileMentionEntry) => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
@@ -18,36 +32,44 @@
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
onInlineResourceBrowse?: () => void;
}
let {
isCommandPickerOpen,
commandQuery,
commands = [],
onCommandPickerClose,
onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isInlineResourcePickerOpen,
resourceSearchQuery,
isMentionPickerOpen,
mentionQuery,
mentionAnchor,
scopePath,
onPromptPickerClose,
onInlineResourcePickerClose,
onInlineResourceSelect,
onMentionPickerClose,
onMentionOpened,
onMentionSelect,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError,
onInlineResourceBrowse
onPromptLoadError
}: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
* Returns true if the event was handled.
*/
/** Delegate keyboard events to the active picker child; true if handled. */
export function handleKeydown(event: KeyboardEvent): boolean {
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
return true;
}
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return true;
}
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
return true;
}
@@ -55,6 +77,15 @@
}
</script>
<ChatFormCommandPicker
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{commands}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
@@ -65,11 +96,13 @@
{onPromptLoadError}
/>
<ChatFormPickerMcpResources
bind:this={resourcePickerRef}
isOpen={isInlineResourcePickerOpen}
searchQuery={resourceSearchQuery}
onClose={onInlineResourcePickerClose}
onResourceSelect={onInlineResourceSelect}
onBrowse={onInlineResourceBrowse}
<ChatFormMentionPicker
bind:this={mentionPickerRef}
isOpen={isMentionPickerOpen ?? false}
query={mentionQuery ?? ''}
customAnchor={mentionAnchor}
scopePath={scopePath ?? null}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
onSelect={onMentionSelect ?? (() => {})}
/>
@@ -28,11 +28,10 @@
onMount(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
textareaElement.focus();
textareaElement.focus({ preventScroll: true });
}
});
// Expose the textarea element for external access
export function getElement() {
return textareaElement;
}
@@ -48,6 +47,17 @@
textareaElement.style.height = '1rem';
}
}
// Plain-text caret offsets, shared with the contenteditable variant so
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
}
export function setCaretOffset(offset: number) {
textareaElement?.setSelectionRange(offset, offset);
}
</script>
<div class="flex-1 {className}">
@@ -1,7 +1,5 @@
<script lang="ts">
import { FolderOpen } from '@lucide/svelte';
import { untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
@@ -10,23 +8,22 @@
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
rankEntries,
splitPathQuery,
runGlobSearchWithChildren,
type GlobEntry
} from '$lib/utils';
import { debounce } from '$lib/utils/debounce';
import * as Popover from '$lib/components/ui/popover';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
import {
DEFAULT_MOBILE_BREAKPOINT,
GLOB_WILDCARD,
HOME_TILDE,
MAX_RESULTS_SHOWN,
NATIVE_LIMIT,
NATIVE_MAX_DEPTH,
PATH_NAV_MAX_DEPTH,
SEARCH_DEBOUNCE_MS,
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
@@ -39,228 +36,147 @@
class?: string;
disabled?: boolean;
directory?: string | null;
/** Controlled open state; the host owns it so the chip click and the
* `/cwd` slash command open the picker through the same path. */
isOpen: boolean;
/** Two-way bound query, kept in sync with the text after `/cwd `. */
query: string;
/** Anchor at the form's top edge so the popover floats above the box. */
customAnchor?: HTMLElement | null;
onChange?: (directory: string | null) => void;
/**
* Lets the host refocus the chat input so typing can resume without
* an extra click after the popover closes.
*/
/** Lets the host refocus the chat input after the popover closes. */
onClose?: () => void;
/** Fired when the chip is clicked so the host can open the picker. */
onOpen?: () => void;
}
let {
class: className = '',
disabled = false,
directory = $bindable(null),
directory = null,
isOpen,
query = $bindable(''),
customAnchor = null,
onChange,
onClose
onClose,
onOpen
}: Props = $props();
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
// exposes a "Browse" button that opens the native folder picker. When unavailable the
// popover still works via the text input - no alerts, no upload semantics.
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
// exposes a "Browse" button only when available.
const pickerSupported =
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
// Popover open state; the element handles outside-click and Escape.
let isOpen = $state(false);
let inputValue = $state('');
let searchInputRef: HTMLInputElement | null = $state(null);
let queryResults = $state<string[]>([]);
let isSearching = $state(false);
let searchError = $state<string | null>(null);
let hoveredIndex = $state(-1);
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
// highlighted row into view only via this trigger, never on hover.
let scrollTrigger = $state(0);
let listContainer = $state<HTMLDivElement | null>(null);
// Absolute home directory on the server, resolved once per session by
// the tools store. Anchors both the search scope and the chip's `~`
// abbreviation.
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => queryResults.length,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
let homeBase = $derived(toolsStore.serverHome);
// AbortController + sequence counter to discard stale responses when the user
// keeps typing; a newer call aborts the previous one. The sequence counter
// also covers the gap between abort and the catch handler.
let searchController: AbortController | null = null;
let searchSeq = 0;
// Cache of the last file_glob_search result per (parent, include, max_depth),
// so repeated queries in the same directory don't re-walk the tree. Entering
// a directory hits it every time: the children listed for an exactly typed
// segment are what the next keystroke, the trailing slash, asks for again.
const SEARCH_CACHE_TTL_MS = 2000;
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
const runSearch = debounce((query: string) => {
void doSearch(query);
}, SEARCH_DEBOUNCE_MS);
// Resolve home eagerly on mount so the chip can abbreviate before the
// user opens the picker. resolveServerHome() is cached, so repeat calls
// (e.g. from handleOpenChange) are no-ops.
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
// Auto-focus the search input when the popover opens.
// HTML `autofocus` is unreliable on dynamically shown elements, so we
// use a microtask (0ms setTimeout) after the effect flushes.
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
let lastScrollTrigger: number | null = null;
// hoveredIndex/queryResults are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
$effect(() => {
if (scrollTrigger === lastScrollTrigger) return;
lastScrollTrigger = scrollTrigger;
untrack(() => {
if (!listContainer) return;
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
const selectedElement = listContainer.querySelector(
`[data-result-index="${hoveredIndex}"]`
) as HTMLElement | null;
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
if (!isOpen) return;
const q = query.trim();
nav.reset(-1);
if (q) {
search.run(q);
} else {
search.cancel();
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
}
});
function cancelSearch() {
searchController?.abort();
searchSeq++;
isSearching = false;
}
useScrollActiveRow({
getTrigger: () => nav.scrollTrigger,
getContainer: () => listContainer,
getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length,
dataIndex: 'result'
});
// Effective directory the current search runs against (shown in the
// footer); updated by doSearch, including when an exactly-typed
// directory is "entered".
let searchScope = $state(HOME_TILDE);
// Runs a directory listing through the cache, so a repeated query in the
// same directory does not re-walk the tree on the server.
async function searchDirs(
path: string,
include: string,
maxDepth: number,
signal: AbortSignal
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
const key = `${path}\u0000${include}\u0000${maxDepth}`;
const cached = searchCache.get(key);
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
signal
);
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
for (const [k, v] of searchCache) {
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
}
searchCache.set(key, { results: entries, base, at: now });
return { base, entries };
}
async function doSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) {
queryResults = [];
searchError = null;
isSearching = false;
hoveredIndex = -1;
searchScope = homeBase ?? HOME_TILDE;
return;
}
cancelSearch();
const controller = new AbortController();
searchController = controller;
const mySeq = ++searchSeq;
const pathQuery = splitPathQuery(trimmed);
isSearching = true;
try {
// A generous limit is requested because ranking happens
// client-side; only the top 20 are shown.
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
const include = pathQuery
? pathQuery.last
? buildCaseInsensitiveGlob(pathQuery.last)
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(trimmed);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
if (mySeq !== searchSeq) return;
if (res.error) {
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
if (!trimmed) {
queryResults = [];
hoveredIndex = -1;
searchError = res.error;
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
return;
}
const { base, entries } = res;
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
let results = ranked.map((e) => joinPath(base, e.path));
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
// An exactly-typed directory is "entered": list its children too,
// so path navigation doesn't require a trailing slash.
const last = pathQuery?.last;
const exact = last
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
: undefined;
if (exact) {
const exactDir = joinPath(base, exact.path);
const childRes = await searchDirs(
exactDir,
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
controller.signal
try {
// Generous limit: ranking is client-side, only the top
// MAX_RESULTS_SHOWN are shown.
const res = await runGlobSearchWithChildren(
trimmed,
homeBase ?? HOME_TILDE,
SEARCH_MAX_DEPTH,
SEARCH_LIMIT,
signal,
{ type: GlobSearchType.DIR }
);
if (mySeq !== searchSeq) return;
if (!childRes.error) {
const children = childRes.entries
.map((e) => joinPath(childRes.base, e.path))
.sort((a, b) => a.localeCompare(b));
results = [...results, ...children];
searchScope = exactDir;
if (!isCurrent()) return;
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
}
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
hoveredIndex = queryResults.length > 0 ? 0 : -1;
// new results: scroll the list back to the top (first item is hovered)
if (hoveredIndex === 0) scrollTrigger++;
searchError = null;
} catch (err) {
if (mySeq !== searchSeq) return;
queryResults = [];
hoveredIndex = -1;
if (controller.signal.aborted) return;
searchError = err instanceof Error ? err.message : String(err);
} finally {
if (mySeq === searchSeq) isSearching = false;
}
}
// Single funnel for every local close so the host refocus fires
// regardless of which commit/dismiss path ended the interaction.
});
// Single funnel for every local close so the host refocus always fires.
function closePicker() {
isOpen = false;
onClose?.();
}
function commit(path: string) {
directory = path;
onChange?.(path);
closePicker();
}
@@ -268,15 +184,12 @@
function setDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
directory = trimmed;
onChange?.(trimmed);
}
// Resolve a folder name picked via the browser-native picker (which exposes
// only the leaf name) to a server-side absolute path. Returns null when the
// server cannot locate a matching directory, so the caller can fail visibly
// instead of committing a bare leaf name that would resolve against the
// server process working directory.
// Resolve a browser-picked folder name (which exposes only the leaf name)
// to a server-side absolute path; null when the server cannot locate it,
// so the caller fails visibly instead of committing a bare leaf name.
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
@@ -318,7 +231,7 @@
}
function handleSubmit() {
const value = inputValue.trim();
const value = query.trim();
if (!value) {
closePicker();
return;
@@ -330,47 +243,33 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
// Commit the highlighted result, falling back to the raw input
// only when the query returned no matches.
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
commit(queryResults[hoveredIndex]);
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
handleSubmit();
}
} else if (event.key === KeyboardKey.ARROW_DOWN) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
scrollTrigger++;
nav.move(1);
}
} else if (event.key === KeyboardKey.ARROW_UP) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
scrollTrigger++;
nav.move(-1);
}
}
}
function handleInputInput(value: string) {
hoveredIndex = -1;
if (value.trim().length > 0) {
runSearch(value);
}
}
function clearDirectory(event?: MouseEvent) {
// Stop the click from bubbling into the popover trigger and re-opening
// Stop the click from bubbling into the chip button and re-opening
// the picker on top of the now-cleared state.
event?.stopPropagation();
event?.preventDefault();
directory = null;
onChange?.(null);
closePicker();
}
// The chip is always visible; the X clears the directory (no-op when
// already empty).
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
@@ -380,105 +279,104 @@
}
function handleOpenChange(open: boolean) {
isOpen = open;
if (open) {
// Seed the search field with the current path so the user can refine it
// (or hit Enter to confirm / clear via the X icon).
inputValue = directory ?? '';
hoveredIndex = -1;
queryResults = [];
searchError = null;
void toolsStore.resolveServerHome();
searchScope = homeBase ?? HOME_TILDE;
if (inputValue.trim()) runSearch(inputValue);
} else {
cancelSearch();
// bits-ui-initiated close (Escape on the content, outside-click,
// trigger toggle) - the only path that bypasses closePicker().
search.cancel();
// bits-ui-initiated close (Escape on the content, outside-click) -
// the only path that bypasses closePicker().
onClose?.();
}
}
// Tooltips only on wider viewports - hover surfaces get in the way on
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
let innerWidth = $state(0);
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
</script>
<div
<button
type="button"
class={[
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
className,
isOpen && 'w-full'
className
]}
onclick={onOpen}
{disabled}
>
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger {disabled} class="flex justify-start">
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
/>
</button>
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open working directory picker</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
>
<div class="p-2 min-h-22 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={query}
placeholder="Choose working directory"
onClose={closePicker}
class="w-full"
/>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={4}
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class="p-2 min-h-28 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={inputValue}
placeholder="Choose working directory"
onInput={handleInputInput}
onClose={closePicker}
class="w-full"
{#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
error={searchError}
rawQuery={query}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
/>
{/if}
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
{hoveredIndex}
{isSearching}
error={searchError}
rawQuery={inputValue}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => (hoveredIndex = index)}
/>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
{#if homeBase}
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase}
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-2 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
</div>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
<svelte:window bind:innerWidth />
@@ -1,6 +1,7 @@
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
import { abbreviateWorkingDir } from '$lib/utils';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions';
@@ -21,7 +22,7 @@
}: Props = $props();
const displayLabel = $derived(
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
);
// Full path surface: hover the abbreviated label to recall the exact directory.
const displayLabelTitle = $derived(directory ?? '');
@@ -183,8 +183,8 @@
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
{/if}
<div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel}
{#if displayedModel}
<div class="info my-6 grid gap-4 tabular-nums">
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
@@ -200,8 +200,8 @@
showMessageStats={currentConfig.showMessageStats}
/>
</div>
{/if}
</div>
</div>
{/if}
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
@@ -164,7 +164,7 @@
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
@@ -98,9 +98,10 @@
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
);
// No subtitle while the call is in flight - the spinner already
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (extraLiveStreaming) return 'streaming...';
if (showSpinner) return 'executing...';
if (showSpinner) return undefined;
if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined;
@@ -63,7 +63,7 @@
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content" {content} />
</div>
@@ -41,7 +41,6 @@
let expandedStates: Record<number, boolean> = $state({});
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
const showMessageStats = $derived(Boolean(config().showMessageStats));
@@ -186,7 +185,6 @@
{section}
open={isExpanded(index, section)}
{isStreaming}
{renderThinkingAsMarkdown}
{hasReasoningError}
attachments={message?.extra}
onToggle={() => toggleExpanded(index, section)}
@@ -3,6 +3,7 @@
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
@@ -10,7 +11,6 @@
section: AgenticSection;
open: boolean;
isStreaming: boolean;
renderThinkingAsMarkdown: boolean;
hasReasoningError?: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
@@ -20,12 +20,13 @@
section,
open,
isStreaming,
renderThinkingAsMarkdown,
hasReasoningError = false,
attachments,
onToggle
}: Props = $props();
const currentConfig = config();
const REASONING_HEADER = 'Reasoning';
const REASONING_HEADER_PENDING = 'Reasoning...';
const REASONING_SUBTITLE_ERROR = 'Error';
@@ -128,7 +129,7 @@
class:is-streaming={isPending}
onscroll={handleScrollEvent}
>
{#if renderThinkingAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
{:else}
<div
+28 -27
View File
@@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
* - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
/**
* Auto-resizing textarea with IME composition support. Automatically adjusts
* height based on content. Handles IME input correctly (waits for composition
* end before processing Enter key). Exposes focus() and resetHeight() methods.
* Auto-resizing contenteditable input that renders `[name](file://...)`
* mention links as inline chips while keeping the value as the markdown
* source string. ChatForm swaps it in once a mention link lands in the
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
*/
export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
/**
* Plain auto-resizing textarea with IME composition support. Default input
* renderer inside ChatForm until a file mention lands.
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
@@ -351,14 +359,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
* Generic scrollable list for picker popovers. Provides search input,
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
/**
* Generic button wrapper for picker list items. Provides consistent styling,
* hover/selected states, and data-picker-index attribute for scroll-into-view.
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
*/
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
@@ -376,30 +384,23 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
/**
* **ChatFormPickerMcpResources** - MCP resource selection interface
*
* Floating picker for browsing and attaching MCP Server Resources.
* Triggered by typing `@` in the chat input.
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
*
* **Features:**
* - Search/filter resources by name, title, description, or URI across all connected servers
* - Keyboard navigation (/ to navigate, Enter to select, Esc to close)
* - Shows attached state for already-attached resources
* - Loading states with skeleton placeholders
* - Server information header per resource for visual identification
*
* **Exported API:**
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
* input to a filesystem match via the server's `file_glob_search` built-in
* tool, scoped to the conversation cwd (or server home when unset).
* Selection splices a `[name](file:///<abs path>)` link into the input.
*/
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* Container component that hosts both MCP prompt and MCP resource pickers.
* Manages shared state, keyboard navigation, and coordination between the two
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
* `/`-triggered slash-command picker. Lists the available slash commands
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
* hands the command to the parent for dispatch.
*/
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
/**
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
* and delegates keyboard events to the active one.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
@@ -15,6 +15,7 @@
import { SvelteMap } from 'svelte/reactivity';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeFileBadge } from './plugins/rehype/file-badge';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
@@ -33,7 +34,8 @@
preprocessLaTeX,
getImageErrorFallbackHtml,
copyCodeToClipboard,
copyToClipboard
copyToClipboard,
splitGluedClosingCodeFences
} from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
@@ -174,6 +176,7 @@
}) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
@@ -340,7 +343,11 @@
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
* @param markdown - The raw markdown string to process
*/
async function processMarkdown(markdown: string) {
async function processMarkdown(rawMarkdown: string) {
// Text glued to a closing code fence is not a fence to the parser -
// the block would swallow it. Split it onto its own line first.
const markdown = splitGluedClosingCodeFences(rawMarkdown);
// Early exit if content unchanged (can happen with rapid coalescing)
if (markdown === previousContent) {
return;
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
/* Code blocks */
.markdown-content :global(.code-block-wrapper) {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
@@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) {
max-height: var(--max-message-height);
}
.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
margin-top: 1rem;
}
.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
margin-bottom: 1rem;
}
.markdown-content:global(.dark) :global(.code-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
@@ -0,0 +1,100 @@
/**
* Rehype plugin that rewrites `file://` markdown anchors into the inline
* mention chip, sharing the class string with the contenteditable
* tokenizer via `$lib/constants/mention-badge`.
*
* The chip is presentational: `file://` navigation is blocked from
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
* no tab stop); the full path stays available on `title`.
*/
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import {
FILE_URI_PREFIX,
MENTION_BADGE_CLASSNAME,
MENTION_BADGE_ICON_CLASSNAME,
MENTION_BADGE_SVG_ATTRIBUTES,
PATH_SEPARATOR,
SETTINGS_KEYS
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
// Trailing path separators mark a directory and are kept out of the label.
const TRAILING_SEPARATOR_REGEX = /\/+$/;
function decodeHrefPath(href: string): string {
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
return decodeFileLinkPath(stripped);
}
function labelFromFileUrl(href: string): string {
const decoded = decodeHrefPath(href);
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
// A trailing `/` in the target marks a directory and selects the folder
// icon, matching the convention the mention picker inserts with.
function iconElement(href: string): Element {
return {
type: 'element',
tagName: 'svg',
properties: {
...MENTION_BADGE_SVG_ATTRIBUTES,
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
},
children: getMentionBadgeIconPaths(href).map((d) => ({
type: 'element',
tagName: 'path',
properties: { d },
children: []
}))
};
}
export const rehypeFileBadge: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName !== 'a') return;
const props = node.properties ?? {};
const href = typeof props.href === 'string' ? props.href : null;
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
const label = labelFromFileUrl(href);
const titleAttr = typeof props.title === 'string' ? props.title : href;
const decodedPath = decodeHrefPath(href);
node.tagName = 'span';
node.properties = {
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
};
node.children = [
iconElement(href),
{
type: 'element',
tagName: 'span',
properties: { className: ['shrink-0', 'truncate'] },
children: [
{
type: 'text',
value: getMentionBadgeLabel(
label,
decodedPath,
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
toolsStore.serverHome
)
}
]
}
];
});
};
};
@@ -1,5 +1,6 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { URL_PARAMS } from '$lib/constants';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
import { goto } from '$app/navigation';
@@ -22,7 +23,7 @@
function handleSelectModel(model: string) {
// Build URL with selected model, preserving other params
const url = new URL(page.url);
url.searchParams.set('model', model);
url.searchParams.set(URL_PARAMS.MODEL, model);
handleOpenChange(false);
goto(url.toString());
@@ -0,0 +1,25 @@
<script lang="ts">
import { highlightMatch } from '$lib/utils';
interface Props {
text: string;
query: string;
matchClass?: string;
}
let {
text,
query,
matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
}: Props = $props();
let segments = $derived(highlightMatch(text, query));
</script>
{#each segments as seg, i (i)}
{#if seg.match}
<mark class={matchClass}>{seg.text}</mark>
{:else}
{seg.text}
{/if}
{/each}
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
* Supports placeholder, autofocus, and change callbacks.
*/
export { default as SearchInput } from './SearchInput.svelte';
/**
* **HighlightedMatch** - Substring-match text highlight
*
* Renders `text` with each case-insensitive occurrence of `query` wrapped
* in `<mark>`.
*/
export { default as HighlightedMatch } from './HighlightedMatch.svelte';
@@ -1,8 +1,9 @@
<script lang="ts">
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
@@ -35,7 +36,7 @@
}: Props = $props();
let isOpen = $state(false);
let highlightedIndex = $state<number>(-1);
let highlightedId = $state<string | null>(null);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -43,15 +44,77 @@
onModelChange: () => onModelChange,
onOpenChange: (open) => {
isOpen = open;
highlightedIndex = -1;
highlightedId = null;
}
});
$effect(() => {
void ms.searchTerm;
highlightedIndex = -1;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
$effect(() => {
if (!isOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
);
search?.focus({ preventScroll: true });
});
});
// Keyboard navigation follows the on-screen row order, not the flat option list order.
let visualOrder = $derived.by(() => {
const order: string[] = [];
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
return order;
});
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
function moveHighlight(direction: 1 | -1) {
const len = visualOrder.length;
if (len === 0) {
highlightedId = null;
return;
}
let index = highlightedIndex;
if (index === -1) {
index = direction === 1 ? 0 : len - 1;
} else {
index = (index + direction + len) % len;
}
highlightedId = visualOrder[index];
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
void ms.handleSelect(modelId);
return;
}
const model = routerModels().find((m) => m.id === modelId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.unloadModel(modelId);
}
export function open() {
ms.handleOpenChange(true);
}
@@ -61,33 +124,17 @@
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
highlightedIndex = 0;
} else {
highlightedIndex += 1;
}
moveHighlight(1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === 0) {
highlightedIndex = ms.filteredOptions.length - 1;
} else {
highlightedIndex -= 1;
}
moveHighlight(-1);
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
const option = ms.filteredOptions[highlightedIndex];
ms.handleSelect(option.id);
} else if (ms.filteredOptions.length > 0) {
highlightedIndex = 0;
if (highlightedId) {
void handleModelKeyAction(highlightedId, event.altKey);
} else if (visualOrder.length > 0) {
highlightedId = visualOrder[0];
}
}
}
@@ -109,7 +156,7 @@
]}
style="max-width: min(calc(100cqw - 10rem), 20rem)"
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
@@ -150,7 +197,7 @@
]}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
@@ -186,6 +233,7 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
searchValue={ms.searchTerm}
@@ -217,9 +265,9 @@
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option, flatIndex } = item}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = flatIndex === highlightedIndex}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
@@ -230,11 +278,11 @@
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedIndex = flatIndex)}
onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
ms.handleSelect(option.id);
void handleModelKeyAction(option.id, event.altKey);
}
}}
/>
@@ -275,7 +323,7 @@
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
@@ -62,9 +62,10 @@
<div
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground',
isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
role="option"
@@ -98,7 +98,12 @@
const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) {
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
processedConfig[field] = Math.max(1, Math.round(numValue));
const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
(section) => section.fields ?? []
).find((entry) => entry.key === field);
const lo = entryByMinMax?.min ?? 1;
const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
} else {
processedConfig[field] = numValue;
}
@@ -83,12 +83,18 @@
<Input
id={field.key}
type={field.isPositiveInteger ? 'number' : 'text'}
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
{...field.isPositiveInteger
? {
min: String(field.min ?? 1),
step: '1',
...(field.max != null ? { max: String(field.max) } : {})
}
: {}}
value={currentValue}
oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={currentModelParams[field.key] != null
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
: ''}
: (field.placeholder ?? '')}
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
/>
{#if isCustomRealTime}
@@ -0,0 +1,44 @@
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
interface ChatCommandsOptions {
/** Gates `/model`. */
showModelSelector: boolean;
/** Gates `/prompt`. */
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasCwdTools: () => boolean;
}
/**
* The slash commands surfaced by the `/` command picker, in display order.
*
* Availability is supplied as predicates rather than store imports: this
* module is re-exported through the `$lib/constants` barrel, and importing
* stores at module load would create a circular dependency (the stores
* themselves import from `$lib/constants`).
*/
export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
return [
{
name: 'prompt',
description: 'Insert an MCP prompt',
action: ChatFormCommandAction.PROMPT,
disabled: !options.hasPrompts()
},
{
name: 'cwd',
description: SET_WORKING_DIRECTORY_LABEL,
keywords: ['current working directory'],
action: ChatFormCommandAction.CWD,
disabled: !options.hasCwdTools()
},
{
name: 'model',
description: 'Select model',
action: ChatFormCommandAction.MODEL,
disabled: !options.showModelSelector
}
];
}
-1
View File
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const RESOURCE_TRIGGER_PREFIX = '@';
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
@@ -19,6 +19,10 @@ export const PANEL_CLASSES = `
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
export const DIALOG_SUBMENU_CONTENT = 'w-60';
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
+2
View File
@@ -18,6 +18,7 @@ export * from './binary-detection';
export * from './built-in-tools';
export * from './cache';
export * from './chat-form';
export * from './chat-commands';
export * from './cli-flags';
export * from './code-blocks';
export * from './icons';
@@ -39,6 +40,7 @@ export * from './max-bundle-size';
export * from './mcp';
export * from './mcp-form';
export * from './mcp-resource';
export * from './mention-badge';
export * from './message-export';
export * from './path-display';
export * from './model-id';
@@ -0,0 +1,42 @@
/**
* Shared visual contract between the two DOM-only badge paths (the
* contenteditable tokenizer + the rehype plugin). Svelte cannot be
* mounted at the per-keystroke tokenizer hot path nor from a hast tree,
* so both emit the badge with the same class string literal; Tailwind's
* scanner picks it up in both sources.
*/
export const MENTION_BADGE_CLASSNAME =
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
/**
* SVG attributes shared by the DOM-built and hast-built badge icons.
* The tokenizer applies them via `setAttribute`, the rehype plugin
* spreads them onto the hast `<svg>` `properties`; string values are
* valid for both.
*/
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true'
};
/**
* SVG path strings for the badge's inline icon; each entry becomes one
* `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
* current `File` and `Folder` glyphs.
*/
export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
'M14 2v5a1 1 0 0 0 1 1h5'
];
export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
];
+12 -2
View File
@@ -1,4 +1,14 @@
export const NEW_CHAT_PARAM = 'new_chat';
/** Query params the chat routes read from the URL. */
export const URL_PARAMS = {
/** Prompt to send on arrival. */
QUERY: 'q',
/** Model to select. */
MODEL: 'model',
/** Load the selected model instead of waiting for the first message. */
LOAD: 'load',
/** Start a new chat. */
NEW_CHAT: 'new_chat'
} as const;
/** Settings section slugs — used for routes and navigation. */
export const SETTINGS_SECTION_SLUGS = {
@@ -16,7 +26,7 @@ export const ROUTES = {
/** Root — start of the app. */
START: '#/',
/** New chat — root with new chat query param. */
NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`,
NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
/** Chat base — for dynamic chat URLs use RouterService. */
CHAT: '#/chat',
/** MCP servers. */
+3 -2
View File
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
@@ -31,8 +31,9 @@ export const SETTINGS_KEYS = {
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_BUILD_VERSION: 'showBuildVersion',
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',
+32 -12
View File
@@ -23,7 +23,12 @@ import type {
SettingsSectionEntry,
SettingsSection
} from '$lib/types';
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
import { CLI_FLAGS } from './cli-flags';
import { DEFAULT_MCP_CONFIG } from './mcp';
import {
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
} from './working-directory';
import { SETTINGS_KEYS } from './settings-keys';
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
import { TITLE_GENERATION } from './title-generation';
@@ -228,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
help: 'Render user messages using markdown formatting in the chat.',
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
label: 'Render content as raw text',
help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
label: 'Render thinking as Markdown',
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
@@ -298,6 +295,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
label: 'Show full path in mentions',
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
}
]
},
@@ -555,6 +560,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true
},
{
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
label: 'Mention search depth',
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
min: 1,
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
type: SettingsFieldType.INPUT,
section: SETTINGS_SECTION_SLUGS.AGENTIC,
isPositiveInteger: true
}
]
},
@@ -699,6 +716,9 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
type: s.type,
isExperimental: s.isExperimental,
isPositiveInteger: s.isPositiveInteger,
placeholder: s.placeholder,
min: s.min,
max: s.max,
dependsOn: s.dependsOn,
help: s.help,
options: s.options,
+4 -1
View File
@@ -1,4 +1,4 @@
import { Search, Settings, SquarePen } from '@lucide/svelte';
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
import { ROUTES } from './routes';
@@ -6,6 +6,9 @@ import { ROUTES } from './routes';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
@@ -8,6 +8,9 @@
export const GLOB_WILDCARD = '*';
/** Label shown for the working-directory picker / `/cwd` slash command. */
export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
/** Character that starts and ends a glob character-class fragment. */
export const GLOB_RANGE_OPEN = '[';
export const GLOB_RANGE_CLOSE = ']';
@@ -38,3 +41,9 @@ export const PATH_NAV_MAX_DEPTH = 1;
// Native folder-picker resolution searches a shallow, bounded window.
export const NATIVE_MAX_DEPTH = 4;
export const NATIVE_LIMIT = 20;
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
/** Depth the pickers fall back to when the user setting is invalid. */
export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
+11
View File
@@ -78,3 +78,14 @@ export enum PdfViewMode {
TEXT = 'text',
PAGES = 'pages'
}
export enum ChatFormCommandAction {
PROMPT = 'prompt',
CWD = 'cwd',
MODEL = 'model'
}
export enum FileMentionEntryType {
FILE = 'file',
DIRECTORY = 'directory'
}
+3 -1
View File
@@ -24,7 +24,9 @@ export {
MessageRole,
MessageType,
PdfViewMode,
ReasoningFormat
ReasoningFormat,
ChatFormCommandAction,
FileMentionEntryType
} from './chat.enums';
export { SessionRecordType } from './conversation-import.enums';
@@ -0,0 +1,355 @@
import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
findCommandToken,
findMentionToken,
takeCommandDismissSnapshot,
takeMentionDismissSnapshot,
type CommandDismissSnapshot,
type MentionDismissSnapshot
} from '$lib/utils';
/** Dependencies injected as getters so the hook stays free of store circular imports. */
export interface UseChatFormPickersOptions {
getValue: () => string;
/** Also fires the form's onChange. */
setValue: (value: string) => void;
/** Undefined when unmounted. */
getCaretOffset: () => number | undefined;
setCaretOffset: (offset: number) => void;
focusInput: () => void;
/** Gates `/model`. */
getShowModelSelector: () => boolean;
/** Gates `/prompt`. */
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasCwdTools: () => boolean;
getCwd: () => string | null;
/** Mention search fallback scope. */
getServerHome: () => string | null;
openModelSelector: () => void;
/** Delegate a keydown to the mounted pickers component, if any. */
getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
}
/**
* Chat-form picker state and the `/`+`@` routing that drives them.
* Owns open/query state, dismiss snapshots and slash-command dispatch;
* textarea/caret/attachment handling stays in the chat form.
*/
export function useChatFormPickers(opts: UseChatFormPickersOptions) {
let isCommandPickerOpen = $state(false);
let commandQuery = $state('');
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isMentionPickerOpen = $state(false);
let mentionQuery = $state('');
let isWorkingDirectoryPickerOpen = $state(false);
let workingDirectoryQuery = $state('');
// Last dismissed `@`-mention token; while intact, the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
// Same dismissal contract for the `/`-command token.
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
// Fall back to the server home so the picker still finds matches
// before a cwd is set.
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
const availableCommands = $derived(
getChatCommands({
showModelSelector: opts.getShowModelSelector(),
hasPrompts: opts.hasPrompts,
hasCwdTools: opts.hasCwdTools
})
);
// Dispatch a slash command picked from the list: consume the token and
// open the target picker, seeding its search with `args`. Runs only on
// explicit selection (Enter/click), so the buffer is never cleared
// mid-typing.
function dispatchCommand(command: ChatFormCommand, args: string) {
isCommandPickerOpen = false;
commandQuery = '';
switch (command.action) {
case ChatFormCommandAction.PROMPT:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
isPromptPickerOpen = true;
promptSearchQuery = args.trim();
break;
case ChatFormCommandAction.CWD: {
// Keep `/cwd <args>` in the input so the search field and the
// token stay two-way bound; normalize partial tokens (`/cw foo`).
const trimmed = args.trim();
const newValue = `/cwd ${trimmed}`;
if (opts.getValue() !== newValue) {
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
}
workingDirectoryQuery = trimmed;
isWorkingDirectoryPickerOpen = true;
break;
}
case ChatFormCommandAction.MODEL:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
opts.openModelSelector();
break;
}
}
function handleInput() {
const value = opts.getValue();
const cursor = opts.getCaretOffset() ?? value.length;
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
isMentionPickerOpen = false;
mentionQuery = '';
isPromptPickerOpen = false;
promptSearchQuery = '';
const token = findCommandToken(value);
if (!token) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// While the `/cwd` picker is open the token doubles as its search
// field: keep the two in sync instead of re-dispatching.
if (isWorkingDirectoryPickerOpen) {
isCommandPickerOpen = false;
commandQuery = '';
if (token.name === 'cwd') {
workingDirectoryQuery = token.args.trim();
} else {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
}
return;
}
// Dismissed token stays literal until it changes.
const isDismissedSticky =
commandDismissedSnapshot !== null &&
commandDismissedSnapshot.name === token.name &&
commandDismissedSnapshot.args === token.args;
if (isDismissedSticky) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// Commands dispatch only on explicit selection (Enter/click),
// never mid-typing: `/model is broken` is prose until the user
// picks the command from the list.
if (availableCommands.length > 0) {
isCommandPickerOpen = true;
commandQuery = token.name;
} else {
isCommandPickerOpen = false;
commandQuery = '';
}
return;
}
isCommandPickerOpen = false;
commandQuery = '';
if (commandDismissedSnapshot !== null) {
commandDismissedSnapshot = null;
}
if (isWorkingDirectoryPickerOpen) {
isWorkingDirectoryPickerOpen = false;
}
const token = findMentionToken(value, cursor);
if (token) {
// Dismissed token stays literal: don't reopen until it changes.
const isDismissedSticky =
mentionDismissedSnapshot !== null &&
mentionDismissedSnapshot.start === token.start &&
mentionDismissedSnapshot.query === token.query;
if (!isDismissedSticky) {
// Only search once a char follows `@`; a bare `@` is a no-op
// (otherwise the picker flashes an empty hint on re-type).
if (token.query.length > 0) {
mentionDismissedSnapshot = null;
isMentionPickerOpen = true;
mentionQuery = token.query;
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
}
}
isPromptPickerOpen = false;
promptSearchQuery = '';
isMentionPickerOpen = false;
mentionQuery = '';
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
if (mentionDismissedSnapshot !== null && !token) {
mentionDismissedSnapshot = null;
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (opts.getPickersRef()?.handleKeydown(event)) {
return true;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return true;
}
return false;
}
function handleCommandSelect(command: ChatFormCommand) {
// Dispatch on the live token so typed args seed the target picker.
const token = findCommandToken(opts.getValue());
dispatchCommand(command, token?.args ?? '');
}
// Picker dismissed: snapshot the live token so it stays literal until
// deleted or retyped.
function handleCommandPickerClose() {
if (isCommandPickerOpen) {
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
}
isCommandPickerOpen = false;
commandQuery = '';
// Target picker manages its own focus: don't yank it back to the input.
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
opts.focusInput();
}
}
// Same dismissal snapshot for the mention token.
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
opts.focusInput();
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
opts.focusInput();
}
function handleWorkingDirectoryOpen() {
workingDirectoryQuery = opts.getCwd() ?? '';
isWorkingDirectoryPickerOpen = true;
}
function handleWorkingDirectoryClose() {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
opts.focusInput();
}
// Two-way bind the text after `/cwd ` and the picker search input; the
// reverse direction is handled by handleInput.
$effect(() => {
if (!isWorkingDirectoryPickerOpen) return;
const value = opts.getValue();
const token = findCommandToken(value);
if (!token || token.name !== 'cwd') return;
const newValue = `/cwd ${workingDirectoryQuery}`;
if (newValue === value) return;
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
});
return {
get isCommandPickerOpen() {
return isCommandPickerOpen;
},
set isCommandPickerOpen(v: boolean) {
isCommandPickerOpen = v;
},
get commandQuery() {
return commandQuery;
},
set commandQuery(v: string) {
commandQuery = v;
},
get isPromptPickerOpen() {
return isPromptPickerOpen;
},
set isPromptPickerOpen(v: boolean) {
isPromptPickerOpen = v;
},
get promptSearchQuery() {
return promptSearchQuery;
},
set promptSearchQuery(v: string) {
promptSearchQuery = v;
},
get isMentionPickerOpen() {
return isMentionPickerOpen;
},
set isMentionPickerOpen(v: boolean) {
isMentionPickerOpen = v;
},
get mentionQuery() {
return mentionQuery;
},
set mentionQuery(v: string) {
mentionQuery = v;
},
get isWorkingDirectoryPickerOpen() {
return isWorkingDirectoryPickerOpen;
},
set isWorkingDirectoryPickerOpen(v: boolean) {
isWorkingDirectoryPickerOpen = v;
},
get workingDirectoryQuery() {
return workingDirectoryQuery;
},
set workingDirectoryQuery(v: string) {
workingDirectoryQuery = v;
},
get availableCommands() {
return availableCommands;
},
get mentionScopePath() {
return mentionScopePath;
},
handleInput,
// True when a picker consumed the event, so the form skips submit.
handleKeydown,
dispatchCommand,
handleCommandSelect,
handleCommandPickerClose,
handleMentionPickerClose,
handlePromptPickerClose,
handleWorkingDirectoryOpen,
handleWorkingDirectoryClose,
openPromptPicker() {
isPromptPickerOpen = true;
},
closePromptPicker() {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
};
}
export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
@@ -0,0 +1,67 @@
import { debounce } from '$lib/utils/debounce';
/**
* Shared debounced async-search machinery for the chat-form pickers:
* AbortController + sequence counter to discard stale responses, a
* debounce, and a live `isSearching` flag.
*/
export interface UseDebouncedSearchOptions {
debounceMs: number;
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
canRun: () => boolean;
/** Live query, used to drop a scheduled call whose query changed. */
getQuery: () => string;
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
}
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
let controller: AbortController | null = null;
let searchSeq = 0;
let isSearching = $state(false);
function isCurrent(seq: number) {
return seq === searchSeq;
}
function cancel() {
controller?.abort();
searchSeq++;
isSearching = false;
}
const schedule = debounce((query: string) => {
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
void start(query);
}, opts.debounceMs);
async function start(query: string) {
cancel();
const fresh = new AbortController();
controller = fresh;
const mySeq = ++searchSeq;
isSearching = true;
try {
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
} finally {
if (isCurrent(mySeq)) isSearching = false;
}
}
return {
get isSearching() {
return isSearching;
},
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
setLoading(value: boolean) {
isSearching = value;
},
run(query: string) {
schedule(query);
},
cancel
};
}
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
@@ -8,6 +8,7 @@ import {
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
@@ -139,11 +140,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
textarea?.focus({ preventScroll: true });
input?.focus({ preventScroll: true });
});
}
@@ -0,0 +1,108 @@
import { KeyboardKey } from '$lib/enums';
/**
* Shared keyboard navigation state for the chat-form pickers: a highlighted
* row, a scroll trigger, and Arrow/Escape/Enter handling.
*/
export interface UsePickerNavigationOptions {
/** Gates all key handling. */
isOpen: () => boolean;
count: () => number;
/**
* Resolve the row to highlight for a movement step, or -1 when no move
* is possible. Defaults to plain wraparound across `count()`.
*/
step?: (from: number, dir: 1 | -1) => number;
onClose: () => void;
/** Called on Enter when `hoveredIndex` points at a selectable row. */
onSelect: (index: number) => void;
}
function wrapStep(from: number, dir: 1 | -1, count: number): number {
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
}
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
let hoveredIndex = $state(-1);
let scrollTrigger = $state(0);
function resolve(from: number, dir: 1 | -1): number {
const n = opts.count();
if (n === 0) return -1;
if (opts.step) return opts.step(from, dir);
return wrapStep(from, dir, n);
}
function move(dir: 1 | -1) {
const next = resolve(hoveredIndex, dir);
if (next >= 0) {
hoveredIndex = next;
scrollTrigger++;
}
}
/** Reset the highlight without bumping the scroll trigger. */
function reset(index: number) {
hoveredIndex = index;
}
/** Bump the scroll trigger without moving the highlight. */
function bumpScroll() {
scrollTrigger++;
}
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
function setHover(index: number) {
hoveredIndex = index;
}
function handleKeydown(event: KeyboardEvent): boolean {
if (!opts.isOpen()) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
opts.onClose();
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
move(1);
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
move(-1);
return true;
}
if (event.key === KeyboardKey.ENTER) {
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
event.preventDefault();
opts.onSelect(hoveredIndex);
return true;
}
// No selectable row - let the caller's Enter-to-submit run.
return false;
}
return false;
}
return {
get hoveredIndex() {
return hoveredIndex;
},
get scrollTrigger() {
return scrollTrigger;
},
reset,
setHover,
move,
bumpScroll,
handleKeydown
};
}
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;

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