Compare commits

...
11 Commits
Author SHA1 Message Date
Aman GuptaandGitHub af5172627d RPC: populate use_count to enable fusion inside backends (#27142)
* RPC: populate use_count to enable fusion inside backends

* bump minor version
2026-08-18 21:08:57 +05:30
Aleksander GrygierandGitHub 3dc7285b4f ui: Services consolidation refactor (#27239)
* ui: Move stream lookup and replay fetches into ChatService

chatStore called fetch() directly for /v1/streams/lookup and the
/v1/stream replay. These now live next to the other stream-session
methods in ChatService, so services stay the only API I/O layer.

* ui: Move /models/sse feed reader into ModelsService

ModelsService.watchModelEvents owns the byte stream, reconnect loop
and SSE record parsing; modelsStore keeps only event routing and
state.

* ui: Extract conversation import/export into ConversationTransferService

The JSONL session format, ZIP archiving and browser downloads are
pure I/O with no store state, so they move out of
conversationsStore. The store keeps the DB orchestration
(bulkExportConversations, downloadConversation,
importConversationsData) and delegates the format work.

* ui: Consolidate active model resolution into modelsStore.activeModelId

The same resolution chain was duplicated in useChatScreenActiveModel,
ChatForm, ChatFormActionModels and contextStatsStore, with slight
drift in the single-model fallback. The canonical getter now lives in
modelsStore, and the shared last-assistant-model lookup moved to
utils as getConversationModel.

* ui: Initialize stores explicitly via initStores()

Store constructors and module-level side effects ran migrations and
localStorage reads in import order. Migrations rename and rewrite
localStorage keys, so a settings load racing ahead of them could
clobber migrated values. initStores() is called once from the root
layout and runs migrations first, then the stores that read
localStorage, then the conversations DB load.

* refactor: Constants for stream query params
2026-08-18 16:39:32 +02:00
Aleksander GrygierandGitHub fdf4c64604 ui: Stores consolidation refactor (#27238)
* ui: Remove dead code from stores

- persisted() helper was exported but never used
- messageUpdateCallback / registerMessageUpdateCallback were never wired up
- conversationsStore.initialize() alias, single caller moved to init()

* ui: Merge device, theme and viewport into a single deviceStore

All three are reactive browser-environment signals, now exposed as one
class store: deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari
/ isWKWebView / isStandalone and deviceStore.systemTheme.isDark. The
systemTheme name disambiguates the OS preference from the user theme
preference in settingsStore. Drops the unused viewport export (only
isMobile was consumed).

* ui: Merge build info into version store

One VersionStore class with build (llama.cpp build number from
build.json) and frontend (PWA version from _app/version.json),
matching the class pattern of the other stores.

* ui: Colocate context gauge popup state with its components

The gauge popup state is local UI state shared only by the
ChatFormContextGauge subtree, so it lives next to its consumers
instead of the app-scope stores barrel.
2026-08-18 16:37:26 +02:00
Niklas WenzelandGitHub 04b569142d common: share thread pools when n_threads differ (#27138) 2026-08-18 16:23:43 +02:00
Ed AddarioandGitHub 0596704284 quant : Optimise memory usage by evicting weights after processing each layer (#22877)
* Evict weights from memory after processing each layer

* Revert changes

* Move unmap to libllama

* Unmap weights offloaded to backend

* Change member's constness

* Remove unmap weights offloaded to backend
2026-08-18 16:22:32 +02:00
TitaniumtownandGitHub 0882c7bc89 sycl: honor GGML_HINT_SRC0_IS_HADAMARD (#27298)
Kernel is a port of `ggml-cuda/fwht.cu`

(us/run, median):
```
m x n x k           GEMM      FWHT   speedup
64 x    1 x  64     10.20     2.93     3.48x
64 x 2048 x  64     10.75     2.71     3.97x
128 x    1 x 128    10.33     2.88     3.59x
128 x   32 x 128     9.20     2.77     3.33x
128 x 2048 x 128    16.46     2.76     5.95x
256 x    1 x 256    10.19     2.77     3.68x
256 x 2048 x 256    16.69     3.41     4.89x
512 x 2048 x 512    54.16    12.89     4.20x
```
2026-08-18 21:21:25 +08:00
Thiago PadilhaandGitHub afd439df1f unicode : include '~' in collapsed symbol class (#26972)
The collapsed \p{S} class was missing '~', which split " ~" into
separate pre-tokens and prevented the Ġ~ BPE merge used by DeepSeek V4.
This caused re-tokenized prompts to diverge from sampled tokens and
broke KV cache reuse.

Assisted-by: Codex
2026-08-18 15:15:22 +02:00
Georgi GerganovandGitHub 169e4a7ff2 readme : update status badges + regen AUTHORS (#27317)
* readme : update status badges

* authors : regen
2026-08-18 14:35:04 +03:00
9d77fa1725 ci : Update OpenVINO to 2026.3, skip nemotron-h rollback test (#27292)
* update to ov-2026.3, update device drivers

* ci: skip nemotron-h rollback test on OpenVINO

The OpenVINO backend does not support SSM_SCAN, so the Nemotron-H recurrent state rollback graph is split and cannot preserve the recurrent cache output shape. Keep the test enabled for other backends and retain the qwen35 OpenVINO rollback coverage.

---------

Co-authored-by: ravi9 <ravi.panchumarthy@intel.com>
2026-08-18 12:02:22 +02:00
shivamkumard-ctrlandGitHub c0296022f3 ci: add Windows ARM64 CUDA support to the manual workflow (#27300)
- Add a CUDA 13.4 ARM64 matrix entry.
- Build only ggml-cuda for x64 and ARM64.
2026-08-18 11:55:45 +02:00
7acdbb1f19 mtmd: fix LFM2 image tiling threshold (#27057)
* mtmd: fix LFM2 image tiling threshold

* refactor testing

* fix

* fix on windows

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
2026-08-18 11:11:19 +02:00
74 changed files with 1629 additions and 926 deletions
+10 -10
View File
@@ -1,18 +1,18 @@
ARG OPENVINO_VERSION_MAJOR=2026.2.1
ARG OPENVINO_VERSION_FULL=2026.2.1.21919.ede283a88e3
ARG OPENVINO_VERSION_MAJOR=2026.3
ARG OPENVINO_VERSION_FULL=2026.3.0.22451.bd8d6542e3c
ARG UBUNTU_VERSION=24.04
# Intel GPU driver versions. https://github.com/intel/compute-runtime/releases
ARG IGC_VERSION=v2.36.3
ARG IGC_VERSION_FULL=2_2.36.3+21719
ARG COMPUTE_RUNTIME_VERSION=26.22.38646.4
ARG COMPUTE_RUNTIME_VERSION_FULL=26.22.38646.4-0
ARG IGC_VERSION=v2.38.2
ARG IGC_VERSION_FULL=2_2.38.2+22051
ARG COMPUTE_RUNTIME_VERSION=26.27.39122.11
ARG COMPUTE_RUNTIME_VERSION_FULL=26.27.39122.11-0
ARG IGDGMM_VERSION=22.10.0
# Intel NPU driver versions. https://github.com/intel/linux-npu-driver/releases
ARG NPU_DRIVER_VERSION=v1.33.0
ARG NPU_DRIVER_FULL=v1.33.0.20260529-26625960453
ARG LIBZE1_VERSION=1.27.0-1~24.04~ppa2
ARG NPU_DRIVER_VERSION=v1.35.0
ARG NPU_DRIVER_FULL=v1.35.0.20260722-29947505341
ARG LIBZE1_VERSION=1.28.2-1~24.04~ppa1
# Optional proxy build arguments
ARG http_proxy=
@@ -170,7 +170,7 @@ RUN --mount=type=cache,target=/var/cache/intel-npu,sharing=locked \
fi; \
DEB=/var/cache/intel-npu/libze1_${LIBZE1_VERSION}_amd64.deb; \
if [ ! -f "$DEB" ]; then \
wget -q -O "$DEB" https://snapshot.ppa.launchpadcontent.net/kobuk-team/intel-graphics/ubuntu/20260324T100000Z/pool/main/l/level-zero-loader/libze1_${LIBZE1_VERSION}_amd64.deb; \
wget -q -O "$DEB" https://snapshot.ppa.launchpadcontent.net/kobuk-team/intel-graphics/ubuntu/20260606T100000Z/pool/main/l/level-zero-loader/libze1_${LIBZE1_VERSION}_amd64.deb; \
fi; \
mkdir /tmp/npu/ && cd /tmp/npu/ && tar -xf "$TGZ" && cp "$DEB" .; \
apt-get update; \
@@ -6,8 +6,7 @@ inputs:
required: true
cuda_arch:
description: "CUDA target architecture"
required: false
default: "x64"
required: true
runs:
using: "composite"
+5 -5
View File
@@ -40,9 +40,9 @@ jobs:
runs-on: ubuntu-24.04
env:
# Sync versions in build.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
# Sync versions in build-openvino.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Clone
@@ -69,8 +69,8 @@ jobs:
env:
# Sync versions in build.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Clone
+19 -13
View File
@@ -22,6 +22,7 @@ env:
jobs:
cuda:
name: windows-cuda (${{ matrix.cuda }}, ${{ matrix.arch }})
runs-on: windows-2022
permissions:
@@ -29,7 +30,16 @@ jobs:
strategy:
matrix:
cuda: ['12.4', '13.3']
include:
- cuda: '12.4'
arch: x64
defines: '-DGGML_CUDA_CUB_3DOT2=ON'
- cuda: '13.3'
arch: x64
defines: ''
- cuda: '13.4'
arch: arm64
defines: '-DCMAKE_TOOLCHAIN_FILE=cmake/arm64-windows-msvc-cuda.cmake'
steps:
- name: Clone
@@ -39,12 +49,13 @@ jobs:
- name: ccache
uses: ggml-org/ccache-action@v1.2.21
with:
key: release-windows-2022-x64-cuda-${{ matrix.cuda }}
key: release-windows-2022-${{ matrix.arch }}-cuda-${{ matrix.cuda }}
- name: Install Cuda Toolkit
uses: ./.github/actions/windows-setup-cuda
with:
cuda_version: ${{ matrix.cuda }}
cuda_arch: ${{ matrix.arch }}
- name: Install Ninja
id: install_ninja
@@ -54,26 +65,21 @@ jobs:
- name: Build
id: cmake_build
shell: cmd
# TODO: Remove GGML_CUDA_CUB_3DOT2 flag once CCCL 3.2 is bundled within CTK and that CTK version is used in this project
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" x64
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" ${{ matrix.arch == 'x64' && 'x64' || 'amd64_arm64' }}
cmake -S . -B build -G "Ninja Multi-Config" ^
-DLLAMA_BUILD_SERVER=ON ^
-DLLAMA_BUILD_BORINGSSL=ON ^
-DGGML_NATIVE=OFF ^
-DGGML_BACKEND_DL=ON ^
-DGGML_CPU_ALL_VARIANTS=ON ^
-DGGML_NATIVE=OFF ^
-DGGML_CPU=OFF ^
-DGGML_CUDA=ON ^
-DGGML_RPC=ON ^
-DGGML_CUDA_CUB_3DOT2=ON
-DLLAMA_BUILD_BORINGSSL=ON ${{ matrix.defines }}
set /A NINJA_JOBS=%NUMBER_OF_PROCESSORS%-1
cmake --build build --config Release -j %NINJA_JOBS% -t ggml
cmake --build build --config Release
cmake --build build --config Release -j %NINJA_JOBS% --target ggml-cuda
- name: ccache-clear
uses: ./.github/actions/ccache-clear
with:
key: release-windows-2022-x64-cuda-${{ matrix.cuda }}
key: release-windows-2022-${{ matrix.arch }}-cuda-${{ matrix.cuda }}
hip:
runs-on: windows-2022
+7 -7
View File
@@ -39,8 +39,8 @@ jobs:
env:
# Sync versions in build-openvino.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Clone
@@ -81,7 +81,7 @@ jobs:
# TODO: fix and re-enable the `test-llama-archs` test below
run: |
cd ${{ github.workspace }}
ctest --test-dir build/ReleaseOV -L main -E "test-llama-archs" --verbose --timeout 2000
ctest --test-dir build/ReleaseOV -L main -E "test-llama-archs|test-recurrent-state-rollback-nemotron-h" --verbose --timeout 2000
- name: Test (GPU)
id: cmake_test_gpu
@@ -89,15 +89,15 @@ jobs:
run: |
cd ${{ github.workspace }}
export GGML_OPENVINO_DEVICE=GPU
ctest --test-dir build/ReleaseOV -L main -E "test-llama-archs" --verbose --timeout 3000
ctest --test-dir build/ReleaseOV -L main -E "test-llama-archs|test-recurrent-state-rollback-nemotron-h" --verbose --timeout 3000
openvino-windows-2022:
runs-on: windows-2022
env:
# Sync versions in build-openvino.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Clone
@@ -166,4 +166,4 @@ jobs:
call "%OPENVINO_ROOT%\setupvars.bat"
cd build
ctest --test-dir ReleaseOV -L main -E "test-llama-archs" -C Release --verbose --timeout 3000
ctest --test-dir ReleaseOV -L main -E "test-llama-archs|test-recurrent-state-rollback-nemotron-h" -C Release --verbose --timeout 3000
+2 -2
View File
@@ -288,8 +288,8 @@ jobs:
env:
# Sync versions in build.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Clone
+4 -4
View File
@@ -446,8 +446,8 @@ jobs:
env:
# Sync versions in build-openvino.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Set OpenVINO version output
@@ -562,8 +562,8 @@ jobs:
env:
# Sync versions in build-openvino.yml, build-self-hosted.yml, release.yml, build-cache.yml, .devops/openvino.Dockerfile
OPENVINO_VERSION_MAJOR: "2026.2.1"
OPENVINO_VERSION_FULL: "2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR: "2026.3"
OPENVINO_VERSION_FULL: "2026.3.0.22451.bd8d6542e3c"
steps:
- name: Set OpenVINO version output
+462 -1
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -7,10 +7,11 @@
<b>LLM inference in C/C++</b>
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Release](https://img.shields.io/github/v/release/ggml-org/llama.cpp)](https://github.com/ggml-org/llama.cpp/releases)
[![Release](https://img.shields.io/github/v/release/ggml-org/llama.cpp?filter=v*)](https://github.com/ggml-org/llama.cpp/releases?q=tag:v0)
[![Nightly](https://img.shields.io/github/v/release/ggml-org/llama.cpp?label=nightly)](https://github.com/ggml-org/llama.cpp/releases)
[![Server](https://github.com/ggml-org/llama.cpp/actions/workflows/server.yml/badge.svg)](https://github.com/ggml-org/llama.cpp/actions/workflows/server.yml)
[![Docker](https://github.com/ggml-org/llama.cpp/actions/workflows/docker.yml/badge.svg)](https://github.com/ggml-org/llama.cpp/actions/workflows/docker.yml)
[![Winget](https://github.com/ggml-org/llama.cpp/actions/workflows/winget.yml/badge.svg)](https://github.com/ggml-org/llama.cpp/actions/workflows/winget.yml)
[![Docker](https://img.shields.io/github/actions/workflow/status/ggml-org/llama.cpp/docker.yml?label=Docker)](https://github.com/ggml-org/llama.cpp/actions/workflows/docker.yml)
[![Winget](https://img.shields.io/github/actions/workflow/status/ggml-org/llama.cpp/winget.yml?label=Winget)](https://github.com/ggml-org/llama.cpp/actions/workflows/winget.yml)
[manifesto](https://github.com/ggml-org/llama.cpp/discussions/205) / [ggml](https://github.com/ggml-org/ggml) / [ops](https://github.com/ggml-org/llama.cpp/blob/master/docs/ops.md) / [maintainer PRs](https://github.com/ggml-org/llama.cpp/issues?q=is%3Apr%20is%3Aopen%20draft%3AFalse%20(author%3Argerganov%20OR%20author%3AKitaitiMakoto%20OR%20author%3Adanbev%20OR%20author%3Aaldehir%20OR%20author%3Amax-krasnyansky%20OR%20author%3ACISC%20OR%20author%3Aggerganov%20OR%20author%3Aam17an%20OR%20author%3Abartowski1182%20OR%20author%3Ahipudding%20OR%20author%3AServeurpersoCom%20OR%20author%3Apwilkin%20OR%20author%3Areeselevine%20OR%20author%3Angxson%20OR%20author%3Ajeffbolznv%20OR%20author%3A0cc4m%20OR%20author%3Aangt%20OR%20author%3AIMbackK%20OR%20author%3Aarthw%20OR%20author%3AJohannesGaessler%20OR%20author%3AORippler%20OR%20author%3Aruixiang63%20OR%20author%3Axctan%20OR%20author%3Aallozaur%20OR%20author%3Ayomaytk%20OR%20author%3Aaendk%20OR%20author%3Agaugarg-nv%20OR%20author%3Ataronaeo%20OR%20author%3Aforforever73%20OR%20author%3Alhez%20OR%20author%3Anetrunnereve%20OR%20author%3Afairydreaming)%20sort%3Aupdated-desc) / [compile times](https://github.com/ggml-org/llama.cpp-dev/blob/master/README-compile-times.md) / [lib llama API](https://github.com/ggml-org/llama.cpp/issues/9289) / [llama-server REST API](https://github.com/ggml-org/llama.cpp/issues/9291)
+1 -1
View File
@@ -190,7 +190,7 @@ if [ ! -z ${GG_BUILD_OPENVINO} ]; then
CMAKE_EXTRA="${CMAKE_EXTRA} -DGGML_OPENVINO=ON"
# TODO: fix and re-enable the `test-llama-archs` test below
CTEST_EXTRA="-E test-llama-archs"
CTEST_EXTRA="-E test-llama-archs|test-recurrent-state-rollback-nemotron-h"
fi
## helpers
+15 -1
View File
@@ -1750,6 +1750,18 @@ struct ggml_threadpool_params ggml_threadpool_params_from_cpu_params(const commo
return tpp;
}
namespace {
bool can_share_threadpool(const ggml_threadpool_params & tpp1, const ggml_threadpool_params & tpp2) {
// n_threads does not matter -> we'll use what's larger
ggml_threadpool_params tpp_comparison = tpp1;
tpp_comparison.n_threads = tpp2.n_threads;
return ggml_threadpool_params_match(&tpp_comparison, &tpp2);
}
} // namespace
common_threadpools::~common_threadpools() {
if (!free_fn) {
return;
@@ -1778,7 +1790,9 @@ void common_threadpools::init(llama_context * ctx, const common_params & params)
struct ggml_threadpool_params tpp =
ggml_threadpool_params_from_cpu_params(params.cpuparams);
if (!ggml_threadpool_params_match(&tpp, &tpp_batch)) {
if (can_share_threadpool(tpp, tpp_batch)) {
tpp.n_threads = std::max(tpp.n_threads, tpp_batch.n_threads);
} else {
threadpool_batch = ggml_threadpool_new_fn(&tpp_batch);
if (!threadpool_batch) {
COM_WRN("batch threadpool create failed : n_threads %d\n", tpp_batch.n_threads);
+6 -6
View File
@@ -237,8 +237,8 @@ chmod +x ubuntu-llamacpp-ov-install.sh
# ============================================
set -euo pipefail
OPENVINO_VERSION_MAJOR="2026.2.1"
OPENVINO_VERSION_FULL="2026.2.1.21919.ede283a88e3"
OPENVINO_VERSION_MAJOR="2026.3"
OPENVINO_VERSION_FULL="2026.3.0.22451.bd8d6542e3c"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OPENVINO_INSTALL_DIR="/opt/intel/openvino_${OPENVINO_VERSION_MAJOR}"
@@ -334,7 +334,7 @@ echo " ./build/ReleaseOV/bin/llama-cli -m model.gguf"
```
> [!NOTE]
> The script pins OpenVINO `2026.2.1` via the `OPENVINO_VERSION_MAJOR` / `OPENVINO_VERSION_FULL` variables at the top — edit them to track a different release.
> The script pins OpenVINO `2026.3` via the `OPENVINO_VERSION_MAJOR` / `OPENVINO_VERSION_FULL` variables at the top — edit them to track a different release.
</details>
@@ -364,8 +364,8 @@ REM ============================================
REM llama.cpp OpenVINO Build Script (Ninja)
REM ============================================
set "OPENVINO_VERSION_MAJOR=2026.2.1"
set "OPENVINO_VERSION_FULL=2026.2.1.21919.ede283a88e3"
set "OPENVINO_VERSION_MAJOR=2026.3"
set "OPENVINO_VERSION_FULL=2026.3.0.22451.bd8d6542e3c"
set "SCRIPT_DIR=%~dp0"
set "VCPKG_DIR=C:\vcpkg"
@@ -547,7 +547,7 @@ endlocal
```
> [!NOTE]
> The script pins OpenVINO `2026.2.1` via the `OPENVINO_VERSION_MAJOR` / `OPENVINO_VERSION_FULL` variables at the top — edit them to track a different release. From any new shell, source the matching `setupvars` script via the junction — `call "C:\Intel\openvino\setupvars.bat"` from `cmd`, or `& "C:\Intel\openvino\setupvars.ps1"` from PowerShell. If `winget` cannot register Visual Studio Build Tools on first run, install them once manually and re-run the script from an elevated **Developer Command Prompt for VS 2022**.
> The script pins OpenVINO `2026.3` via the `OPENVINO_VERSION_MAJOR` / `OPENVINO_VERSION_FULL` variables at the top — edit them to track a different release. From any new shell, source the matching `setupvars` script via the junction — `call "C:\Intel\openvino\setupvars.bat"` from `cmd`, or `& "C:\Intel\openvino\setupvars.ps1"` from PowerShell. If `winget` cannot register Visual Studio Build Tools on first run, install them once manually and re-run the script from an elevated **Developer Command Prompt for VS 2022**.
</details>
+1 -1
View File
@@ -7,7 +7,7 @@ extern "C" {
#endif
#define RPC_PROTO_MAJOR_VERSION 5
#define RPC_PROTO_MINOR_VERSION 0
#define RPC_PROTO_MINOR_VERSION 1
#define RPC_PROTO_PATCH_VERSION 0
#ifdef __cplusplus
+16 -7
View File
@@ -47,7 +47,7 @@ struct rpc_tensor {
uint64_t data;
char name[GGML_MAX_NAME];
char padding[4];
int32_t use_count;
};
static_assert(sizeof(rpc_tensor) % 8 == 0, "rpc_tensor size must be multiple of 8");
@@ -447,7 +447,7 @@ static rpc_tensor serialize_tensor(const ggml_tensor * tensor) {
// Avoid sending uninitialized data over the wire
memset(result.name, 0, sizeof(result.name));
memset(result.padding, 0, sizeof(result.padding));
result.use_count = 0;
snprintf(result.name, GGML_MAX_NAME, "%s", tensor->name);
return result;
@@ -675,7 +675,7 @@ static void ggml_backend_rpc_synchronize(ggml_backend_t backend) {
// this is no-op because we don't have any async operations
}
static void add_tensor(ggml_tensor * tensor, std::vector<rpc_tensor> & tensors, std::unordered_set<ggml_tensor*> & visited) {
static void add_tensor(ggml_tensor * tensor, const ggml_cgraph * cgraph, std::vector<rpc_tensor> & tensors, std::unordered_set<ggml_tensor*> & visited) {
if (tensor == nullptr) {
return;
}
@@ -684,10 +684,15 @@ static void add_tensor(ggml_tensor * tensor, std::vector<rpc_tensor> & tensors,
}
visited.insert(tensor);
for (int i = 0; i < GGML_MAX_SRC; i++) {
add_tensor(tensor->src[i], tensors, visited);
add_tensor(tensor->src[i], cgraph, tensors, visited);
}
add_tensor(tensor->view_src, tensors, visited);
tensors.push_back(serialize_tensor(tensor));
add_tensor(tensor->view_src, cgraph, tensors, visited);
rpc_tensor result = serialize_tensor(tensor);
const size_t hash_pos = ggml_hash_find(&cgraph->visited_hash_set, tensor);
if (hash_pos != GGML_HASHSET_FULL && ggml_bitset_get(cgraph->visited_hash_set.used, hash_pos)) {
result.use_count = cgraph->use_counts[hash_pos];
}
tensors.push_back(result);
}
static void serialize_graph(uint32_t device, const ggml_cgraph * cgraph, std::vector<uint8_t> & output) {
@@ -695,7 +700,7 @@ static void serialize_graph(uint32_t device, const ggml_cgraph * cgraph, std::ve
std::vector<rpc_tensor> tensors;
std::unordered_set<ggml_tensor*> visited;
for (uint32_t i = 0; i < n_nodes; i++) {
add_tensor(cgraph->nodes[i], tensors, visited);
add_tensor(cgraph->nodes[i], cgraph, tensors, visited);
}
// serialization format:
// | device (4 bytes) | n_nodes (4 bytes) | nodes (n_nodes * sizeof(uint64_t) | n_tensors (4 bytes) | tensors (n_tensors * sizeof(rpc_tensor)) |
@@ -1451,6 +1456,10 @@ bool rpc_server::graph_compute(const std::vector<uint8_t> & input) {
GGML_LOG_ERROR("[%s] failed to create graph node %d (id=%" PRId64 ")\n", __func__, i, id);
return false;
}
if (graph->nodes[i] != nullptr) {
const size_t hash_pos = ggml_hash_insert(&graph->visited_hash_set, graph->nodes[i]);
graph->use_counts[hash_pos] = tensor_ptrs.at(id)->use_count;
}
}
ggml_status status = ggml_backend_graph_compute(backends[device], graph);
GGML_ASSERT(status == GGML_STATUS_SUCCESS && "Unsuccessful graph computations are not supported with RPC");
+119
View File
@@ -0,0 +1,119 @@
#include "fwht.hpp"
#include <cmath>
template <int N>
static void fwht_kernel(const float * __restrict__ src, float * __restrict__ dst, const int64_t n_rows,
const float scale, const sycl::nd_item<2> & item) {
const sycl::sub_group sg = item.get_sub_group();
const int64_t r = item.get_global_id(0);
if (r >= n_rows) {
return;
}
src += r * N;
dst += r * N;
constexpr int el_w = N / WARP_SIZE;
static_assert(el_w >= 1 && N % WARP_SIZE == 0, "row must be a whole number of sub-group widths");
float reg[el_w];
const int lane = sg.get_local_linear_id();
#pragma unroll
for (int i = 0; i < el_w; ++i) {
reg[i] = src[i * WARP_SIZE + lane] * scale;
}
// Butterflies inside the sub-group. The partner of a lane with bit h clear is the
// lower index of the pair, so it takes the sum and the upper takes lower - upper.
#pragma unroll
for (int h = 1; h < WARP_SIZE; h *= 2) {
#pragma unroll
for (int j = 0; j < el_w; ++j) {
const float val = reg[j];
const float val2 = dpct::permute_sub_group_by_xor(sg, val, h, WARP_SIZE);
reg[j] = (lane & h) == 0 ? val + val2 : val2 - val;
}
}
// Butterflies across registers: h is a multiple of WARP_SIZE, so the partner of
// element i*WARP_SIZE + lane lives in reg[i + h/WARP_SIZE] on the same lane.
#pragma unroll
for (int h = WARP_SIZE; h < N; h *= 2) {
const int step = h / WARP_SIZE;
#pragma unroll
for (int j = 0; j < el_w; j += 2 * step) {
#pragma unroll
for (int k = 0; k < step; ++k) {
const float x = reg[j + k];
const float y = reg[j + k + step];
reg[j + k] = x + y;
reg[j + k + step] = x - y;
}
}
}
#pragma unroll
for (int i = 0; i < el_w; ++i) {
dst[i * WARP_SIZE + lane] = reg[i];
}
}
template <int N>
static void launch_fwht(const float * src, float * dst, const int64_t n_rows, const float scale,
dpct::queue_ptr stream) {
constexpr int rows_per_block = 4;
const int64_t num_blocks = (n_rows + rows_per_block - 1) / rows_per_block;
// dim 1 is the fastest-varying, so a sub-group is exactly one row's WARP_SIZE lanes.
const sycl::range<2> global(num_blocks * rows_per_block, WARP_SIZE);
const sycl::range<2> local(rows_per_block, WARP_SIZE);
stream->parallel_for(sycl::nd_range<2>(global, local),
[=](sycl::nd_item<2> item) [[sycl::reqd_sub_group_size(WARP_SIZE)]] {
fwht_kernel<N>(src, dst, n_rows, scale, item);
});
}
bool ggml_sycl_op_fwht(ggml_backend_sycl_context & ctx, const ggml_tensor * src, ggml_tensor * dst) {
if (src->type != GGML_TYPE_F32 || dst->type != GGML_TYPE_F32) {
return false;
}
if (!ggml_are_same_shape(src, dst)) {
return false;
}
if (!ggml_is_contiguous(src) || !ggml_is_contiguous(dst)) {
return false;
}
const int n = (int) src->ne[0];
const int64_t rows = ggml_nrows(src);
const float * src_d = (const float *) src->data;
float * dst_d = (float *) dst->data;
dpct::queue_ptr stream = ctx.stream();
const float scale = 1.0f / std::sqrt((float) n);
switch (n) {
case 64:
launch_fwht<64>(src_d, dst_d, rows, scale, stream);
return true;
case 128:
launch_fwht<128>(src_d, dst_d, rows, scale, stream);
return true;
case 256:
launch_fwht<256>(src_d, dst_d, rows, scale, stream);
return true;
case 512:
launch_fwht<512>(src_d, dst_d, rows, scale, stream);
return true;
default:
return false;
}
}
+12
View File
@@ -0,0 +1,12 @@
#ifndef GGML_SYCL_FWHT_HPP
#define GGML_SYCL_FWHT_HPP
#include "common.hpp"
// Fast Walsh-Hadamard transform, the fast path for a MUL_MAT whose src0 ggml has
// tagged GGML_HINT_SRC0_IS_HADAMARD. src0 is not read at all. Returns false if the
// shape is not one this can serve, in which case the caller must fall through to the
// ordinary mat-mul dispatch.
bool ggml_sycl_op_fwht(ggml_backend_sycl_context & ctx, const ggml_tensor * src, ggml_tensor * dst);
#endif // GGML_SYCL_FWHT_HPP
+13
View File
@@ -58,6 +58,7 @@
#include "ggml-sycl/backend.hpp"
#include "ggml-sycl/common.hpp"
#include "ggml-sycl/element_wise.hpp"
#include "ggml-sycl/fwht.hpp"
#include "ggml-sycl/gemm.hpp"
#include "ggml-sycl/getrows.hpp"
#include "ggml-sycl/norm.hpp"
@@ -4473,6 +4474,18 @@ static bool can_use_mul_mat_vec_q(const ggml_tensor * src0, const ggml_tensor *
static void ggml_sycl_mul_mat(ggml_backend_sycl_context & ctx, const ggml_tensor * src0, const ggml_tensor * src1, ggml_tensor * dst) {
scope_op_debug_print scope_dbg_print(__func__, dst, /*num_src=*/2);
// Handle HADAMARAD hint given from further up the pipeline and pass it to the correct
// kernel.
//
// The op check is not redundant: this backend also routes MUL_MAT_ID through here with a
// stack copy of dst, which carries MUL_MAT_ID's own op_params. ggml_mul_mat_set_hint()
// asserts GGML_OP_MUL_MAT for the same reason.
if (dst->op == GGML_OP_MUL_MAT && ggml_get_op_params_i32(dst, 1) == GGML_HINT_SRC0_IS_HADAMARD &&
ggml_sycl_op_fwht(ctx, src1, dst)) {
return;
}
const bool split = ggml_backend_buffer_is_sycl_split(src0->buffer);
int64_t min_compute_capability = INT_MAX;
+5
View File
@@ -1395,6 +1395,11 @@ void llama_model_loader::get_mapping_range(size_t * first, size_t * last, void *
}
}
void llama_model_loader::unmap_weight(const llama_tensor_weight & w) const {
if (!use_mmap) { return; }
mappings.at(w.idx)->unmap_fragment(w.offs, w.offs + ggml_nbytes(w.tensor));
}
void llama_model_loader::load_data_for(struct ggml_tensor * cur) const {
const auto & w = require_weight(ggml_get_name(cur));
+3
View File
@@ -194,6 +194,9 @@ struct llama_model_loader {
void get_mapping_range(size_t * first, size_t * last, void ** addr, int idx, ggml_context * ctx) const;
// release a weight's mmap pages
void unmap_weight(const llama_tensor_weight & w) const;
// for backwards compatibility, does not support ggml-backend
void load_data_for(struct ggml_tensor * cur) const;
+5 -1
View File
@@ -1270,7 +1270,7 @@ static void llama_model_quantize_impl(const std::string & fname_inp, const std::
total_size_org += tensor_size;
total_size_new += new_size;
// update the gguf meta data as we go
// update the gguf metadata as we go
gguf_set_tensor_type(ctx_outs[cur_split].get(), metadata[i].name.c_str(), new_type);
GGML_ASSERT(gguf_get_tensor_size(ctx_outs[cur_split].get(), gguf_find_tensor(ctx_outs[cur_split].get(), metadata[i].name.c_str())) == new_size);
gguf_set_tensor_data(ctx_outs[cur_split].get(), metadata[i].name.c_str(), new_data);
@@ -1278,6 +1278,10 @@ static void llama_model_quantize_impl(const std::string & fname_inp, const std::
// write tensor data + padding
fout.write((const char *) new_data, new_size);
zeros(fout, GGML_PAD(new_size, align) - new_size);
// unmap the tensor to free memory
if (ml.use_mmap) { ml.unmap_weight(weight); }
} // no --dry-run
} // main loop
+1 -1
View File
@@ -1241,7 +1241,7 @@ std::vector<std::string> unicode_regex_split(const std::string & text, const std
{ unicode_cpt_flags::LETTER, "\x41-\x5A\x61-\x7A" }, // A-Za-z
{ unicode_cpt_flags::PUNCTUATION, "\x21-\x23\x25-\x2A\x2C-\x2F\x3A-\x3B\x3F-\x40\\\x5B-\\\x5D\x5F\\\x7B\\\x7D" }, // !-#%-*,-/:-;?-@\[-\]_\{\}
{ unicode_cpt_flags::ACCENT_MARK, "" }, // no sub-128 codepoints
{ unicode_cpt_flags::SYMBOL, "\\\x24\\\x2B\x3C-\x3E\x5E\x60\\\x7C" }, // $+<=>^`|
{ unicode_cpt_flags::SYMBOL, "\\\x24\\\x2B\x3C-\x3E\x5E\x60\\\x7C\\\x7E" }, // $+<=>^`|~
};
// compute collapsed codepoints only if needed by at least one regex
+5
View File
@@ -116,6 +116,8 @@ function(llama_build_and_test source)
set_property(TEST ${TEST_TARGET} PROPERTY LABELS ${LLAMA_TEST_LABEL})
endfunction()
llama_build_and_test(test-unicode.cpp)
# build test-tokenizer-0 target once and add many tests
llama_build(test-tokenizer-0.cpp)
@@ -310,6 +312,9 @@ llama_build_and_test(test-mtmd-c-api.c)
target_link_libraries(${LLAMA_TEST_NAME} PRIVATE mtmd)
unset(LLAMA_TEST_NAME)
llama_build_and_test(test-mtmd-impl.cpp)
target_link_libraries(test-mtmd-impl PRIVATE mtmd)
# GGUF model data fetcher library for tests that need real model metadata
# Only compile when cpp-httplib has SSL support (CPPHTTPLIB_OPENSSL_SUPPORT)
if (TARGET cpp-httplib)
+88
View File
@@ -0,0 +1,88 @@
#include "testing.h"
#include "mtmd-image.h"
#include <iostream>
#include <string>
#include <utility>
#include <vector>
// this test file contains:
// 1. test cases for mtmd helpers
// 2. test cases for internal mtmd components
// internal headers can be included here
struct test_registry {
using fn_t = void (*)(testing &);
struct entry {
std::string name;
fn_t fn;
};
static std::vector<entry> & all() {
static std::vector<entry> entries;
return entries;
}
test_registry(const char * name, fn_t fn) {
all().push_back({ name, fn });
}
};
#define MAKE_TEST(name) \
static void name(testing & t); \
static const test_registry test_registry_ ## name(#name, &name); \
static void name(testing & t)
//
// mtmd_image
//
MAKE_TEST(test_image_preprocessor_lfm2) {
clip_hparams hparams;
hparams.patch_size = 16;
hparams.n_merge = 2;
hparams.set_limit_image_tokens(64, 256);
// { image size, expected tiling }
const std::vector<std::pair<clip_image_size, bool>> cases = {
{ { 704, 704 }, false },
// 720 / (patch_size * n_merge) is exactly 22.5, so this only matches HF
// if round_by_factor rounds half to even (22) instead of away from zero (23)
{ { 720, 720 }, false },
{ { 736, 736 }, true },
{ { 1024, 977 }, true },
{ { 1056, 384 }, false },
};
for (const auto & [size, expected] : cases) {
const bool actual = mtmd_image_preprocessor_lfm2::should_tile(hparams, size);
t.assert_equal(
"tiling for " + std::to_string(size.width) + "x" + std::to_string(size.height),
std::string(expected ? "tiled" : "single"),
std::string(actual ? "tiled" : "single"));
}
}
//
// main
//
int main(int argc, char ** argv) {
testing t(std::cout);
t.verbose = true;
// usage: test-mtmd-impl [filter_regex]
for (int i = 1; i < argc; i++) {
t.set_filter(argv[i]);
}
for (const auto & e : test_registry::all()) {
t.test(e.name, e.fn);
}
return t.summary();
}
+24
View File
@@ -0,0 +1,24 @@
#include "../src/unicode.h"
#include <cstdio>
#include <string>
#include <vector>
int main() {
const std::vector<std::string> regex_exprs = {
"[~][A-Za-z]+| ?[\\p{S}]+|\\s+",
};
const std::vector<std::string> expected = { " ~", "foo" };
const auto actual = unicode_regex_split(" ~foo", regex_exprs, false);
if (actual != expected) {
fprintf(stderr, "unexpected split:");
for (const auto & piece : actual) {
fprintf(stderr, " [%s]", piece.c_str());
}
fprintf(stderr, "\n");
return 1;
}
return 0;
}
+3
View File
@@ -90,6 +90,9 @@ if (BUILD_SHARED_LIBS)
set_target_properties (mtmd PROPERTIES POSITION_INDEPENDENT_CODE ON)
target_compile_definitions(mtmd PRIVATE LLAMA_BUILD)
target_compile_definitions(mtmd PUBLIC LLAMA_SHARED)
# export all symbols so that internal components can be tested by test-mtmd-impl
set_target_properties (mtmd PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
set(MTMD_PUBLIC_HEADERS
+4
View File
@@ -858,6 +858,9 @@ static std::ifstream open_ifstream_binary(const std::string & fname) {
}
#endif
// in test-mtmd-impl, we include woth common.h and this file, and these functions are duplicated
// this is a quick fix to avoid compilation errors
#ifndef DIRECTORY_SEPARATOR
static std::string string_format(const char * fmt, ...) {
va_list ap;
va_list ap2;
@@ -915,6 +918,7 @@ inline bool string_ends_with(std::string_view str, std::string_view suffix) {
return str.size() >= suffix.size() &&
str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0;
}
#endif
//
// gguf utils
+19 -2
View File
@@ -1013,14 +1013,31 @@ mtmd_image_preproc_out mtmd_image_preprocessor_lfm2::preprocess(const clip_image
return output;
}
bool mtmd_image_preprocessor_lfm2::should_tile(
const clip_hparams & hparams,
const clip_image_size & original_size) {
const int align_size = hparams.patch_size * hparams.n_merge;
const auto round_by_factor = [align_size](float x) {
// see https://github.com/ggml-org/llama.cpp/pull/27057#discussion_r3796264887
return static_cast<int>(std::nearbyint(static_cast<double>(x) / align_size)) * align_size;
};
const int h_bar = std::max(hparams.patch_size, round_by_factor(original_size.height));
const int w_bar = std::max(hparams.patch_size, round_by_factor(original_size.width));
return static_cast<double>(h_bar) * static_cast<double>(w_bar) >
static_cast<double>(hparams.image_max_pixels) * max_pixels_tolerance;
}
mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lfm2::get_slice_instructions(const clip_image_size & original_size) {
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, 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;
const bool needs_tiling = should_tile(hparams, original_size);
if (!needs_tiling) {
inst.refined_size = clip_image_size{0, 0};
+2
View File
@@ -148,6 +148,8 @@ struct mtmd_image_preprocessor_lfm2 : mtmd_image_preprocessor_llava_uhd {
mtmd_image_preproc_out preprocess(const clip_image_u8 & img) override;
slice_instructions get_slice_instructions(const clip_image_size & original_size) override;
static bool should_tile(const clip_hparams & hparams, const clip_image_size & original_size);
private:
clip_image_size find_closest_aspect_ratio(
float aspect_ratio,
-1
View File
@@ -2023,7 +2023,6 @@ int llama_perplexity(int argc, char ** argv) {
}
const int32_t n_ctx = params.n_ctx;
if (n_ctx <= 0) {
LOG_ERR("%s: perplexity tool requires '--ctx-size' > 0\n", __func__);
return 1;
@@ -48,6 +48,7 @@
containsFileMentionLink,
findCommandToken,
findMentionToken,
getConversationModel,
isIMEComposing,
isOffsetInCodeBlock,
parseClipboardContent,
@@ -190,31 +191,9 @@
let isRouter = $derived(serverStore.isRouterMode);
let conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let activeModelId = $derived(modelsStore.activeModelId);
let hasModelSelected = $derived(
!isRouter || !!conversationModel || !!modelsStore.selectedModelId
@@ -2,10 +2,10 @@
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ChatFormActionAddSheet>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
@@ -1,6 +1,7 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore, conversationsStore, isMobile, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
import { getConversationModel } from '$lib/utils';
interface Props {
disabled?: boolean;
@@ -30,7 +31,7 @@
let isOffline = $derived(!!serverStore.error);
let conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let lastSyncedConversationModel: string | null = null;
@@ -74,29 +75,7 @@
}
});
let activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let activeModelId = $derived(modelsStore.activeModelId);
let modelPropsVersion = $state(0); // Used to trigger reactivity after fetch
@@ -170,7 +149,7 @@
}
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
@@ -1,15 +1,14 @@
<script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import {
chatStore,
conversationsStore,
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from '$lib/stores';
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { chatStore, conversationsStore } from '$lib/stores';
import { untrack } from 'svelte';
const gauge = useContextGauge();
@@ -1,9 +1,9 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { gaugePopup } from './gauge-popup.svelte';
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
import { gaugePopup } from '$lib/stores/context-gauge-popup.svelte';
interface Props {
currentRead: number;
@@ -2,8 +2,13 @@
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import {
gaugeCardEnter,
gaugeCardLeave,
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -1,5 +1,5 @@
<script lang="ts">
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
@@ -37,7 +37,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
textareaElement?.focus({ preventScroll: true });
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { ChatFormInputRichToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
import {
@@ -750,7 +750,7 @@
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
if (!deviceStore.isMobile) {
rootElement?.focus({ preventScroll: true });
}
});
@@ -792,7 +792,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
rootElement?.focus({ preventScroll: true });
}
@@ -8,7 +8,7 @@
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { isMobile, settingsStore, toolsStore } from '$lib/stores';
import { deviceStore, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -130,7 +130,7 @@
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
const showTooltip = $derived(!deviceStore.isMobile);
$effect(() => {
if (typeof window === 'undefined') return;
@@ -11,7 +11,7 @@
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type {
ChatMessageActions,
ChatMessageDeletionInfo,
@@ -304,7 +304,7 @@
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
@@ -21,8 +21,7 @@
import {
chatStore,
conversationsStore,
device,
isMobile,
deviceStore,
serverStore,
settingsStore
} from '$lib/stores';
@@ -32,7 +31,7 @@
let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(
Boolean(settingsStore.config.disableAutoScroll) || isMobile.current
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
);
let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false);
@@ -52,11 +51,11 @@
let hasPropsError = $derived(!!serverStore.error);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!isMobile.current) return '1rem';
if (!deviceStore.isMobile) return '1rem';
if (device.isStandalone) return '1.5rem';
if (deviceStore.isStandalone) return '1.5rem';
if (device.isIOSSafari) return '0.25rem';
if (deviceStore.isIOSSafari) return '0.25rem';
return '0.5rem';
});
@@ -84,7 +83,7 @@
});
function handleMobileScroll() {
if (!isMobile.current) return;
if (!deviceStore.isMobile) return;
const container = scroll.chatScrollContainer;
@@ -184,7 +183,7 @@
}
function handleSendLikeScroll() {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
autoScroll.enable();
}
@@ -197,7 +196,7 @@
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | null;
if (isMobile.current) {
if (deviceStore.isMobile) {
// Keep the last user message bubble just above the input on mobile
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
const baseHeight = container.scrollHeight - innerHeight;
@@ -220,7 +219,7 @@
}
}, 100);
if (isMobile.current) {
if (deviceStore.isMobile) {
autoScroll.setDisabled(disableAutoScroll);
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
@@ -243,7 +242,8 @@
$effect(() => {
const shouldDisableAutoScroll =
settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
settingsStore.config.disableAutoScroll ||
(deviceStore.isMobile && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
@@ -266,7 +266,7 @@
autoScroll.enable();
}
if (isMobile.current && isCurrentConversationLoading) {
if (deviceStore.isMobile && isCurrentConversationLoading) {
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
}
@@ -318,9 +318,9 @@
<div
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
device.isStandalone
deviceStore.isStandalone
? 'bottom-6 right-4 left-4'
: device.isIOSSafari
: deviceStore.isIOSSafari
? 'bottom-1 left-2 right-2'
: 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
@@ -336,7 +336,7 @@
{/if}
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
{#if (isMobile.current ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
<ChatScreenActionScrollDown
onclick={() => {
mobileScrollDownHint = false;
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
@@ -120,13 +120,13 @@
}
onMount(() => {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
afterNavigate((navigation) => {
if (navigation?.from != null && !isMobile.current) {
if (navigation?.from != null && !deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
@@ -14,7 +14,7 @@
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, device, isMobile, settingsStore } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore, settingsStore } from '$lib/stores';
import { buildConversationTree } from '$lib/utils';
import { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
@@ -36,7 +36,7 @@
let logoHovered = $state(false);
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => {
@@ -65,7 +65,7 @@
});
$effect(() => {
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
isExpandedMode = false;
}
});
@@ -227,7 +227,7 @@
}
async function selectConversation(id: string) {
if (isMobile.current) {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
@@ -315,9 +315,9 @@
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
'md:h-[calc(100dvh-1.125rem)]',
isExpandedMode &&
(device.isStandalone
(deviceStore.isStandalone
? 'h-[calc(100dvh-2rem)]'
: device.isIOSDevice
: deviceStore.isIOSDevice
? 'h-[calc(100dvh-0.5rem)]'
: 'h-[calc(100dvh-1rem)]'),
'rounded-3xl md:rounded-2xl',
@@ -353,7 +353,7 @@
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
<div
class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
class="flex items-center transition-all duration-150 ease-out {deviceStore.isMobile &&
!isExpandedMode
? 'opacity-0 h-0!'
: ''}"
@@ -361,7 +361,7 @@
out:fade={{ duration: 100 }}
>
<ActionIcon
icon={isMobile.current ? X : PanelLeftClose}
icon={deviceStore.isMobile ? X : PanelLeftClose}
size="lg"
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
@@ -375,9 +375,9 @@
</div>
<div
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
? 'transition-[opacity,height] duration-200 ease-out'
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
in:fade={{ duration: 200 }}
out:fade={{ duration: 200 }}
>
@@ -395,7 +395,7 @@
isSearchModeActive = true;
}}
onNewChat={() => {
if (isMobile.current) {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
}}
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -42,7 +42,7 @@
let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
@@ -107,7 +107,7 @@
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = item.route
? () => {
@@ -156,7 +156,7 @@
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = item.route
? () => {
onNewChat?.();
@@ -8,6 +8,7 @@
} from '$lib/components/app';
import SettingsGroup from '$lib/components/app/settings/SettingsGroup.svelte';
import { ConversationSelectionMode, FileExtensionText, HtmlInputType } from '$lib/enums';
import { ConversationTransferService } from '$lib/services';
import { conversationsStore, settingsStore } from '$lib/stores';
import { createMessageCountMap } from '$lib/utils';
import { fade } from 'svelte/transition';
@@ -147,9 +148,9 @@
);
if (allData.length === 1) {
conversationsStore.downloadConversationFile(allData[0]);
ConversationTransferService.downloadConversationFile(allData[0]);
} else {
conversationsStore.downloadConversationsArchive(allData);
ConversationTransferService.downloadConversationsArchive(allData);
}
exportedConversations = selectedConversations;
@@ -177,7 +178,7 @@
if (!file) return;
try {
const importedData = await conversationsStore.parseImportFile(file);
const importedData = await ConversationTransferService.parseImportFile(file);
if (importedData.length === 0) {
throw new Error('No conversations found in file');
@@ -31,5 +31,11 @@ export const API_STREAM = {
LOOKUP: './v1/streams/lookup'
};
// query params for the resumable stream routes
export const STREAM_QUERY_PARAMS = {
CONV_ID: 'conv_id',
FROM: 'from'
} as const;
/** CORS proxy endpoint path */
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
@@ -8,36 +8,15 @@
* demand if they aren't cached yet.
*/
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { getConversationModel } from '$lib/utils';
export function useChatScreenActiveModel() {
const isRouter = $derived(serverStore.isRouterMode);
const conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
const options = modelsStore.models;
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
const activeModelId = $derived(modelsStore.activeModelId);
let modelPropsVersion = $state(0);
+1 -1
View File
@@ -58,7 +58,7 @@ export function usePwa() {
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
const currentVersion = versionStore.frontend;
if (!currentVersion) return;
@@ -1,8 +1,9 @@
import { REASONING_EFFORT_LEVELS, REASONING_EFFORT_TOKENS } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import type { ReasoningEffortLevel } from '$lib/types';
import type { DatabaseMessage } from '$lib/types/database';
import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
@@ -24,7 +25,7 @@ export interface UseReasoningMenuReturn {
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
// a router chat can carry reasoning from an earlier turn before the props
// cache is primed, so a model that already produced thinking still qualifies
+57 -9
View File
@@ -13,6 +13,7 @@ import {
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
SSE_LINE_SEPARATOR,
STREAM_QUERY_PARAMS,
STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX,
STREAM_VISIBILITY_KICK_MS
} from '$lib/constants';
@@ -33,6 +34,7 @@ import type {
ApiStreamSession
} from '$lib/types/api';
import { isAbortError } from '$lib/utils/abort';
import { ApiError } from '$lib/utils/api-fetch';
import { getAuthHeaders, getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { streamIdentity } from '$lib/utils/stream-identity';
@@ -529,7 +531,7 @@ export class ChatService {
try {
const id = streamIdentity(conversationId, model);
await fetch(`${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}`, {
await fetch(ChatService.buildStreamUrl(id), {
headers: getAuthHeaders(),
method: 'DELETE'
});
@@ -538,6 +540,46 @@ export class ChatService {
}
}
/**
* Look up server-side stream sessions for the given conversation ids. Ids carry the frozen
* conv::model identity when a model was bound at POST time.
*/
static async lookupStreamSessions(conversationIds: string[]): Promise<ApiStreamSession[]> {
const resp = await fetch(API_STREAM.LOOKUP, {
body: JSON.stringify({ conversation_ids: conversationIds }),
headers: getJsonHeaders(),
method: 'POST'
});
if (!resp.ok) {
throw new ApiError(`Stream lookup failed with HTTP ${resp.status}`, resp.status);
}
const body = (await resp.json()) as unknown;
if (!Array.isArray(body)) {
throw new Error('Stream lookup returned a non-array response');
}
return body as ApiStreamSession[];
}
/**
* Fetch the full replay of a server-side stream from byte 0. Returns the raw Response so the
* caller can pipe it through the SSE parser like a fresh stream.
*/
static async fetchStreamReplay(streamId: string): Promise<Response> {
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
headers: getAuthHeaders()
});
if (!resp.ok) {
throw new ApiError(`Stream replay failed with HTTP ${resp.status}`, resp.status);
}
return resp;
}
/**
* Pick the running session to splice into when discoverActiveStream lists candidates for a
* conversation. Finalized sessions are not candidates: their final content was already written
@@ -629,6 +671,15 @@ export class ChatService {
return streamIdentity(conversationId, model);
}
// build the replay route url for a stream identity, from is the resume byte offset, omitted
// for the cancel route
private static buildStreamUrl(streamId: string, from?: number): string {
const query = `${STREAM_QUERY_PARAMS.CONV_ID}=${encodeURIComponent(streamId)}`;
const offset = from === undefined ? '' : `&${STREAM_QUERY_PARAMS.FROM}=${from}`;
return `${API_STREAM.BASE}?${query}${offset}`;
}
/**
* Reconnect to an interrupted stream for this conversation. Returns the fetch Response so the
* existing SSE parser drains it like a fresh stream. The server returns 200 on success, 404 if
@@ -642,13 +693,10 @@ export class ChatService {
const ac = new AbortController();
try {
const resp = await fetch(
`${API_STREAM.BASE}?conv_id=${encodeURIComponent(streamId)}&from=0`,
{
headers: getAuthHeaders(),
signal: ac.signal
}
);
const resp = await fetch(ChatService.buildStreamUrl(streamId, 0), {
headers: getAuthHeaders(),
signal: ac.signal
});
ac.abort();
@@ -668,7 +716,7 @@ export class ChatService {
const state = ChatService.getStreamState(conversationId);
const from = state?.bytesReceived ?? 0;
const id = streamIdentity(conversationId, model);
const url = `${API_STREAM.BASE}?conv_id=${encodeURIComponent(id)}&from=${from}`;
const url = ChatService.buildStreamUrl(id, from);
return await fetch(url, { headers: getAuthHeaders(), method: 'GET', signal });
}
@@ -0,0 +1,279 @@
/**
* ConversationTransferService - Stateless conversation import/export layer
*
* Owns the session file format (one JSONL record per line: a SESSION header
* followed by MESSAGE records), ZIP archiving and browser downloads.
* DB access and store refreshes stay in conversationsStore.
*/
import { EXPORT_CONV, NEWLINE, ZIP_MAGIC } from '$lib/constants';
import {
FileExtensionText,
MimeTypeApplication,
MimeTypeText,
SessionRecordType
} from '$lib/enums';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
export class ConversationTransferService {
/**
*
*
* JSONL Session Format
*
*
*/
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
static serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
static parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private static isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
static async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (ConversationTransferService.isSessionsJsonl(text)) {
return ConversationTransferService.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
*
*
* Downloads
*
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
static generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
* @param filename - Filename; if omitted, a deterministic name is generated
*/
static downloadConversationFile(data: ExportedConversation, filename?: string): void {
const { conv: conversation, messages: msgs } = data;
if (!conversation) {
console.error('Invalid data: missing conversation');
return;
}
const downloadFilename =
filename ?? ConversationTransferService.generateConversationFilename(conversation, msgs);
const jsonl = ConversationTransferService.serializeSessionToJsonl(data);
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
ConversationTransferService.triggerDownload(blob, downloadFilename);
}
/**
* Triggers a browser download of multiple conversations as a `.zip`, one
* `.jsonl` file per conversation.
* @param data - The conversations to export
*/
static downloadConversationsArchive(data: ExportedConversation[]): void {
if (data.length === 0) {
console.error('Invalid data: no conversations to export');
return;
}
const usedNames = new Set<string>();
const files: Record<string, Uint8Array> = {};
for (const session of data) {
const baseName = ConversationTransferService.generateConversationFilename(
session.conv,
session.messages
);
// Disambiguate any duplicate filenames within the archive.
let entryName = baseName;
let suffix = 1;
while (usedNames.has(entryName)) {
entryName = baseName.replace(
new RegExp(`${FileExtensionText.JSONL}$`),
`_${suffix++}${FileExtensionText.JSONL}`
);
}
usedNames.add(entryName);
files[entryName] = strToU8(ConversationTransferService.serializeSessionToJsonl(session));
}
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
const zipped = zipSync(files);
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
ConversationTransferService.triggerDownload(blob, archiveName);
}
/**
* Triggers a browser download of a blob under the given filename.
*/
private static triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
+9
View File
@@ -103,6 +103,15 @@ export { ChatService } from './chat.service';
*/
export { DatabaseService } from './database.service';
/**
* **ConversationTransferService** - Conversation import/export format layer
*
* Owns the JSONL session format (SESSION header + MESSAGE records), ZIP
* archiving and browser downloads. Stateless; DB access and store refreshes
* stay in conversationsStore.
*/
export { ConversationTransferService } from './conversation-transfer.service';
/**
* **ModelsService** - Model management API communication
*
+92 -1
View File
@@ -1,7 +1,15 @@
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { base } from '$app/paths';
import {
API_MODELS,
MODEL_ID,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import { apiFetch, apiPost, normalizeModelName } from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
export class ModelsService {
/**
@@ -100,6 +108,89 @@ export class ModelsService {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Status Feed
*
*
*/
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Read the /models/sse feed and invoke onEvent for each parsed envelope.
* Reconnects on network drops until the signal aborts. Splits the byte
* stream into SSE records on the blank line boundary; the payload rides in
* the data lines as a JSON envelope with its own model, event and data fields.
*/
static async watchModelEvents(
signal: AbortSignal,
onEvent: (event: ApiModelsSseEvent) => void
): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
const event = ModelsService.parseStatusRecord(buffer.slice(0, boundary));
if (event) onEvent(event);
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsService.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record into its JSON envelope, or null when the record
* carries no data payload or malformed JSON.
*/
private static parseStatusRecord(record: string): ApiModelsSseEvent | null {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return null;
try {
return JSON.parse(payload) as ApiModelsSseEvent;
} catch {
return null;
}
}
/**
*
*
@@ -1,45 +0,0 @@
/**
* buildInfoStore - llama.cpp build information
*
* Reads the build version from `build.json` — embedded at llama.cpp build time
* with the llama.cpp build number (LLAMA_BUILD_NUMBER). Shown in the UI when
* `showBuildVersion` is enabled.
*
* In dev mode (via `npm run dev`), falls back to `import.meta.env.DEV`'s truthy
* value since the artifact is not produced.
*/
import { browser } from '$app/environment';
import { base } from '$app/paths';
let build = $state<string>('');
async function loadBuild() {
if (!browser) return;
if (import.meta.env.DEV) {
build = 'dev';
return;
}
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
build = data.version ?? '';
}
} catch {
// build.json missing or unreachable - leave as empty string
}
}
loadBuild();
export const buildInfoStore = {
get value(): string {
return build;
}
};
+8 -65
View File
@@ -14,7 +14,6 @@
import {
CONVERSATION_ID_SEPARATOR,
CWD_CLEARED_TEXT,
HEADERS,
INACTIVE_CONVERSATION,
STREAM_RESUME_RETRY_MS,
SYSTEM_MESSAGE_PLACEHOLDER,
@@ -25,7 +24,6 @@ import {
ErrorDialogType,
MessageRole,
MessageType,
MimeTypeApplication,
ReasoningEffort,
StreamConnectionState
} from '$lib/enums';
@@ -58,7 +56,7 @@ import {
findMessageById,
formatCwdMessage,
generateConversationTitle,
getAuthHeaders,
getConversationModel,
isAbortError,
normalizeModelName,
streamIdentity
@@ -109,9 +107,6 @@ class ChatStore {
private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null);
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
@@ -225,33 +220,12 @@ class ChatStore {
async probeServerStream(convId: string): Promise<ApiStreamSession | null> {
if (!convId) return null;
let listResp: Response;
try {
// POST the one conv id we are probing
listResp = await fetch(`./v1/streams/lookup`, {
body: JSON.stringify({ conversation_ids: [convId] }),
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
} catch (e) {
console.warn('probeServerStream fetch failed:', e);
return null;
}
if (!listResp.ok) {
console.warn(`probeServerStream got HTTP ${listResp.status} for conv ${convId}`);
return null;
}
let sessions: ApiStreamSession[];
try {
sessions = (await listResp.json()) as ApiStreamSession[];
sessions = await ChatService.lookupStreamSessions([convId]);
} catch (e) {
console.warn('probeServerStream JSON parse failed:', e);
console.warn(`probeServerStream failed for conv ${convId}:`, e);
return null;
}
@@ -296,18 +270,9 @@ class ChatStore {
let response: Response;
try {
response = await fetch(`./v1/stream?conv_id=${encodeURIComponent(id)}&from=0`, {
headers: getAuthHeaders()
});
response = await ChatService.fetchStreamReplay(id);
} catch (e) {
console.error('attachServerStream replay fetch failed:', e);
unlock();
return;
}
if (!response.ok) {
console.warn(`attachServerStream replay got HTTP ${response.status} for conv ${convId}`);
console.error(`attachServerStream replay failed for conv ${convId}:`, e);
unlock();
return;
@@ -807,21 +772,9 @@ class ChatStore {
let sessions: ApiStreamSession[];
try {
const resp = await fetch('./v1/streams/lookup', {
body: JSON.stringify({ conversation_ids: lookupIds }),
headers: { ...getAuthHeaders(), [HEADERS.CONTENT_TYPE]: MimeTypeApplication.JSON },
method: 'POST'
});
if (!resp.ok) return;
const body = (await resp.json()) as unknown;
if (!Array.isArray(body)) return;
sessions = body as ApiStreamSession[];
sessions = await ChatService.lookupStreamSessions(lookupIds);
} catch (e) {
console.warn('syncRemoteRunningStreams fetch failed:', e);
console.warn('syncRemoteRunningStreams lookup failed:', e);
return;
}
@@ -1335,7 +1288,7 @@ class ChatStore {
let effectiveModel: string | null | undefined = undefined;
if (serverStore.isRouterMode) {
const conversationModel = this.getConversationModel(allMessages);
const conversationModel = getConversationModel(allMessages);
effectiveModel = modelOverride || modelsStore.selectedModelName || conversationModel;
}
@@ -2788,16 +2741,6 @@ class ChatStore {
}
}
getConversationModel(messages: DatabaseMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
}
return null;
}
private getApiOptions(): Record<string, unknown> {
const currentConfig = settingsStore.config;
const hasValue = (value: unknown): boolean =>
@@ -49,23 +49,8 @@ function deriveLiveStats(state: ApiProcessingState | null): LiveStats | null {
}
class ContextStatsStore {
// Resolve the model the stats report context for: explicit selection >
// last assistant model > single-model mode (mirrors useChatScreenActiveModel).
activeModelId = $derived.by(() => {
if (!serverStore.isRouterMode) {
return modelsStore.singleModelName;
}
const selectedId = modelsStore.selectedModelId;
if (selectedId) {
const model = modelsStore.models.find((m) => m.id === selectedId);
if (model) return model.model;
}
return chatStore.getConversationModel(conversationsStore.activeMessages as DatabaseMessage[]);
});
// The canonical resolution lives in modelsStore.activeModelId.
activeModelId = $derived(modelsStore.activeModelId);
isActiveModelLoaded = $derived(
this.activeModelId !== null &&
+5 -289
View File
@@ -20,21 +20,9 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import {
EXPORT_CONV,
NEWLINE,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY,
ROUTES,
ZIP_MAGIC
} from '$lib/constants';
import {
FileExtensionText,
MessageRole,
MimeTypeApplication,
MimeTypeText,
ReasoningEffort,
SessionRecordType
} from '$lib/enums';
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, ROUTES } from '$lib/constants';
import { MessageRole, ReasoningEffort } from '$lib/enums';
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
import { DatabaseService } from '$lib/services/database.service';
import { MigrationService } from '$lib/services/migration.service';
import { RouterService } from '$lib/services/router.service';
@@ -43,7 +31,6 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -100,14 +87,6 @@ class ConversationsStore {
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
}
/**
* Callback for updating message content in chatStore.
* Registered by chatStore to enable cross-store updates without circular dependency.
*/
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
private initPromise: Promise<void> | null = null;
@@ -143,23 +122,6 @@ class ConversationsStore {
return this.initPromise;
}
/**
* Alias for init() for backward compatibility.
*/
async initialize(): Promise<void> {
return this.init();
}
/**
* Register a callback for message updates from other stores.
* Called by chatStore during initialization.
*/
registerMessageUpdateCallback(
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
): void {
this.messageUpdateCallback = callback;
}
/**
*
*
@@ -504,7 +466,7 @@ class ConversationsStore {
return;
}
this.downloadConversationsArchive(exported);
ConversationTransferService.downloadConversationsArchive(exported);
toast.success(
exported.length === 1
@@ -976,247 +938,6 @@ class ConversationsStore {
*
*/
/**
* Generates a sanitized filename for a conversation export
* @param conversation - The conversation metadata
* @param msgs - Optional array of messages belonging to the conversation
* @returns The generated filename string
*/
generateConversationFilename(
conversation: { id?: string; name?: string },
msgs?: DatabaseMessage[]
): string {
const conversationName = (conversation.name ?? '').trim().toLowerCase();
const sanitizedName = conversationName
.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
// If we have messages, use the timestamp of the newest message
const referenceDate = msgs?.length
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
: new Date();
const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
const formattedDate = iso
.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
}
/**
* Serializes a session (a conversation with its messages) as JSONL.
* The first line is the session header (a `SessionRecordType.SESSION` record
* carrying the conversation properties); each subsequent line is a single message.
* @param data - The exported conversation payload
* @returns The JSONL string (one record per line)
*/
serializeSessionToJsonl(data: ExportedConversation): string {
const { conv, messages } = data;
const sessionLine = JSON.stringify({
harness: EXPORT_CONV.HARNESS,
type: SessionRecordType.SESSION,
...conv
});
const messageLines = messages.map((message: DatabaseMessage) => {
// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
const { toolCalls, ...rest } = message;
const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
});
return [sessionLine, ...messageLines].join(NEWLINE);
}
/**
* Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
* A `SessionRecordType.SESSION` line starts a new session; following
* `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
* sessions in a single file.
* @param text - The JSONL file contents
* @returns The parsed conversations with their messages
*/
parseSessionsJsonl(text: string): ExportedConversation[] {
const sessions: ExportedConversation[] = [];
let current: ExportedConversation | null = null;
for (const line of text.split(NEWLINE)) {
const trimmed = line.trim();
if (!trimmed) continue;
const record = JSON.parse(trimmed);
if (record.type === SessionRecordType.SESSION) {
// Drop the discriminator and harness marker; the rest is the conversation.
const conv = { ...record };
delete conv.type;
delete conv.harness;
current = { conv: conv as DatabaseConversation, messages: [] };
sessions.push(current);
} else if (record.type === SessionRecordType.MESSAGE) {
if (!current) {
throw new Error('Invalid JSONL: message record before any session record');
}
const message = record.message as DatabaseMessage;
// `toolCalls` is parsed to an array on export; the DB stores it as a string.
if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
message.toolCalls = JSON.stringify(message.toolCalls);
}
current.messages.push(message);
}
// Ignore unknown record types for forward compatibility.
}
return sessions;
}
/**
* Reports whether the text is the JSONL session format, whose first non-empty
* line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
* with an array or an object that has no such discriminator.
* @param text - The file contents
*/
private isSessionsJsonl(text: string): boolean {
const trimmed = text.trimStart();
const lineEnd = trimmed.indexOf(NEWLINE);
const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
try {
return JSON.parse(firstLine).type === SessionRecordType.SESSION;
} catch {
// Not a standalone JSON record, so not the JSONL format.
return false;
}
}
/**
* Parses an import file into conversations, accepting the current JSONL and
* ZIP formats as well as the legacy JSON format. The format comes from the
* contents, so an import works whatever the file is named.
* @param file - The user-selected file
* @returns The parsed conversations with their messages
*/
async parseImportFile(file: File): Promise<ExportedConversation[]> {
const bytes = new Uint8Array(await file.arrayBuffer());
if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
const entries = unzipSync(bytes);
const sessions: ExportedConversation[] = [];
for (const [entryName, entryBytes] of Object.entries(entries)) {
if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
sessions.push(...this.parseSessionsJsonl(strFromU8(entryBytes)));
}
return sessions;
}
const text = strFromU8(bytes);
if (this.isSessionsJsonl(text)) {
return this.parseSessionsJsonl(text);
}
// Legacy JSON format: an array of conversations or a single conversation object.
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) {
return parsed;
}
if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
return [parsed];
}
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (a single conversation with its messages)
* @param filename - Filename; if omitted, a deterministic name is generated
*/
downloadConversationFile(data: ExportedConversation, filename?: string): void {
const { conv: conversation, messages: msgs } = data;
if (!conversation) {
console.error('Invalid data: missing conversation');
return;
}
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
const jsonl = this.serializeSessionToJsonl(data);
const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
this.triggerDownload(blob, downloadFilename);
}
/**
* Triggers a browser download of multiple conversations as a `.zip`, one
* `.jsonl` file per conversation.
* @param data - The conversations to export
*/
downloadConversationsArchive(data: ExportedConversation[]): void {
if (data.length === 0) {
console.error('Invalid data: no conversations to export');
return;
}
const usedNames = new SvelteSet<string>();
const files: Record<string, Uint8Array> = {};
for (const session of data) {
const baseName = this.generateConversationFilename(session.conv, session.messages);
// Disambiguate any duplicate filenames within the archive.
let entryName = baseName;
let suffix = 1;
while (usedNames.has(entryName)) {
entryName = baseName.replace(
new RegExp(`${FileExtensionText.JSONL}$`),
`_${suffix++}${FileExtensionText.JSONL}`
);
}
usedNames.add(entryName);
files[entryName] = strToU8(this.serializeSessionToJsonl(session));
}
const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
const zipped = zipSync(files);
const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
this.triggerDownload(blob, archiveName);
}
/**
* Triggers a browser download of a blob under the given filename.
*/
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Downloads a single conversation as a JSONL file, serializing the full message tree.
* @param convId - The conversation ID to download
@@ -1231,7 +952,7 @@ class ConversationsStore {
const messages = await DatabaseService.getConversationMessages(convId);
this.downloadConversationFile({ conv: conversation, messages });
ConversationTransferService.downloadConversationFile({ conv: conversation, messages });
}
/**
@@ -1251,8 +972,3 @@ class ConversationsStore {
}
export const conversationsStore = new ConversationsStore();
// Auto-initialize in browser
if (browser) {
conversationsStore.init();
}
+63 -44
View File
@@ -1,5 +1,17 @@
/**
* deviceStore - Browser environment signals
*
* Device capabilities, OS theme and viewport in one class store:
* deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari / isWKWebView /
* isStandalone, deviceStore.systemTheme.isDark.
*
* UA-derived flags are static for the session; isStandalone and systemTheme
* track live media query changes.
*/
import { browser } from '$app/environment';
import { MEDIA_QUERIES } from '$lib/constants';
import { DEFAULT_MOBILE_BREAKPOINT, MEDIA_QUERIES } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
/**
* iOS UA token detection.
@@ -17,53 +29,60 @@ const UA_PATTERNS = {
WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
} as const;
interface DeviceContext {
class DeviceStore {
/** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */
isIOSDevice: boolean;
readonly isIOSDevice: boolean = false;
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
isIOSSafari: boolean;
readonly isIOSSafari: boolean = false;
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
* third-party iOS browsers (all of which share the WKWebView engine). */
isWKWebView: boolean;
readonly isWKWebView: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone: boolean;
isStandalone = $state(false);
/** OS color scheme preference; the user override lives in settingsStore. */
readonly systemTheme = $state({ isDark: false });
private mobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
get isMobile(): boolean {
return this.mobile.current;
}
constructor() {
if (!browser) return;
const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0;
this.isIOSDevice =
UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
// token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
this.isIOSSafari = this.isIOSDevice && hasSafariToken;
this.isWKWebView = this.isIOSDevice && !hasSafariToken;
// navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+).
this.isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
this.systemTheme.isDark = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches;
// isStandalone and systemTheme can change at runtime (e.g. user installs the
// PWA while the tab is open); the UA-derived flags are static for the session
const standaloneMql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
standaloneMql.addEventListener('change', (e) => {
this.isStandalone = e.matches;
});
const darkMql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
darkMql.addEventListener('change', (e) => {
this.systemTheme.isDark = e.matches;
});
}
}
const SERVER_DEFAULT: DeviceContext = {
isIOSDevice: false,
isIOSSafari: false,
isStandalone: false,
isWKWebView: false
};
function detect(): DeviceContext {
if (!browser) return SERVER_DEFAULT;
const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0;
const isIOSDevice = UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
// token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
const isIOSSafari = isIOSDevice && hasSafariToken;
const isWKWebView = isIOSDevice && !hasSafariToken;
// navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+).
const isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true;
return { isIOSDevice, isIOSSafari, isStandalone, isWKWebView };
}
export const device = $state<DeviceContext>(detect());
if (browser) {
// isStandalone can change at runtime (e.g. user installs the PWA while the
// tab is open); the UA-derived flags are static for the session.
const mql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
mql.addEventListener('change', (e) => {
device.isStandalone = e.matches;
});
}
export const deviceStore = new DeviceStore();
+1 -21
View File
@@ -53,26 +53,6 @@ export { permissionsStore } from './permissions.svelte';
export { toolsStore } from './tools.svelte';
// ENVIRONMENT / META
export { buildInfoStore } from './build-info.svelte';
export { versionStore } from './version.svelte';
export { device } from './device.svelte';
export { viewport, isMobile } from './viewport.svelte';
export { theme } from './theme.svelte';
export {
gaugePopup,
gaugePopupClose,
gaugeTriggerPointerDown,
gaugeTriggerClick,
gaugeTriggerKeydown,
gaugeTriggerEnter,
gaugeTriggerLeave,
gaugeCardEnter,
gaugeCardLeave
} from './context-gauge-popup.svelte';
export { persisted } from './persisted.svelte';
export { deviceStore } from './device.svelte';
+34
View File
@@ -0,0 +1,34 @@
/**
* Explicit store initialization, called once from the root layout.
*
* Order matters: migrations run first because they rename and rewrite
* localStorage keys, so every store that reads localStorage initializes
* only after they complete. Constructors and module-level side effects
* stay empty so import order can no longer change startup behavior.
*/
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings.svelte';
import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
import { MigrationService } from '$lib/services/migration.service';
let started = false;
export async function initStores(): Promise<void> {
if (!browser || started) return;
started = true;
await MigrationService.runAllMigrations();
settingsStore.initialize();
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
await conversationsStore.init();
}
+31 -76
View File
@@ -1,12 +1,4 @@
import { base } from '$app/paths';
import {
API_MODELS,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
MODEL_PROPS_CACHE,
SSE_DATA_PREFIX,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
import { FAVORITE_MODELS_LOCALSTORAGE_KEY, MODEL_PROPS_CACHE } from '$lib/constants';
import {
FileTypeCategory,
ModelModality,
@@ -20,12 +12,12 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
import { serverStore } from '$lib/stores/server.svelte';
// deep imports, not the '$lib/utils' barrel: it re-exports modules that reach back
// into the stores, and going through it here would read a half-built module
import { getAuthHeaders } from '$lib/utils/api-headers';
import { TTLCache } from '$lib/utils/cache-ttl';
import {
detectThinkingSupport,
detectThinkingSupportWithReason
} from '$lib/utils/chat-template-thinking-detector';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -141,6 +133,33 @@ class ModelsStore {
return props.model_path.split(/(\\|\/)/).pop() || null;
}
/**
* Model the active conversation view resolves to. Router mode: the user's
* selection first, then the conversation's own model. Otherwise the single
* served model, from the models list or the server props as a fallback.
*/
get activeModelId(): string | null {
if (!serverStore.isRouterMode) {
return this.models.length > 0 ? this.models[0].model : this.singleModelName;
}
if (this.selectedModelId) {
const selected = this.models.find((m) => m.id === this.selectedModelId);
if (selected) return selected.model;
}
const conversationModel = getConversationModel(conversationsStore.activeMessages);
if (conversationModel) {
const model = this.models.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
}
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
@@ -717,8 +736,6 @@ class ModelsStore {
*/
// reconnect delay after the feed drops or the server is not ready yet
private static readonly SSE_RECONNECT_MS = 1000;
/**
* Open the /models/sse feed and keep it live with auto reconnect.
* Idempotent and router mode only. The feed drives status and progress,
@@ -752,72 +769,10 @@ class ModelsStore {
}
/**
* Read the feed and reconnect until unsubscribed. Splits the byte stream
* into SSE records on the blank line boundary.
* Read the feed and reconnect until unsubscribed.
*/
private async runStatusReader(signal: AbortSignal): Promise<void> {
const decoder = new TextDecoder();
while (!signal.aborted) {
try {
const response = await fetch(`${base}${API_MODELS.SSE}`, {
headers: getAuthHeaders(),
signal
});
if (response.ok && response.body) {
const reader = response.body.getReader();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
while (boundary !== -1) {
this.handleStatusRecord(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + SSE_RECORD_SEPARATOR.length);
boundary = buffer.indexOf(SSE_RECORD_SEPARATOR);
}
}
}
} catch {
// network drop or abort falls through to the reconnect delay
}
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, ModelsStore.SSE_RECONNECT_MS));
}
}
/**
* Parse one SSE record. The payload rides in the data lines as a JSON
* envelope that carries its own model, event and data fields.
*/
private handleStatusRecord(record: string): void {
const payload = record
.split(SSE_LINE_SEPARATOR)
.filter((line) => line.startsWith(SSE_DATA_PREFIX))
.map((line) => line.slice(SSE_DATA_PREFIX.length).trim())
.join(SSE_LINE_SEPARATOR);
if (payload.length === 0) return;
let envelope: ApiModelsSseEvent;
try {
envelope = JSON.parse(payload);
} catch {
return;
}
this.applyStatusEvent(envelope);
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
}
/**
@@ -5,7 +5,11 @@ import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
constructor() {
/**
* Load persisted permissions. Called by initStores() after migrations
* have run.
*/
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage side effects
if (!browser) return;
@@ -1,51 +0,0 @@
import { browser } from '$app/environment';
type PersistedValue<T> = {
get value(): T;
set value(newValue: T);
};
export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
let value = initialValue;
if (browser) {
try {
const stored = localStorage.getItem(key);
if (stored !== null) {
value = JSON.parse(stored) as T;
}
} catch (error) {
console.warn(`Failed to load ${key}:`, error);
}
}
const persist = (next: T) => {
if (!browser) {
return;
}
try {
if (next === null || next === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify(next));
} catch (error) {
console.warn(`Failed to persist ${key}:`, error);
}
};
return {
get value() {
return value;
},
set value(newValue: T) {
value = newValue;
persist(newValue);
}
};
}
+6 -9
View File
@@ -40,9 +40,9 @@ import {
} from '$lib/constants';
import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { deviceStore } from '$lib/stores/device.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { SettingsExportType } from '$lib/types';
import {
configToParameterRecord,
@@ -85,12 +85,6 @@ class SettingsStore {
return ParameterSyncService.extractServerDefaults(serverStore.defaultParams);
}
constructor() {
if (browser) {
this.initialize();
}
}
/**
*
*
@@ -100,9 +94,12 @@ class SettingsStore {
*/
/**
* Initialize the settings store by loading from localStorage
* Initialize the settings store by loading from localStorage.
* Called by initStores() after migrations have run.
*/
initialize() {
if (!browser) return;
try {
this.loadConfig();
this.migrateLegacyTheme();
@@ -138,7 +135,7 @@ class SettingsStore {
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) {
if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
-14
View File
@@ -1,14 +0,0 @@
import { browser } from '$app/environment';
import { MEDIA_QUERIES } from '$lib/constants';
export const theme = $state({
isSystemDark: browser && window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches
});
if (browser) {
const mql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
mql.addEventListener('change', (e) => {
theme.isSystemDark = e.matches;
});
}
+5 -1
View File
@@ -38,7 +38,11 @@ class ToolsStore {
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
constructor() {
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
*/
initialize(): void {
// browser-only init: skip on SSR to avoid localStorage/fetch side effects
if (!browser) return;
+46 -25
View File
@@ -1,44 +1,65 @@
/**
* versionStore - Frontend build version
* versionStore - Build version information
*
* Reads from SvelteKit's `_app/version.json` — generated by the @vite-pwa/sveltekit
* plugin. The version string changes on every build, so comparing it against
* localStorage reliably detects server upgrades.
* - `build`: llama.cpp build number from `build.json`, embedded at llama.cpp
* build time (LLAMA_BUILD_NUMBER). Shown in the UI when `showBuildVersion`
* is enabled.
* - `frontend`: frontend build version from SvelteKit's `_app/version.json`,
* generated by the @vite-pwa/sveltekit plugin. Changes on every build, so
* comparing it against localStorage reliably detects server upgrades.
*
* In dev mode, falls back to `'dev'`.
* In dev mode both fall back to `'dev'`.
*/
import { browser } from '$app/environment';
import { base } from '$app/paths';
let version = $state<string>('');
class VersionStore {
build = $state<string>('');
frontend = $state<string>('');
async function loadVersion() {
if (!browser) return;
/**
* Fetch the version files. Called by initStores(); order-independent,
* so it runs in the background.
*/
initialize(): void {
if (!browser) return;
if (import.meta.env.DEV) {
version = 'dev';
if (import.meta.env.DEV) {
this.build = 'dev';
this.frontend = 'dev';
return;
return;
}
void this.load();
}
try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
private async load(): Promise<void> {
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
if (res.ok) {
const data = await res.json();
version = data.version ?? '';
this.build = data.version ?? '';
}
} catch {
// build.json missing or unreachable - leave as empty string
}
try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
this.frontend = data.version ?? '';
}
} catch {
// version.json missing or unreachable - leave as empty string
}
} catch {
// _app/version.json missing or unreachable - leave as empty string
}
}
loadVersion();
export const versionStore = {
get value(): string {
return version;
}
};
export const versionStore = new VersionStore();
@@ -1,9 +0,0 @@
import { browser } from '$app/environment';
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
export const viewport = $state({
width: browser ? window.innerWidth : 0
});
export const isMobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
@@ -1,8 +1,23 @@
/**
* Utility functions for conversation data manipulation
*/
import { MessageRole } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types';
/**
* Model that generated the conversation's latest assistant message, or null
* when no assistant message carries one.
*/
export function getConversationModel(messages: readonly DatabaseMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role === MessageRole.ASSISTANT && message.model) return message.model;
}
return null;
}
/**
* Creates a map of conversation IDs to their message counts from exported conversation data
* @param exportedData - Array of exported conversations with their messages
+1
View File
@@ -54,6 +54,7 @@ export { modelLoadFraction, modelLoadProgressText } from './progress';
export {
createMessageCountMap,
getMessageCount,
getConversationModel,
buildConversationTree,
type ConversationTreeItem
} from './conversation-utils';
+1 -1
View File
@@ -77,7 +77,7 @@
onMount(async () => {
if (!conversationsStore.isInitialized) {
await conversationsStore.initialize();
await conversationsStore.init();
}
conversationsStore.clearActiveConversation();
+12 -8
View File
@@ -19,16 +19,16 @@
import { usePwa } from '$lib/hooks/use-pwa.svelte';
import { RouterService } from '$lib/services/router.service';
import {
buildInfoStore,
chatStore,
conversationsStore,
isMobile,
deviceStore,
mcpStore,
modelsStore,
serverStore,
settingsStore,
theme
versionStore
} from '$lib/stores';
import { initStores } from '$lib/stores/init';
import { ModeWatcher } from 'mode-watcher';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
@@ -36,6 +36,10 @@
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
let { children } = $props();
// migrations and store startup, ordered explicitly instead of import side effects
void initStores();
let innerHeight = $state<number | undefined>();
let innerWidth = $state(browser ? window.innerWidth : 0);
@@ -55,7 +59,7 @@
const { needRefresh, updateServiceWorker } = pwa;
function updateFavicon() {
const dark = theme.isSystemDark;
const dark = deviceStore.systemTheme.isDark;
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
@@ -153,7 +157,7 @@
}
$effect(() => {
void theme.isSystemDark;
void deviceStore.systemTheme.isDark;
updateFavicon();
});
@@ -274,7 +278,7 @@
<div class="flex flex-col md:flex-row">
<SidebarNavigation
onSearchClick={() => {
if (isMobile.current) {
if (deviceStore.isMobile) {
goto(ROUTES.SEARCH);
} else if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
@@ -294,8 +298,8 @@
<!-- PWA update prompt + version -->
<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
{#if showBuildVersion && buildInfoStore.value}
<span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span>
{#if showBuildVersion && versionStore.build}
<span class="text-[10px] tabular-nums text-muted-foreground">{versionStore.build}</span>
{/if}
<PwaRefreshAlert
+2 -2
View File
@@ -5,7 +5,7 @@
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
let searchQuery = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
@@ -23,7 +23,7 @@
// Search page is intended for mobile; on desktop the sidebar already exposes
// in-place search, so bounce back to a chat.
$effect(() => {
if (browser && !isMobile.current) {
if (browser && !deviceStore.isMobile) {
goto(ROUTES.NEW_CHAT, { replaceState: true });
}
});
+19 -39
View File
@@ -1,35 +1,9 @@
import { NEWLINE } from '$lib/constants';
import { MessageRole, MessageType } from '$lib/enums';
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
import type { ExportedConversation } from '$lib/types/database';
import { strToU8, zipSync } from 'fflate';
import { beforeAll, describe, expect, it } from 'vitest';
let conversationsStore: typeof import('$lib/stores/conversations.svelte').conversationsStore;
// node env unit project has no DOM, install a minimal localStorage backed by a
// Map before the store module reads it. Transforming the store takes seconds,
// so import it once for the whole file.
beforeAll(async () => {
const store = new Map<string, string>();
const polyfill: Storage = {
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
removeItem: (k) => {
store.delete(k);
},
setItem: (k, v) => {
store.set(k, String(v));
}
};
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
({ conversationsStore } = await import('$lib/stores/conversations.svelte'));
}, 30000);
import { describe, expect, it } from 'vitest';
function makeSession(id: string): ExportedConversation {
return {
@@ -54,10 +28,10 @@ function makeSession(id: string): ExportedConversation {
* for `.jsonl`, so the picker cannot filter on it and the filename carries no
* guarantee: a JSONL export must import under any name.
*/
describe('conversationsStore.parseImportFile', () => {
describe('ConversationTransferService.parseImportFile', () => {
it('imports a JSONL export whose name has no meaningful extension', async () => {
const jsonl = conversationsStore.serializeSessionToJsonl(makeSession('a'));
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export'));
const jsonl = ConversationTransferService.serializeSessionToJsonl(makeSession('a'));
const sessions = await ConversationTransferService.parseImportFile(new File([jsonl], 'export'));
expect(sessions).toHaveLength(1);
expect(sessions[0].conv.id).toBe('a');
@@ -66,27 +40,33 @@ describe('conversationsStore.parseImportFile', () => {
it('imports several sessions from one JSONL file', async () => {
const jsonl = [makeSession('a'), makeSession('b')]
.map((session) => conversationsStore.serializeSessionToJsonl(session))
.map((session) => ConversationTransferService.serializeSessionToJsonl(session))
.join(NEWLINE);
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export.txt'));
const sessions = await ConversationTransferService.parseImportFile(
new File([jsonl], 'export.txt')
);
expect(sessions.map((session) => session.conv.id)).toEqual(['a', 'b']);
});
it('imports a ZIP archive whose name has no meaningful extension', async () => {
const zipped = zipSync({
'a.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('a'))),
'b.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('b'))),
'a.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('a'))),
'b.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('b'))),
'notes.txt': strToU8('ignored')
});
const sessions = await conversationsStore.parseImportFile(new File([zipped], 'archive'));
const sessions = await ConversationTransferService.parseImportFile(
new File([zipped], 'archive')
);
expect(sessions.map((session) => session.conv.id).sort()).toEqual(['a', 'b']);
});
it('imports the legacy JSON array format', async () => {
const json = JSON.stringify([makeSession('a')], null, 2);
const sessions = await conversationsStore.parseImportFile(new File([json], 'export.jsonl'));
const sessions = await ConversationTransferService.parseImportFile(
new File([json], 'export.jsonl')
);
expect(sessions).toHaveLength(1);
expect(sessions[0].conv.id).toBe('a');
@@ -94,7 +74,7 @@ describe('conversationsStore.parseImportFile', () => {
it('imports the legacy JSON single object format', async () => {
const json = JSON.stringify(makeSession('a'));
const sessions = await conversationsStore.parseImportFile(new File([json], 'export'));
const sessions = await ConversationTransferService.parseImportFile(new File([json], 'export'));
expect(sessions).toHaveLength(1);
expect(sessions[0].conv.id).toBe('a');
@@ -102,7 +82,7 @@ describe('conversationsStore.parseImportFile', () => {
it('rejects a file that holds neither format', async () => {
await expect(
conversationsStore.parseImportFile(new File(['not an export'], 'export.jsonl'))
ConversationTransferService.parseImportFile(new File(['not an export'], 'export.jsonl'))
).rejects.toThrow();
});
});