mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-18 18:26:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5172627d | ||
|
|
3dc7285b4f | ||
|
|
fdf4c64604 | ||
|
|
04b569142d | ||
|
|
0596704284 | ||
|
|
0882c7bc89 | ||
|
|
afd439df1f | ||
|
|
169e4a7ff2 | ||
|
|
9d77fa1725 | ||
|
|
c0296022f3 | ||
|
|
7acdbb1f19 |
+10
-10
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
<b>LLM inference in C/C++</b>
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/ggml-org/llama.cpp/releases)
|
||||
[](https://github.com/ggml-org/llama.cpp/releases?q=tag:v0)
|
||||
[](https://github.com/ggml-org/llama.cpp/releases)
|
||||
[](https://github.com/ggml-org/llama.cpp/actions/workflows/server.yml)
|
||||
[](https://github.com/ggml-org/llama.cpp/actions/workflows/docker.yml)
|
||||
[](https://github.com/ggml-org/llama.cpp/actions/workflows/winget.yml)
|
||||
[](https://github.com/ggml-org/llama.cpp/actions/workflows/docker.yml)
|
||||
[](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)
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
-2
@@ -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} />
|
||||
|
||||
+5
-26
@@ -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}
|
||||
|
||||
+3
-4
@@ -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
-1
@@ -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;
|
||||
|
||||
+6
-1
@@ -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();
|
||||
|
||||
+2
-2
@@ -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 });
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 });
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
+11
-11
@@ -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();
|
||||
}
|
||||
}}
|
||||
|
||||
+4
-4
@@ -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?.();
|
||||
|
||||
+4
-3
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,7 @@ export { modelLoadFraction, modelLoadProgressText } from './progress';
|
||||
export {
|
||||
createMessageCountMap,
|
||||
getMessageCount,
|
||||
getConversationModel,
|
||||
buildConversationTree,
|
||||
type ConversationTreeItem
|
||||
} from './conversation-utils';
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!conversationsStore.isInitialized) {
|
||||
await conversationsStore.initialize();
|
||||
await conversationsStore.init();
|
||||
}
|
||||
|
||||
conversationsStore.clearActiveConversation();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user