mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 17:38:20 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b3f50355 | ||
|
|
d7f5c6dd11 | ||
|
|
0c0b7b8751 | ||
|
|
86ffdbeb17 | ||
|
|
aac067021c | ||
|
|
e2b5378403 | ||
|
|
ba5bef2d61 | ||
|
|
3e0210663b | ||
|
|
437668151d | ||
|
|
0bdcef4682 | ||
|
|
408a5ca37b | ||
|
|
10df7ace1d | ||
|
|
d6eaab500f | ||
|
|
aa678f10d0 | ||
|
|
af1ffd913a | ||
|
|
edb7c4819b | ||
|
|
74a4441354 | ||
|
|
bee9168114 | ||
|
|
f699fc0a7d | ||
|
|
96d48dff45 | ||
|
|
99cbdd5592 | ||
|
|
7faeb8c264 | ||
|
|
39ea3e5216 | ||
|
|
60ab1e52f3 | ||
|
|
bfbe7fab2b | ||
|
|
85e7c83d96 | ||
|
|
199fbc7a45 | ||
|
|
9ed3c77899 | ||
|
|
c1d5e4d5e5 | ||
|
|
14a4636b83 | ||
|
|
5d56ff8c06 | ||
|
|
cbef1a9ff6 | ||
|
|
2b8179f3e6 | ||
|
|
ce4197ce81 | ||
|
|
3fda37bd8c | ||
|
|
3c4afa8d78 | ||
|
|
975eaab2bb | ||
|
|
0fa4288d30 | ||
|
|
90c77357fb | ||
|
|
1814da7c44 | ||
|
|
64b48c0d00 | ||
|
|
5d363f11ea | ||
|
|
aba888972a | ||
|
|
a87c399c91 | ||
|
|
e10be676b1 | ||
|
|
0269e2c42a | ||
|
|
6f5cf9f6b7 | ||
|
|
be2d275361 | ||
|
|
c7272c70db | ||
|
|
8768e95f12 | ||
|
|
548fdf061e | ||
|
|
3e009d2b75 | ||
|
|
e82c706981 | ||
|
|
68bead1082 | ||
|
|
85269ca155 | ||
|
|
1d5096beba | ||
|
|
e647e132d5 | ||
|
|
8ad605789b | ||
|
|
792b626980 | ||
|
|
cd66956ca6 | ||
|
|
16904848bc | ||
|
|
0855796e68 | ||
|
|
71395ec9c7 | ||
|
|
9cb1dc6454 | ||
|
|
b247f4ecda | ||
|
|
d33d45ade2 | ||
|
|
cabb271145 | ||
|
|
094bb3f0a3 | ||
|
|
0481347ec0 | ||
|
|
b75e8a975d | ||
|
|
2204582932 | ||
|
|
22a3d70688 | ||
|
|
d8b6ce356c | ||
|
|
d73ce8608e | ||
|
|
127d829018 | ||
|
|
4c45967460 | ||
|
|
74f390538c | ||
|
|
50c2db6905 | ||
|
|
a8767488ce | ||
|
|
f4d8688e8c | ||
|
|
4d482e627c | ||
|
|
66331f9bc0 | ||
|
|
daf4ac087a | ||
|
|
576d06553c | ||
|
|
2bfd151578 | ||
|
|
049ae1c678 | ||
|
|
39851463bb | ||
|
|
3c95402c4f | ||
|
|
b776e1cd02 | ||
|
|
c08fd2ecc8 | ||
|
|
97da46cc1c | ||
|
|
f1fd753de8 | ||
|
|
b3c6338689 | ||
|
|
0d922698f2 | ||
|
|
3f6960f1fc | ||
|
|
65cff54c0d | ||
|
|
66385404ff | ||
|
|
632b611df9 | ||
|
|
f8f97d9f36 | ||
|
|
5af35d050e | ||
|
|
c6dda74bf2 | ||
|
|
64abc62393 | ||
|
|
25fe8bca8e | ||
|
|
d0f86f8359 | ||
|
|
c30aa64754 | ||
|
|
8431dde5d4 | ||
|
|
efaa100b37 | ||
|
|
0b5096edd4 | ||
|
|
b526f98104 | ||
|
|
fce58233fb | ||
|
|
acbaa4391b | ||
|
|
5899c5bbfd | ||
|
|
380212689a | ||
|
|
c3109534d0 | ||
|
|
3434ed0e3b | ||
|
|
474fe147de | ||
|
|
fe4a03e2f5 | ||
|
|
a09ab1c781 | ||
|
|
bae1e1d4d3 | ||
|
|
ac944fc1f8 | ||
|
|
faf3db296e | ||
|
|
16ff27af85 | ||
|
|
5bf9626fed | ||
|
|
f374b5029d | ||
|
|
3bbf0f399e | ||
|
|
06eb02b1da | ||
|
|
c7596ce873 | ||
|
|
ad142b468c | ||
|
|
23dda3099f | ||
|
|
2d5319cef1 | ||
|
|
ff36fa9282 | ||
|
|
4be6a003aa | ||
|
|
8181522f15 | ||
|
|
a837fb450b | ||
|
|
888c5eca84 | ||
|
|
eb4f41fb9a | ||
|
|
a3a10b90af | ||
|
|
0e76645a73 | ||
|
|
ac1dfef883 | ||
|
|
1fef27e836 | ||
|
|
aa12025515 | ||
|
|
dbdd81a675 | ||
|
|
8b98b6f1ca | ||
|
|
a1c7f05059 | ||
|
|
081edf2d9b | ||
|
|
181b668e5a | ||
|
|
ae5e73de20 | ||
|
|
938b76eb3e | ||
|
|
75bbf42912 | ||
|
|
c65b9f8be8 | ||
|
|
0748e92701 | ||
|
|
f6cea71a78 | ||
|
|
2d8ead05b8 | ||
|
|
212d15ba88 | ||
|
|
52ac4e7337 | ||
|
|
c850b979b5 | ||
|
|
95cb87d427 | ||
|
|
c2de8af1c4 | ||
|
|
dfc675848f | ||
|
|
31a7351534 | ||
|
|
ee0b367fb7 | ||
|
|
c2335eebba | ||
|
|
9149835c6a | ||
|
|
62853e78de | ||
|
|
f95411b843 | ||
|
|
e2f7c47447 | ||
|
|
e318910206 | ||
|
|
fc5dac23c5 | ||
|
|
3671e77acd | ||
|
|
22f4dbe0e1 | ||
|
|
55a2bcd474 | ||
|
|
07e1508893 | ||
|
|
2613b4de5a | ||
|
|
66cfc4bcdb | ||
|
|
d2dc05557e | ||
|
|
0ffbf096f7 | ||
|
|
fcd1493c1a | ||
|
|
c2d44e93ac | ||
|
|
d00d937b89 | ||
|
|
213f1a3905 | ||
|
|
6983b09bba | ||
|
|
38c08443e0 | ||
|
|
37e4fcf9ac | ||
|
|
cde3399b31 | ||
|
|
a1e6ef3246 | ||
|
|
e364620c56 | ||
|
|
400e26e3a8 | ||
|
|
0ed3a193ac | ||
|
|
9f891886d6 | ||
|
|
73dabf548d | ||
|
|
3f15274e8f | ||
|
|
c984ab42a0 | ||
|
|
e8570ac616 | ||
|
|
8088d056f0 | ||
|
|
deebb73a65 | ||
|
|
750cbf0dbd | ||
|
|
697fec9d06 | ||
|
|
8969119a42 | ||
|
|
a2aa1f3122 | ||
|
|
d716ccfca6 | ||
|
|
838b2e0de6 | ||
|
|
8fbf6b1902 | ||
|
|
1bf5e13e7f | ||
|
|
082af9c19a | ||
|
|
4a49d25975 |
No files matched your search
@@ -66,7 +66,7 @@ body:
|
||||
attributes:
|
||||
label: Strata version
|
||||
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
|
||||
placeholder: "Strata 0.2.10 · engine 0.1.56"
|
||||
placeholder: "Strata 0.2.11 · engine 0.1.57"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
Version 0.2.10, engine 0.1.56. The public model families are DeepSeekV4.1-Flash-1017,
|
||||
Version 0.2.11, engine 0.1.57. The public model families are DeepSeekV4.1-Flash-1017,
|
||||
Qwen3.8-Flash-Next and Qwen3.6-35B-A3B. DeepSeek V4.1-Flash runs in the app and in the Terminal
|
||||
(`strata launch --family deepseek41 --model ist-das-2.00bpw`; sets in
|
||||
[docs/MODELS.md](docs/MODELS.md#deepseek-v41-flash)). Read **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** for model availability, APIs, hardware limits and
|
||||
|
||||
+51
-1
@@ -8,7 +8,7 @@
|
||||
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
|
||||
# and is wrong about its own performance, which is the worst of both outcomes.
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
project(strata VERSION 0.1.56 LANGUAGES CXX)
|
||||
project(strata VERSION 0.1.57 LANGUAGES CXX)
|
||||
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
|
||||
# own project that takes the unported sources from src/; with this on, nothing below is configured.
|
||||
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
|
||||
@@ -388,6 +388,11 @@ if(STRATA_BUILD_TESTS)
|
||||
target_include_directories(stage_reserve_test PRIVATE include)
|
||||
add_test(NAME stage_reserve_test COMMAND stage_reserve_test)
|
||||
|
||||
# the layer split's spill walk (header-only arithmetic: who is short, which step comes next)
|
||||
add_executable(split_walk_test tests/program/split_walk_test.cpp)
|
||||
target_include_directories(split_walk_test PRIVATE include)
|
||||
add_test(NAME split_walk_test COMMAND split_walk_test)
|
||||
|
||||
# the prompt planner's host-memory model (#2824): header-only, no GPU
|
||||
add_executable(prefill_plan_test tests/program/prefill_plan_test.cpp)
|
||||
target_include_directories(prefill_plan_test PRIVATE include)
|
||||
@@ -1404,6 +1409,10 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
|
||||
target_link_libraries(gemm_dequant_side_parity PRIVATE strata_prefill )
|
||||
add_test(NAME gemm_dequant_side_parity COMMAND gemm_dequant_side_parity)
|
||||
set_tests_properties(gemm_dequant_side_parity PROPERTIES SKIP_RETURN_CODE 77)
|
||||
add_executable(gemm_w16_parity src/prefill/gemm_w16_parity.cu)
|
||||
target_link_libraries(gemm_w16_parity PRIVATE strata_prefill )
|
||||
add_test(NAME gemm_w16_parity COMMAND gemm_w16_parity)
|
||||
set_tests_properties(gemm_w16_parity PROPERTIES SKIP_RETURN_CODE 77)
|
||||
# Pascal, opt-in STRATA_DENSE_F32=1: the dense projections' FP16 GEMMs as cublasSgemm on FP32 copies, bitwise vs
|
||||
# the cuBLAS FP16 call over a sweep of chunk sizes (GPU, synthetic, no model; --full sweeps every size, --bench
|
||||
# adds timings). Not a ctest: whether the two agree is a property of the cuBLAS build, which this checks.
|
||||
@@ -1866,6 +1875,9 @@ if(STRATA_BUILD_TESTS)
|
||||
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
|
||||
target_include_directories(arch_defaults_test PRIVATE include)
|
||||
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
|
||||
add_executable(pcie_share_rule_test src/core/pcie_share_rule_test.cpp)
|
||||
target_include_directories(pcie_share_rule_test PRIVATE include)
|
||||
add_test(NAME pcie_share_rule_test COMMAND pcie_share_rule_test)
|
||||
# STRATA_AMD_COMMUNITY_TUNING: the switch turns each community-made AMD change off (header only; run from the source tree)
|
||||
add_executable(amd_community_test src/core/amd_community_test.cpp)
|
||||
target_include_directories(amd_community_test PRIVATE include)
|
||||
@@ -2479,6 +2491,11 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
set_tests_properties(prefill_fused_iq_test_ws PROPERTIES ENVIRONMENT "STRATA_PF_FUSED_WS=1" SKIP_RETURN_CODE 77 TIMEOUT 600)
|
||||
# the MoE input quantized per token and scattered to its rows (mmq::quantize_scatter) against the per-row
|
||||
# gather: the same q8_1 bytes for every MMQ layout (--bench: both at 2K, 8K and 32K tokens)
|
||||
# the Unsloth v3.1 sets' expert formats through MMQ (Q5_0 down only in a STRATA_ORCA_Q4KS_MMQ build), and --bench
|
||||
add_executable(prefill_mmq_v31_test tests/cuda/prefill_mmq_v31_test.cpp)
|
||||
target_link_libraries(prefill_mmq_v31_test PRIVATE strata_mmq strata_kernels ggml-base ${_strata_gpu_blas_target} ${_strata_gpu_runtime_target})
|
||||
add_test(NAME prefill_mmq_v31_test COMMAND prefill_mmq_v31_test)
|
||||
set_tests_properties(prefill_mmq_v31_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
|
||||
add_executable(prefill_mmq_scatter_test tests/cuda/prefill_mmq_scatter_test.cpp)
|
||||
target_link_libraries(prefill_mmq_scatter_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
|
||||
add_test(NAME prefill_mmq_scatter_test COMMAND prefill_mmq_scatter_test)
|
||||
@@ -2684,6 +2701,21 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
set_tests_properties(native_expert_parity_${_name} PROPERTIES TIMEOUT 120)
|
||||
endforeach()
|
||||
add_test(NAME native_expert_parity_q5_1_min COMMAND native_expert_parity --q5_1-min)
|
||||
# Unsloth v3.1 Qwen3.8-Flash-Next sets (UD-IQ1_M .. UD-IQ4_XS, round 0214): every gate/up x down pair their layers use,
|
||||
# at the real shapes (hidden 2560, FF 640): gate/up IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ4_XS (+ IQ1_M, the GSQ-RCO packs'),
|
||||
# down Q2_0 IQ4_NL Q5_0 Q8_0. Q5_0 down (UD-IQ4_XS 33 layers, UD-Q3_K_XL 20) had no test on this model before.
|
||||
foreach(_gu iq1_m iq2_xxs iq2_xs iq2_s iq3_xxs iq4_xs)
|
||||
foreach(_dn q2_0 iq4_nl q5_0 q8_0)
|
||||
add_test(NAME native_expert_parity_v31_${_gu}_${_dn} COMMAND native_expert_parity --synthetic ${_gu}/${_dn})
|
||||
set_tests_properties(native_expert_parity_v31_${_gu}_${_dn} PROPERTIES TIMEOUT 180)
|
||||
endforeach()
|
||||
endforeach()
|
||||
# the dense projections (Q5_0, Q6_K, Q5_K, Q4_K, Q8_0, IQ4_XS, IQ4_NL ...): decode MMVQ and the prompt's dequantizers
|
||||
add_executable(native_dense_parity src/kernels/native_dense_parity.cpp)
|
||||
target_include_directories(native_dense_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(native_dense_parity PRIVATE strata_kernels ggml-base ${_strata_gpu_runtime_target})
|
||||
add_test(NAME native_dense_parity COMMAND native_dense_parity)
|
||||
set_tests_properties(native_dense_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
|
||||
# APR: the trellis expert types and the Hadamard activation rotation, when ggml comes from the agention fork
|
||||
# (the only tree with these types; marked by its ggml-cuda/tq.cuh)
|
||||
if(STRATA_GGML_DIR AND EXISTS "${STRATA_GGML_DIR}/ggml/src/ggml-cuda/tq.cuh")
|
||||
@@ -2702,6 +2734,24 @@ if(STRATA_NATIVE_EXPERTS)
|
||||
add_test(NAME native_expert_parity_bf16_embd COMMAND native_expert_parity --bf16-embd)
|
||||
endif()
|
||||
endif()
|
||||
# Round 0214 (the new Unsloth Qwen3.8-Flash-Next quants UD-IQ1_M .. UD-IQ4_XS), CUDA and HIP: every expert type pair the
|
||||
# sets use at 2560/640 (GPU decode kernels, the CPU pool and the prompt path's dequantizers against ggml), and the dense
|
||||
# tensor types (Q5_0, Q6_K, the K-quants, IQ4_NL, IQ4_XS, Q2_0) through native_mmvq.
|
||||
if(STRATA_BUILD_TESTS AND (STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP))
|
||||
set(_strata_new_gu iq1_m iq2_xxs iq2_xs iq2_s q2_0 iq3_xxs iq4_xs)
|
||||
set(_strata_new_dn q2_0 iq4_nl q5_0 q8_0)
|
||||
foreach(_g ${_strata_new_gu})
|
||||
foreach(_d ${_strata_new_dn})
|
||||
add_test(NAME native_expert_parity_new_${_g}_${_d} COMMAND native_expert_parity --synthetic ${_g}/${_d})
|
||||
set_tests_properties(native_expert_parity_new_${_g}_${_d} PROPERTIES TIMEOUT 300)
|
||||
endforeach()
|
||||
endforeach()
|
||||
add_executable(native_dense_types_parity src/kernels/native_dense_types_parity.cpp)
|
||||
target_include_directories(native_dense_types_parity PRIVATE ${_strata_gpu_include_directories})
|
||||
target_link_libraries(native_dense_types_parity PRIVATE strata_kernels strata_kernels_cpu ${_strata_gpu_runtime_target})
|
||||
add_test(NAME native_dense_types_parity COMMAND native_dense_types_parity)
|
||||
set_tests_properties(native_dense_types_parity PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
|
||||
endif()
|
||||
# #296's real-weight checks (OrcaRouter Q4_K_S files; docs/ORCA_Q4_K_S.md) - test programs, not part of a release
|
||||
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS)
|
||||
add_executable(ple_q5_parity src/kernels/ple_q5_parity.cpp)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata-desktop",
|
||||
"productName": "Strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"private": true,
|
||||
"description": "Strata Desktop - local AI, tuned to one model.",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is every static-PIE program inside an AppImage still the program it was staged as? (#4337)
|
||||
|
||||
Why: the bundled ripgrep (`harness/node_modules/@vscode/ripgrep-linux-x64/bin/rg`) is a statically linked PIE. Such a program relocates itself at
|
||||
start-up by reading its own `.dynamic` section through a fixed offset in its code. linuxdeploy, which Tauri runs over the AppDir, treats every ELF file
|
||||
as a library to fix up and rewrites its RPATH; for a static PIE that moves `.dynamic` to a new segment and leaves the old place filled with a filler
|
||||
byte, so the start-up code reads garbage. Reporters on #4337 see the same crash offset in every run (`rg + 0x3ffcf1`) and filler bytes (0x5A) where
|
||||
`.rela.dyn` and `.dynamic` were. A static PIE whose `.dynamic` is filled does not start: tools/test_appimage_elf.py shows that on a real one.
|
||||
apps/desktop/scripts/repack-appimage.sh puts the staged bytes back; this file is the check that says whether it did.
|
||||
|
||||
appimage_elf.py scan <folder> every static PIE under <folder> must have a sound .dynamic (exit 1 if not)
|
||||
appimage_elf.py compare <staged-folder> <folder> the same files, byte for byte, plus what differs in their ELF headers
|
||||
|
||||
What is checked: only static PIE programs. A shared library (.so, Python's and Node's native modules, Pillow, numpy, sharp) is not one, however it is
|
||||
named: it has no entry point, or a DT_SONAME and no DF_1_PIE flag, and its RPATH/RUNPATH is normal. A static PIE is an ET_DYN with no interpreter, an entry
|
||||
point, and DF_1_PIE in DT_FLAGS_1; one whose .dynamic cannot be read at all (overwritten) is reported as damaged, since the overwrite takes the flags too.
|
||||
|
||||
Standard library only; reads ELF64 little endian (x86-64 and arm64), anything else is ignored.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
ET_DYN = 3
|
||||
PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3
|
||||
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_SONAME, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 14, 15, 29
|
||||
DT_FLAGS_1, DF_1_PIE = 0x6FFFFFFB, 0x08000000
|
||||
FILLERS = (0x5A, 0x58) # 'Z' (what the reports found) and 'X' (what patchelf documents)
|
||||
RUN = 128 # that many equal filler bytes in a row is not code or data
|
||||
|
||||
|
||||
def _known_tag(tag):
|
||||
"""The dynamic tags the ELF specification and the GNU and processor ranges define: anything else is garbage."""
|
||||
return 0 <= tag <= 0x22 or 0x6000000D <= tag <= 0x6FFFFFFF or 0x70000000 <= tag <= 0x7FFFFFFF
|
||||
|
||||
|
||||
def read_elf(data):
|
||||
"""The facts about an ELF64 little-endian file, or None when it is not one."""
|
||||
if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
|
||||
return None
|
||||
e_type = struct.unpack_from("<H", data, 16)[0]
|
||||
e_entry = struct.unpack_from("<Q", data, 0x18)[0]
|
||||
phoff = struct.unpack_from("<Q", data, 0x20)[0]
|
||||
phentsize, phnum = struct.unpack_from("<HH", data, 0x36)
|
||||
if phentsize < 56 or phoff + phentsize * phnum > len(data):
|
||||
return {"type": e_type, "entry": e_entry, "phdrs": [], "broken": "the program header table is outside the file"}
|
||||
phdrs = []
|
||||
for i in range(phnum):
|
||||
p_type, flags, off, vaddr, _paddr, filesz, memsz, align = struct.unpack_from("<IIQQQQQQ", data, phoff + i * phentsize)
|
||||
phdrs.append({"type": p_type, "flags": flags, "off": off, "vaddr": vaddr, "filesz": filesz, "memsz": memsz, "align": align})
|
||||
return {"type": e_type, "entry": e_entry, "phdrs": phdrs, "broken": None}
|
||||
|
||||
|
||||
def is_pie_candidate(info):
|
||||
"""An ET_DYN with no interpreter, a dynamic segment and an entry point: what a static PIE looks like from outside the dynamic section.
|
||||
A shared library normally has no entry point (e_entry 0) and is not a candidate; the few that do (the C library itself, the dynamic loader)
|
||||
have a readable dynamic section and are told apart by it (static_pie_kind). The test uses nothing the damage we look for can erase."""
|
||||
return bool(info) and info["type"] == ET_DYN and not info["broken"] and info.get("entry", 0) != 0 and not any(p["type"] == PT_INTERP for p in info["phdrs"]) and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
|
||||
|
||||
|
||||
def static_pie_kind(data, info):
|
||||
"""'static-pie' for a sound static PIE (DT_FLAGS_1 has DF_1_PIE, no DT_SONAME), 'damaged' for a candidate whose dynamic section cannot be read
|
||||
(a static PIE with an overwritten .dynamic loses its flags with it, so unreadable is treated as damaged), None for everything else:
|
||||
shared libraries, which carry a DT_SONAME and no DF_1_PIE, and programs that are not static PIEs."""
|
||||
if not is_pie_candidate(info):
|
||||
return None
|
||||
entries, why = dynamic_entries(data, info)
|
||||
if why or any(not _known_tag(t) for t, _ in entries):
|
||||
return "damaged"
|
||||
tags = {t: v for t, v in entries}
|
||||
if DT_SONAME in tags:
|
||||
return None
|
||||
return "static-pie" if tags.get(DT_FLAGS_1, 0) & DF_1_PIE else None
|
||||
|
||||
|
||||
def is_static_pie(info):
|
||||
"""Kept for callers: a candidate. check_static_pie decides with static_pie_kind."""
|
||||
return is_pie_candidate(info)
|
||||
|
||||
|
||||
def _mapped(info, vaddr, size):
|
||||
"""The file offset of [vaddr, vaddr+size) when one PT_LOAD maps all of it from the file, else None."""
|
||||
for p in info["phdrs"]:
|
||||
if p["type"] == PT_LOAD and p["vaddr"] <= vaddr and vaddr + size <= p["vaddr"] + p["filesz"]:
|
||||
return p["off"] + (vaddr - p["vaddr"])
|
||||
return None
|
||||
|
||||
|
||||
def dynamic_entries(data, info):
|
||||
"""(tag, value) pairs of the PT_DYNAMIC segment up to DT_NULL, and the problem if it does not end properly."""
|
||||
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
|
||||
if seg["off"] + seg["filesz"] > len(data):
|
||||
return [], "the dynamic segment is outside the file"
|
||||
out = []
|
||||
for at in range(seg["off"], seg["off"] + seg["filesz"] - 15, 16):
|
||||
tag, val = struct.unpack_from("<qQ", data, at)
|
||||
if tag == DT_NULL:
|
||||
return out, None
|
||||
out.append((tag, val))
|
||||
return out, "the dynamic segment does not end with DT_NULL"
|
||||
|
||||
|
||||
def _longest_filler(data, start, end):
|
||||
best = run = 0
|
||||
prev = None
|
||||
for b in data[start:end]:
|
||||
run = run + 1 if b == prev and b in FILLERS else (1 if b in FILLERS else 0)
|
||||
prev = b
|
||||
best = max(best, run)
|
||||
return best
|
||||
|
||||
|
||||
def check_static_pie(data):
|
||||
"""The problems of one file (a list of sentences); empty for a sound static PIE and for anything that is not one."""
|
||||
info = read_elf(data)
|
||||
if static_pie_kind(data, info) is None:
|
||||
return []
|
||||
problems = []
|
||||
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
|
||||
if _mapped(info, seg["vaddr"], max(seg["filesz"], 1)) is None:
|
||||
problems.append("the dynamic segment is not inside any loaded segment")
|
||||
entries, why = dynamic_entries(data, info)
|
||||
if why:
|
||||
problems.append(why)
|
||||
bad = [t for t, _ in entries if not _known_tag(t)]
|
||||
if bad:
|
||||
problems.append(f"{len(bad)} dynamic entries have tags that do not exist (first {bad[0]:#x}): the section is overwritten")
|
||||
if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in entries):
|
||||
problems.append("a static PIE has an RPATH or RUNPATH: something rewrote it after it was linked")
|
||||
values = dict(entries)
|
||||
if DT_RELA in values:
|
||||
size = values.get(DT_RELASZ, 0)
|
||||
at = _mapped(info, values[DT_RELA], max(size, 1))
|
||||
if size == 0 or at is None:
|
||||
problems.append("the relocation table named by DT_RELA is empty or outside the loaded segments")
|
||||
elif _longest_filler(data, at, at + size) >= min(RUN, size):
|
||||
problems.append("the relocation table is filled with one byte")
|
||||
for p in info["phdrs"]:
|
||||
if p["type"] == PT_LOAD and _longest_filler(data, p["off"], p["off"] + p["filesz"]) >= RUN:
|
||||
problems.append(f"{RUN} or more filler bytes (0x5A or 0x58) in a row inside a loaded segment")
|
||||
break
|
||||
return problems
|
||||
|
||||
|
||||
def elf_differences(staged, packaged):
|
||||
"""What differs between two builds of one program in the parts that matter for start-up: for the report."""
|
||||
a, b = read_elf(staged), read_elf(packaged)
|
||||
if not a or not b:
|
||||
return [] if staged == packaged else ["one of the two is not an ELF64 file"]
|
||||
out = []
|
||||
ta, tb = [p["type"] for p in a["phdrs"]], [p["type"] for p in b["phdrs"]]
|
||||
if ta != tb:
|
||||
out.append(f"program headers {len(ta)} -> {len(tb)} ({ta.count(PT_LOAD)} -> {tb.count(PT_LOAD)} loaded segments)")
|
||||
if is_pie_candidate(a) and is_pie_candidate(b):
|
||||
da, _ = dynamic_entries(staged, a)
|
||||
db, _ = dynamic_entries(packaged, b)
|
||||
if da != db:
|
||||
out.append(f"dynamic section: {len(da)} -> {len(db)} entries" + ("; an RPATH/RUNPATH was added" if any(t in (DT_RPATH, DT_RUNPATH) for t, _ in db) and not any(t in (DT_RPATH, DT_RUNPATH) for t, _ in da) else ""))
|
||||
return out
|
||||
|
||||
|
||||
def _files(folder):
|
||||
for base, dirs, names in os.walk(folder):
|
||||
dirs.sort()
|
||||
for name in sorted(names):
|
||||
path = os.path.join(base, name)
|
||||
if os.path.islink(path) or not os.path.isfile(path):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def scan(folder):
|
||||
"""{relative path: problems} for every static PIE under `folder` that has any."""
|
||||
found = {}
|
||||
for path in _files(folder):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(4)
|
||||
if head != b"\x7fELF":
|
||||
continue
|
||||
data = head + f.read()
|
||||
except OSError:
|
||||
continue
|
||||
problems = check_static_pie(data)
|
||||
if problems:
|
||||
found[os.path.relpath(path, folder)] = problems
|
||||
return found
|
||||
|
||||
|
||||
def compare(staged_folder, folder):
|
||||
"""{relative path: sentences} for every staged ELF file that is missing or differs in `folder`."""
|
||||
found = {}
|
||||
for path in _files(staged_folder):
|
||||
rel = os.path.relpath(path, staged_folder)
|
||||
with open(path, "rb") as f:
|
||||
want = f.read()
|
||||
if want[:4] != b"\x7fELF":
|
||||
continue
|
||||
other = os.path.join(folder, rel)
|
||||
if not os.path.isfile(other):
|
||||
found[rel] = ["missing"]
|
||||
continue
|
||||
with open(other, "rb") as f:
|
||||
have = f.read()
|
||||
if have != want:
|
||||
found[rel] = ["bytes differ"] + elf_differences(want, have)
|
||||
return found
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) >= 3 and argv[1] == "scan":
|
||||
bad = scan(argv[2])
|
||||
for rel, problems in bad.items():
|
||||
print(f"BROKEN {rel}: " + "; ".join(problems))
|
||||
print(f"{len(bad)} static PIE file(s) with a damaged dynamic section")
|
||||
return 1 if bad else 0
|
||||
if len(argv) >= 4 and argv[1] == "compare":
|
||||
bad = compare(argv[2], argv[3])
|
||||
for rel, why in bad.items():
|
||||
print(f"DIFFERS {rel}: " + "; ".join(why))
|
||||
print(f"{len(bad)} ELF file(s) differ from what was staged")
|
||||
return 1 if bad else 0
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -9,7 +9,9 @@
|
||||
# installer's own integrity promise ("what we staged is what runs") is broken. This script unpacks the AppImage, copies the staged
|
||||
# folders (control, harness, node, engine-bundle) over the AppDir byte for byte, and packs it again with the same runtime.
|
||||
#
|
||||
# --check does not write anything: it lists every engine-bundle file whose bytes differ from the manifest in the AppImage.
|
||||
# --check does not write anything: it lists every file of control, harness, node and engine-bundle whose bytes differ from what was staged, then
|
||||
# looks at every static PIE in the AppImage (appimage_elf.py: the bundled ripgrep is one; linuxdeploy's RPATH rewrite leaves such a program with a
|
||||
# filled .dynamic and it crashes at start, #4337) and runs the bundled `rg` once. A single "0 file(s) differ" and "ok" is what a release needs.
|
||||
set -euo pipefail
|
||||
|
||||
IN="$(realpath "${1:?in.AppImage}")"; OUT="${2:-}"
|
||||
@@ -33,13 +35,36 @@ LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name engine-bundle -prin
|
||||
[ -n "$LIB" ] || LIB="$(find "$WORK/AppDir/usr/lib" -maxdepth 2 -type d -name control -printf '%h\n' | head -1)"
|
||||
[ -n "$LIB" ] || { echo "no Strata resource folder in the AppImage" >&2; exit 1; }
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Every static PIE under the AppDir must still have a sound .dynamic, and the bundled ripgrep must start (x86-64 Linux only: it is run).
|
||||
elf_and_rg_check() {
|
||||
local bad=0
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 "$HERE/appimage_elf.py" scan "$LIB" || bad=1
|
||||
else
|
||||
echo "python3 is not here: the static PIE check was skipped" >&2
|
||||
fi
|
||||
local rg
|
||||
rg="$(find "$LIB/harness" -path '*ripgrep-linux-x64/bin/rg' -type f 2>/dev/null | head -1)"
|
||||
if [ -n "$rg" ] && [ "$(uname -s)" = Linux ] && [ "$(uname -m)" = x86_64 ]; then
|
||||
local t; t="$(mktemp -d -t strata-rg.XXXXXX)"
|
||||
echo 'needle in a haystack' > "$t/h.txt"
|
||||
if "$rg" --version >/dev/null 2>&1 && [ "$("$rg" --no-config -c needle "$t/h.txt" 2>/dev/null)" = 1 ]; then echo "bundled rg runs: ok"
|
||||
else echo "the bundled rg does not run: $rg" >&2; bad=1; fi
|
||||
rm -rf "$t"
|
||||
fi
|
||||
return $bad
|
||||
}
|
||||
|
||||
if [ "$CHECK" = 1 ]; then
|
||||
bad=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#$RES/}"
|
||||
if ! cmp -s "$f" "$LIB/$rel"; then echo "differs: $rel"; bad=$((bad + 1)); fi
|
||||
done < <(find "$RES/engine-bundle" "$RES/node" -type f ! -name .gitignore)
|
||||
done < <(for d in control harness node engine-bundle; do [ -d "$RES/$d" ] && find "$RES/$d" -type f ! -name .gitignore; done)
|
||||
echo "$bad file(s) differ from what was staged"
|
||||
elf_and_rg_check || bad=$((bad + 1))
|
||||
[ "$bad" = 0 ]
|
||||
exit
|
||||
fi
|
||||
@@ -49,6 +74,8 @@ for d in control harness node engine-bundle; do
|
||||
[ -d "$RES/$d" ] || continue
|
||||
rsync -aL --checksum --delete --exclude .gitignore "$RES/$d/" "$LIB/$d/"
|
||||
done
|
||||
# What was put back must be sound before it is packed: a damaged static PIE (the bundled ripgrep) stops the pack here, not on a person's computer.
|
||||
elf_and_rg_check || { echo "the restored AppDir failed the static PIE / ripgrep check; not packing" >&2; exit 1; }
|
||||
# The launcher records the environment the AppImage was started from, before its own hooks add the AppImage's libraries and GTK/GIO
|
||||
# settings (LD_LIBRARY_PATH, GTK_*, XDG_DATA_DIRS ...). The host gives it back to every program that is not Strata's own (zenity,
|
||||
# kdialog, xdg-open, the assistant's shell): apps/host/server/external-env.mjs. Without it a system program loads the AppImage's libssl
|
||||
|
||||
@@ -616,10 +616,10 @@ await scenario('stop', { installed: true, env: { FAKE_TOKEN_DELAY: '0.1', FAKE_R
|
||||
await scenario('latest', {}, async ({ base, browser, shot }) => {
|
||||
const page = await newPage(browser, base);
|
||||
const now = Date.now();
|
||||
await page.evaluate(now => {
|
||||
await page.evaluate(async now => {
|
||||
const messages = [];
|
||||
for (let i = 0; i < 40; i++) { messages.push({ id: 'u' + i, role: 'user', content: 'question ' + i, at: now }, { id: 'a' + i, role: 'assistant', content: 'answer number ' + i + ' with some text to fill the line and more of it.', at: now, tokens: 20, inputTokens: 5 }); }
|
||||
localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }]));
|
||||
await fetch('/api/chats/s_latest', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 's_latest', title: 'long', updatedAt: now, createdAt: now, messages }) });
|
||||
localStorage.setItem('strata.active-session.v1', 's_latest');
|
||||
}, now);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
@@ -690,12 +690,12 @@ if (only.has('restart')) {
|
||||
await run(async (context, { base }) => {
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); localStorage.setItem('strata.sessions.v1', JSON.stringify([{ id: 'chat-1', title: 'My chat' }])); });
|
||||
await page.evaluate(() => { localStorage.setItem('strata.prefs.v2', JSON.stringify({ theme: 'dark', contentFontSize: 19, language: 'de' })); return fetch('/api/chats/chat-1', { method: 'PUT', headers: { 'x-strata-client': 'ui' }, body: JSON.stringify({ id: 'chat-1', title: 'My chat', messages: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) }); });
|
||||
});
|
||||
await run(async (context, { base }) => {
|
||||
const page = context.pages()[0] ?? await context.newPage();
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
const saved = await page.evaluate(() => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: localStorage.getItem('strata.sessions.v1'), theme: document.documentElement.dataset.theme }));
|
||||
const saved = await page.evaluate(async () => ({ prefs: JSON.parse(localStorage.getItem('strata.prefs.v2') || '{}'), chats: JSON.stringify(await (await fetch('/api/chats', { headers: { 'x-strata-client': 'ui' } })).json()), theme: document.documentElement.dataset.theme }));
|
||||
check(saved.prefs.contentFontSize === 19 && saved.prefs.language === 'de', 'text size and language survive a restart on the same port', JSON.stringify(saved.prefs));
|
||||
check(saved.theme === 'dark', 'the theme survives too', saved.theme);
|
||||
check(/My chat/.test(saved.chats ?? ''), 'and the chat list', saved.chats);
|
||||
|
||||
Generated
+3
-1
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
|
||||
|
||||
[[package]]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"serde",
|
||||
@@ -3637,6 +3637,8 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "strata-desktop"
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
description = "Strata Desktop — local AI, tuned to one model."
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
@@ -11,6 +11,11 @@ license = "MIT"
|
||||
gtk = "0.18"
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
# Windows: the shell listens for the page's renderer failing (WebView2 ProcessFailed, see hook_renderer_failures in main.rs); these are the versions wry itself uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
webview2-com = "0.39"
|
||||
windows-core = "0.62"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ fn graphics_environment() -> Vec<String> {
|
||||
const NAMES: &[&str] = &[
|
||||
"XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "DISPLAY", "WAYLAND_DISPLAY", "GDK_BACKEND", "GTK_THEME",
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER", "WEBKIT_DISABLE_COMPOSITING_MODE", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "LIBGL_ALWAYS_SOFTWARE",
|
||||
"GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
|
||||
"FONTCONFIG_FILE", "FONTCONFIG_PATH", "STRATA_FONT_CONFIG", "GALLIUM_DRIVER", "__GLX_VENDOR_LIBRARY_NAME", "__NV_PRIME_RENDER_OFFLOAD", "MESA_LOADER_DRIVER_OVERRIDE", "APPIMAGE", "APPDIR",
|
||||
];
|
||||
NAMES.iter().filter_map(|n| std::env::var(n).ok().map(|v| format!(" {n}={v}"))).collect()
|
||||
}
|
||||
@@ -584,7 +584,7 @@ mod tests {
|
||||
let path = write_startup_failure(&dir, "the app stopped unexpectedly: boom (at main.rs:1, thread main)", Some(Path::new("/x/desktop-1.log"))).unwrap();
|
||||
assert!(path.file_name().unwrap().to_string_lossy().starts_with("desktop-crash-"));
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", "0.2.0", "System:", "Meaning:"] {
|
||||
for needle in ["boom", "main.rs:1", "/x/desktop-1.log", env!("CARGO_PKG_VERSION"), "System:", "Meaning:"] {
|
||||
assert!(text.contains(needle), "{needle}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Linux: keep the colour emoji font that the AppImage's WebKitGTK cannot draw away from the app window (#4533).
|
||||
//!
|
||||
//! The WebKitGTK inside the AppImage paints a COLRv1 colour font through Skia, and its `colrv1_configure_skpaint` reads past the end of a
|
||||
//! gradient's colour-stop list. The library is built with libstdc++ range checks on, so the read becomes `abort()` and `WebKitWebProcess`
|
||||
//! dies: the window goes grey the first time a chat title or a message draws an emoji (Fedora 44 and newer Ubuntu ship
|
||||
//! `Noto-COLRv1.ttf` as the default colour emoji font; the reporters on #4533 name the same assertion and the same workaround).
|
||||
//!
|
||||
//! fontconfig reads one file when `FONTCONFIG_FILE` is set, and that file replaces the normal configuration, so the file written here
|
||||
//! includes the system configuration first and only then rejects the COLRv1 faces. Emoji are then drawn from whatever else the system
|
||||
//! has (the outline "Noto Emoji" is preferred); nothing else about the fonts changes.
|
||||
//!
|
||||
//! Only the window process is told (main.rs run_window_process, before WebKit starts its own processes). The shell owns the host, the
|
||||
//! engine and the agent, and they keep the fonts the person chose. A machine with no COLRv1 font is not touched at all.
|
||||
//!
|
||||
//! This file is plain std so its decisions are unit-tested on every platform; only the window process on Linux uses it.
|
||||
//! The idea is that of community PR #4485; the code is written for this tree.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// File name patterns of the COLRv1 faces (fontconfig globs, case sensitive, so both spellings). Bitmap (`CBDT`) and outline faces are not
|
||||
/// listed: they draw fine, and hiding them would leave an empty box where an emoji should be.
|
||||
pub const COLRV1_GLOBS: &[&str] = &["*COLRv1*", "*colrv1*", "*COLRV1*"];
|
||||
|
||||
/// The outline family preferred in place of the colour one.
|
||||
const OUTLINE_EMOJI: &str = "Noto Emoji";
|
||||
|
||||
/// fontconfig's own configuration. `FONTCONFIG_FILE` replaces it, so the written file pulls it back in.
|
||||
pub const SYSTEM_CONFIG: &str = "/etc/fonts/fonts.conf";
|
||||
|
||||
/// `STRATA_FONT_CONFIG=off` turns this off; `STRATA_FONT_CONFIG=/path/to/fonts.conf` uses the person's own file.
|
||||
const OFF: &str = "off";
|
||||
|
||||
/// The name of the file written under the data folder.
|
||||
const FILE_NAME: &str = "fonts-no-colrv1.conf";
|
||||
|
||||
/// Folders fontconfig scans by default, where a colour emoji font is found: the system ones, the person's own, and the XDG data folders.
|
||||
pub fn font_dirs(env: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![PathBuf::from("/usr/share/fonts"), PathBuf::from("/usr/local/share/fonts")];
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
match env("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
|
||||
Some(data) => dirs.push(PathBuf::from(data).join("fonts")),
|
||||
None => {
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".local/share/fonts"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(h) = &home {
|
||||
dirs.push(h.join(".fonts"));
|
||||
}
|
||||
if let Some(list) = env("XDG_DATA_DIRS") {
|
||||
for d in list.split(':').filter(|d| !d.is_empty()) {
|
||||
dirs.push(PathBuf::from(d).join("fonts"));
|
||||
}
|
||||
}
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A COLRv1 font file installed under `dirs`, if any. The scan is shallow and stops at the first one found, so a window that starts on a
|
||||
/// machine without such a font costs a few directory reads and nothing else.
|
||||
pub fn colrv1_font_present(dirs: &[PathBuf]) -> Option<PathBuf> {
|
||||
fn scan(dir: &Path, depth: u8, found: &mut Option<PathBuf>) {
|
||||
if found.is_some() || depth == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan(&path, depth - 1, found);
|
||||
}
|
||||
else if path.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase().contains("colrv1")).unwrap_or(false) {
|
||||
*found = Some(path);
|
||||
return;
|
||||
}
|
||||
if found.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut found = None;
|
||||
for dir in dirs {
|
||||
scan(dir, 5, &mut found);
|
||||
if found.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The configuration file's text: the system's own rules, then the rejection of the COLRv1 faces, then the outline family preferred.
|
||||
pub fn config_text(system_config: &str) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"<?xml version=\"1.0\"?>".into(),
|
||||
"<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">".into(),
|
||||
"<!-- Written by Strata for its own window only. The WebKitGTK inside the AppImage aborts while it draws a COLRv1 colour font,".into(),
|
||||
" so those faces are hidden from the window; every other font is the system's own setup, included first.".into(),
|
||||
" Off: STRATA_FONT_CONFIG=off. Your own file: set FONTCONFIG_FILE yourself. -->".into(),
|
||||
"<fontconfig>".into(),
|
||||
format!(" <include ignore_missing=\"yes\">{system_config}</include>"),
|
||||
" <selectfont>".into(),
|
||||
];
|
||||
// fontconfig rejects a font only inside <selectfont>
|
||||
for glob in COLRV1_GLOBS {
|
||||
lines.push(format!(" <rejectfont><glob>{glob}</glob></rejectfont>"));
|
||||
}
|
||||
lines.push(" </selectfont>".into());
|
||||
lines.push(" <alias binding=\"strong\">".into());
|
||||
lines.push(" <family>Noto Color Emoji</family>".into());
|
||||
lines.push(format!(" <prefer><family>{OUTLINE_EMOJI}</family></prefer>"));
|
||||
lines.push(" </alias>".into());
|
||||
lines.push("</fontconfig>".into());
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// Write the file under `dir` and return its path, or None when it cannot be written or there is no system configuration to include
|
||||
/// (then the window would be left without any font). Rewritten only when the contents differ, so a start-up touches the disk once, and a
|
||||
/// file left by an older Strata is put right. Written through a temporary file and moved, so a second instance starting at the same moment
|
||||
/// never hands fontconfig half a file.
|
||||
pub fn ensure_config(dir: &Path, system_config: &Path) -> Option<PathBuf> {
|
||||
if !system_config.exists() {
|
||||
return None;
|
||||
}
|
||||
std::fs::create_dir_all(dir).ok()?;
|
||||
let path = dir.join(FILE_NAME);
|
||||
let want = config_text(&system_config.to_string_lossy());
|
||||
if matches!(std::fs::read_to_string(&path), Ok(have) if have == want) {
|
||||
return Some(path);
|
||||
}
|
||||
let tmp = dir.join(format!("{FILE_NAME}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, want.as_bytes()).ok()?;
|
||||
if std::fs::rename(&tmp, &path).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
// another writer got there first: take its file only if it is the one wanted
|
||||
return (std::fs::read_to_string(&path).ok()? == want).then_some(path);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// The file the window process should hand to fontconfig, or None to leave the environment exactly as it is. In this order, so a person's
|
||||
/// own choice always wins and a machine without the font is untouched:
|
||||
/// 1. `FONTCONFIG_FILE` already set: theirs.
|
||||
/// 2. `STRATA_FONT_CONFIG=off`: nothing.
|
||||
/// 3. `STRATA_FONT_CONFIG=<path>`: that file, when it exists.
|
||||
/// 4. no COLRv1 font installed: nothing.
|
||||
pub fn config_for(env: &dyn Fn(&str) -> Option<String>, dir: &Path, dirs: &[PathBuf], system_config: &Path) -> Option<PathBuf> {
|
||||
if env("FONTCONFIG_FILE").is_some() || env("STRATA_FONT_CONFIG").as_deref() == Some(OFF) {
|
||||
return None;
|
||||
}
|
||||
if let Some(custom) = env("STRATA_FONT_CONFIG") {
|
||||
let p = PathBuf::from(custom);
|
||||
return p.is_file().then_some(p);
|
||||
}
|
||||
colrv1_font_present(dirs)?;
|
||||
ensure_config(dir, system_config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let owned: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k: &str| owned.iter().find(|(a, _)| a == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("strata-fontguard-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_includes_the_system_config_first_and_rejects_only_colrv1_faces_inside_selectfont() {
|
||||
let text = config_text("/etc/fonts/fonts.conf");
|
||||
assert!(text.starts_with("<?xml"));
|
||||
let include = text.find("/etc/fonts/fonts.conf").expect("the system's fonts are kept");
|
||||
let select = text.find("<selectfont>").expect("a rejection needs <selectfont>");
|
||||
let select_end = text.find("</selectfont>").expect("closed");
|
||||
assert!(include < select && select < select_end);
|
||||
assert_eq!(text.matches("<rejectfont>").count(), COLRV1_GLOBS.len());
|
||||
for glob in COLRV1_GLOBS {
|
||||
let at = text.find(&format!("<glob>{glob}</glob>")).unwrap_or_else(|| panic!("{glob} is rejected"));
|
||||
assert!(at > select && at < select_end, "{glob} is inside <selectfont>");
|
||||
}
|
||||
// the bitmap and the outline faces stay
|
||||
assert!(!text.contains("NotoColorEmoji.ttf") && !text.contains("NotoEmoji-Regular.ttf"));
|
||||
assert!(text.contains("<prefer><family>Noto Emoji</family></prefer>"));
|
||||
assert!(!text.contains('{') && !text.contains('}'), "no placeholder left: {text}");
|
||||
// well formed enough: every opened tag is closed
|
||||
for tag in ["fontconfig", "selectfont", "alias"] {
|
||||
assert_eq!(text.matches(&format!("<{tag}")).count(), text.matches(&format!("</{tag}>")).count(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_globs_match_the_real_file_names_and_not_the_faces_that_draw_fine() {
|
||||
// fontconfig's glob is `*` anywhere; this is the same rule on the file names that matter
|
||||
let hit = |name: &str| COLRV1_GLOBS.iter().any(|g| { let core = g.trim_matches('*'); name.contains(core) });
|
||||
for bad in ["/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf", "/usr/share/fonts/truetype/noto/NotoColorEmoji-COLRv1.ttf",
|
||||
"/home/me/.fonts/Twemoji-colrv1.ttf", "/usr/share/fonts/NotoSansCOLRv1-Regular.ttf"] {
|
||||
assert!(hit(bad), "{bad}");
|
||||
}
|
||||
for good in ["/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf"] {
|
||||
assert!(!hit(good), "{good}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_persons_own_fontconfig_or_an_opt_out_leaves_the_environment_alone() {
|
||||
let dir = scratch("own");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let fonts = dir.join("fonts");
|
||||
std::fs::create_dir_all(&fonts).unwrap();
|
||||
std::fs::write(fonts.join("Noto-COLRv1.ttf"), b"x").unwrap();
|
||||
let dirs = vec![fonts];
|
||||
assert_eq!(config_for(&env_from(&[("FONTCONFIG_FILE", "/home/me/fonts.conf")]), &dir, &dirs, &system), None);
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "off")]), &dir, &dirs, &system), None);
|
||||
let theirs = dir.join("theirs.conf");
|
||||
std::fs::write(&theirs, b"<fontconfig/>").unwrap();
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", theirs.to_str().unwrap())]), &dir, &dirs, &system), Some(theirs));
|
||||
assert_eq!(config_for(&env_from(&[("STRATA_FONT_CONFIG", "/no/such/fonts.conf")]), &dir, &dirs, &system), None);
|
||||
// no setting at all: the font is there, so the file is written
|
||||
assert!(config_for(&env_from(&[]), &dir.join("out"), &dirs, &system).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_without_a_colrv1_font_is_not_touched() {
|
||||
let dir = scratch("none");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let fonts = dir.join("fonts");
|
||||
std::fs::create_dir_all(fonts.join("noto")).unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoColorEmoji.ttf"), b"bitmap").unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoEmoji-Regular.ttf"), b"outline").unwrap();
|
||||
std::fs::write(fonts.join("noto/NotoSansArabic-Regular.ttf"), b"arabic").unwrap();
|
||||
assert_eq!(colrv1_font_present(&[fonts.clone()]), None);
|
||||
assert_eq!(config_for(&env_from(&[]), &dir.join("out"), &[fonts.clone()], &system), None);
|
||||
assert!(!dir.join("out").exists(), "nothing is written");
|
||||
std::fs::write(fonts.join("noto/Noto-COLRv1.ttf"), b"colour").unwrap();
|
||||
assert!(colrv1_font_present(&[fonts]).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scan_is_shallow_and_a_missing_folder_is_fine() {
|
||||
let dir = scratch("deep");
|
||||
let mut deep = dir.clone();
|
||||
for i in 0..9 { deep = deep.join(format!("d{i}")); }
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join("Noto-COLRv1.ttf"), b"x").unwrap();
|
||||
assert_eq!(colrv1_font_present(&[dir.clone(), dir.join("missing")]), None, "nine levels down is not a font folder");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_follow_the_xdg_variables() {
|
||||
let none = font_dirs(&env_from(&[("HOME", "/home/me")]));
|
||||
assert!(none.contains(&PathBuf::from("/usr/share/fonts")));
|
||||
assert!(none.contains(&PathBuf::from("/home/me/.local/share/fonts")));
|
||||
assert!(none.contains(&PathBuf::from("/home/me/.fonts")));
|
||||
let set = font_dirs(&env_from(&[("HOME", "/home/me"), ("XDG_DATA_HOME", "/data/me"), ("XDG_DATA_DIRS", "/usr/local/share:/var/lib/flatpak/exports/share")]));
|
||||
assert!(set.contains(&PathBuf::from("/data/me/fonts")));
|
||||
assert!(!set.contains(&PathBuf::from("/home/me/.local/share/fonts")));
|
||||
assert!(set.contains(&PathBuf::from("/var/lib/flatpak/exports/share/fonts")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_is_written_once_an_old_one_is_put_right_and_nothing_is_left_behind() {
|
||||
let dir = scratch("write");
|
||||
let system = dir.join("system.conf");
|
||||
std::fs::write(&system, b"<fontconfig/>").unwrap();
|
||||
let out = dir.join("out");
|
||||
let first = ensure_config(&out, &system).expect("written");
|
||||
assert_eq!(ensure_config(&out, &system).unwrap(), first, "the same path every start");
|
||||
std::fs::write(&first, "<fontconfig/>").unwrap();
|
||||
assert_eq!(ensure_config(&out, &system), Some(first.clone()));
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), config_text(&system.to_string_lossy()));
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 1, "one file, no temporary");
|
||||
// no system configuration to include: nothing is written (the window would have no font at all)
|
||||
assert_eq!(ensure_config(&dir.join("out2"), &dir.join("absent.conf")), None);
|
||||
assert!(!dir.join("out2").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,11 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
mod updater;
|
||||
mod desktoplog;
|
||||
mod recovery;
|
||||
mod shell;
|
||||
// Plain std, built on every platform so its decisions are tested everywhere; only the Linux window process uses it.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod fontguard;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod supervisor;
|
||||
|
||||
@@ -879,7 +883,19 @@ fn main() {
|
||||
/// Windows and macOS: one process owns the host and the window.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn run_embedded() {
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default();
|
||||
// macOS: WKWebView ends the page's web content process under memory pressure and leaves a blank window; the page is loaded again (#5079).
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.on_web_content_process_terminate(|webview| {
|
||||
let port = webview.app_handle().state::<HostPort>().0.lock().ok().and_then(|p| *p);
|
||||
let Some(port) = port else { return };
|
||||
let failed = webview.url().ok().map(|u| u.to_string());
|
||||
let target = webview.clone();
|
||||
on_renderer_failure(format!("http://127.0.0.1:{port}"), recovery::Failure::RendererExited, "web content process terminated".to_string(), failed, move |url| {
|
||||
if let Ok(url) = url.parse::<tauri::Url>() { let _ = target.navigate(url); }
|
||||
});
|
||||
});
|
||||
builder
|
||||
// First, as the plugin requires: a second launch ends here after telling the running app to show itself.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -987,9 +1003,80 @@ fn open_window(app: &tauri::App, port: u16, shell_token: &str) -> Result<(), Box
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
hook_linux_drops(&window, port, shell_token);
|
||||
#[cfg(windows)]
|
||||
hook_renderer_failures(&window, port);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Milliseconds since the first failure report or window (a clock that only goes forward, for the recovery limit).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn uptime_ms() -> u64 {
|
||||
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
|
||||
START.get_or_init(Instant::now).elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// The page's renderer is gone (#5079). Writes it to the run log, and, when the window needs the page again, loads it again through
|
||||
/// `navigate` after a short wait: the same screen with `?recovery=1` the first time (the page then starts with the sidebar previews
|
||||
/// closed once), the home screen the second time, and nothing after two in two minutes. The chats are the host's and are not touched.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_renderer_failure(origin: String, failure: recovery::Failure, detail: String, failed: Option<String>, navigate: impl FnOnce(String) + Send + 'static) {
|
||||
static GUARD: Mutex<recovery::Guard> = Mutex::new(recovery::Guard::new());
|
||||
let say = |text: &str| { if let Some(log) = desktoplog::run_log() { log.line("shell", text); } };
|
||||
say(&format!("the page's {} ({detail}); it was showing {}", failure.name(), failed.as_deref().unwrap_or("an unknown address")));
|
||||
let plan = match GUARD.lock() {
|
||||
Ok(mut guard) => recovery::plan(&mut guard, uptime_ms(), &origin, failure, failed.as_deref()),
|
||||
Err(_) => recovery::Plan::Stop,
|
||||
};
|
||||
match plan {
|
||||
recovery::Plan::Ignore => {}
|
||||
recovery::Plan::Stop => say("the page's renderer failed again within two minutes of two recoveries; the window is left as it is (Reload in the page loads it again)"),
|
||||
recovery::Plan::Reload { attempt, url } => {
|
||||
say(&format!("loading the page again (recovery {attempt}): {url}"));
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(recovery::RELOAD_DELAY_MS));
|
||||
navigate(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: WebView2 tells the shell when a process of the page fails; the page's renderer dying is recovered by on_renderer_failure.
|
||||
#[cfg(windows)]
|
||||
fn hook_renderer_failures(window: &tauri::WebviewWindow, port: u16) {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::{ICoreWebView2ProcessFailedEventArgs2, COREWEBVIEW2_PROCESS_FAILED_KIND};
|
||||
use webview2_com::{take_pwstr, ProcessFailedEventHandler};
|
||||
use windows_core::{Interface, PWSTR};
|
||||
let target = window.clone();
|
||||
let origin = format!("http://127.0.0.1:{port}");
|
||||
let _ = window.with_webview(move |platform| unsafe {
|
||||
let Ok(core) = platform.controller().CoreWebView2() else { return };
|
||||
let handler = ProcessFailedEventHandler::create(Box::new(move |sender, args| {
|
||||
let Some(args) = args else { return Ok(()) };
|
||||
let mut kind = COREWEBVIEW2_PROCESS_FAILED_KIND::default();
|
||||
args.ProcessFailedKind(&mut kind)?;
|
||||
let mut detail = format!("kind {}", kind.0);
|
||||
if let Ok(more) = args.cast::<ICoreWebView2ProcessFailedEventArgs2>() {
|
||||
let mut reason = Default::default();
|
||||
let mut exit_code = 0i32;
|
||||
if more.Reason(&mut reason).is_ok() { detail = format!("{detail}, reason {}", recovery::reason_name(reason.0)); }
|
||||
if more.ExitCode(&mut exit_code).is_ok() { detail = format!("{detail}, exit code {exit_code:#x}"); }
|
||||
}
|
||||
let failed = sender.and_then(|view| {
|
||||
let mut source = PWSTR::null();
|
||||
view.Source(&mut source).ok()?;
|
||||
Some(take_pwstr(source))
|
||||
});
|
||||
let window = target.clone();
|
||||
on_renderer_failure(origin.clone(), recovery::Failure::from_webview2(kind.0), detail, failed, move |url| {
|
||||
if let Ok(url) = url.parse::<tauri::Url>() { let _ = window.navigate(url); }
|
||||
});
|
||||
Ok(())
|
||||
}));
|
||||
let mut token = 0i64;
|
||||
let _ = core.add_ProcessFailed(&handler, &mut token);
|
||||
});
|
||||
}
|
||||
|
||||
/// A file:// address from a drag, as a path (percent-escapes decoded); anything else (http, mailto, text) is not a dropped file.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
fn file_uri_to_path(uri: &str) -> Option<String> {
|
||||
@@ -1143,6 +1230,15 @@ fn run_window_process() -> ! {
|
||||
eprintln!("[strata] STRATA_WINDOW_TEST_CRASH is set: the window process aborts on purpose.");
|
||||
std::process::abort();
|
||||
}
|
||||
// WebKitGTK aborts while it draws a COLRv1 colour emoji font (fontguard.rs, #4533): this process is told to leave that font out. Only this
|
||||
// process, and before WebKit starts its own: the host, the engine and the agent keep the fonts the person chose.
|
||||
{
|
||||
let env = |k: &str| std::env::var(k).ok();
|
||||
let dirs = fontguard::font_dirs(&env);
|
||||
if let Some(conf) = fontguard::config_for(&env, &desktoplog::data_dir().join("fonts"), &dirs, Path::new(fontguard::SYSTEM_CONFIG)) {
|
||||
std::env::set_var("FONTCONFIG_FILE", conf);
|
||||
}
|
||||
}
|
||||
// The shell is this process's parent: when it is gone (killed, crashed, logged off) the window goes too.
|
||||
let parent = std::os::unix::process::parent_id();
|
||||
std::thread::spawn(move || loop {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! A page whose renderer dies is brought back, not left on the browser's error page (#5079).
|
||||
//!
|
||||
//! WebView2 (Windows) reports a dead renderer through its ProcessFailed event, and WKWebView (macOS) through
|
||||
//! `webViewWebContentProcessDidTerminate`; until now the shell listened to neither, so the window kept the browser's own "This page has a
|
||||
//! problem" page, nothing reached the log, and the person's Reload brought back the same chat that had just crashed. Linux does not need it:
|
||||
//! there the window is its own process and the shell moves to browser mode when it dies (supervisor.rs).
|
||||
//!
|
||||
//! This file is only decisions, so it builds and tests everywhere; the platform hooks are in main.rs.
|
||||
//! - which failures are worth a reload (the page's own renderer; not a GPU process that restarts by itself, not a frame of the page);
|
||||
//! - how many automatic recoveries are allowed in a window of time (a renderer that dies again at once is not reloaded for ever);
|
||||
//! - where the reload goes: the same screen first, with the sidebar previews closed once; the home screen the second time.
|
||||
//! The page reads `?recovery=<n>` in apps/host/src/lib/recovery.ts. The chats are the host's: nothing here touches them.
|
||||
|
||||
/// Automatic recoveries allowed inside `WINDOW_MS`; the next failure leaves the window as it is for the person to reload.
|
||||
pub const MAX_AUTOMATIC: usize = 2;
|
||||
/// The window of time the limit counts over.
|
||||
pub const WINDOW_MS: u64 = 120_000;
|
||||
/// Let the browser start a new renderer before the page is asked for again.
|
||||
pub const RELOAD_DELAY_MS: u64 = 400;
|
||||
|
||||
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_KIND values, by number.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Failure {
|
||||
BrowserExited,
|
||||
/// The renderer of the page itself.
|
||||
RendererExited,
|
||||
Unresponsive,
|
||||
/// The renderer of a frame inside the page (a website preview); the page itself is alive.
|
||||
FrameRendererExited,
|
||||
Gpu,
|
||||
Other(i32),
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
pub fn from_webview2(kind: i32) -> Failure {
|
||||
match kind {
|
||||
0 => Failure::BrowserExited,
|
||||
1 => Failure::RendererExited,
|
||||
2 => Failure::Unresponsive,
|
||||
3 => Failure::FrameRendererExited,
|
||||
6 => Failure::Gpu,
|
||||
other => Failure::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the page's own renderer dying leaves a window that has to be loaded again. A GPU process is restarted by the browser, a frame
|
||||
/// shows its own sad face, an unresponsive page may only be busy, and a dead browser process cannot navigate at all.
|
||||
pub fn needs_reload(self) -> bool {
|
||||
self == Failure::RendererExited
|
||||
}
|
||||
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Failure::BrowserExited => "browser process exited".into(),
|
||||
Failure::RendererExited => "renderer exited".into(),
|
||||
Failure::Unresponsive => "renderer unresponsive".into(),
|
||||
Failure::FrameRendererExited => "frame renderer exited".into(),
|
||||
Failure::Gpu => "gpu process exited".into(),
|
||||
Failure::Other(n) => format!("process failure kind {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// WebView2's COREWEBVIEW2_PROCESS_FAILED_REASON, for the log.
|
||||
pub fn reason_name(reason: i32) -> &'static str {
|
||||
match reason {
|
||||
0 => "unexpected",
|
||||
1 => "unresponsive",
|
||||
2 => "terminated",
|
||||
3 => "crashed",
|
||||
4 => "launch failed",
|
||||
5 => "out of memory",
|
||||
6 => "profile deleted",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Load the page again; `attempt` is 1 for the first recovery of the window, 2 for the second.
|
||||
Reload { attempt: usize },
|
||||
/// Too many in a short time: leave the window alone.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Counts recoveries over a sliding window of time. `now_ms` is any clock that only goes forward (the caller's uptime).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Guard {
|
||||
times: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Guard {
|
||||
pub const fn new() -> Guard {
|
||||
Guard { times: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn on_failure(&mut self, now_ms: u64) -> Action {
|
||||
self.times.retain(|t| now_ms.saturating_sub(*t) < WINDOW_MS);
|
||||
if self.times.len() >= MAX_AUTOMATIC {
|
||||
return Action::Stop;
|
||||
}
|
||||
self.times.push(now_ms);
|
||||
Action::Reload { attempt: self.times.len() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a recovery loads. The first goes back to the screen that died (its `#/...` part) with `?recovery=1`, which makes the page
|
||||
/// start with the sidebar previews closed once; the second goes to the home screen, in case the screen itself is what crashes.
|
||||
/// `failed` is the address the window was showing; it is used only when it is on `origin`.
|
||||
pub fn recovery_url(origin: &str, attempt: usize, failed: Option<&str>) -> String {
|
||||
let origin = origin.trim_end_matches('/');
|
||||
let base = format!("{origin}/?recovery={}", attempt.clamp(1, 9));
|
||||
if attempt > 1 {
|
||||
return base;
|
||||
}
|
||||
let Some(failed) = failed else { return base };
|
||||
// on this origin: after it comes the end, a path, a query or a fragment (not another digit of a longer port)
|
||||
let rest = match failed.strip_prefix(origin) {
|
||||
Some(rest) if rest.is_empty() || rest.starts_with(['/', '?', '#']) => rest,
|
||||
_ => return base,
|
||||
};
|
||||
match rest.find('#') {
|
||||
Some(at) => {
|
||||
let hash = &rest[at..];
|
||||
// only what a page address holds: no spaces, quotes, angle brackets or control characters
|
||||
let safe = hash.len() < 2048 && hash.starts_with("#/") && hash.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '"' | '<' | '>' | '\\' | '`' | '{' | '}' | '|' | '^'));
|
||||
if safe { format!("{base}{hash}") } else { base }
|
||||
}
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the shell does about one failure report.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Plan {
|
||||
/// Not a failure that leaves the window needing a reload: only logged.
|
||||
Ignore,
|
||||
/// A reload is due, to `url`.
|
||||
Reload { attempt: usize, url: String },
|
||||
/// Too many recent recoveries: the window is left as it is.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// One failure report in, the decision out. The platform hooks (main.rs) only collect the facts and carry the decision out.
|
||||
pub fn plan(guard: &mut Guard, now_ms: u64, origin: &str, failure: Failure, failed: Option<&str>) -> Plan {
|
||||
if !failure.needs_reload() {
|
||||
return Plan::Ignore;
|
||||
}
|
||||
match guard.on_failure(now_ms) {
|
||||
Action::Stop => Plan::Stop,
|
||||
Action::Reload { attempt } => Plan::Reload { attempt, url: recovery_url(origin, attempt, failed) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_pages_own_renderer_is_reloaded() {
|
||||
assert!(Failure::from_webview2(1).needs_reload());
|
||||
for kind in [0, 2, 3, 4, 5, 6, 7, 8, 9, 42] {
|
||||
assert!(!Failure::from_webview2(kind).needs_reload(), "kind {kind}");
|
||||
}
|
||||
assert_eq!(Failure::from_webview2(6), Failure::Gpu);
|
||||
assert_eq!(Failure::from_webview2(1).name(), "renderer exited");
|
||||
assert_eq!(Failure::from_webview2(42).name(), "process failure kind 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_are_named_for_the_log() {
|
||||
assert_eq!(reason_name(5), "out of memory");
|
||||
assert_eq!(reason_name(3), "crashed");
|
||||
assert_eq!(reason_name(99), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_recoveries_in_two_minutes_then_the_window_is_left_alone() {
|
||||
let mut g = Guard::new();
|
||||
assert_eq!(g.on_failure(1_000), Action::Reload { attempt: 1 });
|
||||
assert_eq!(g.on_failure(5_000), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(6_000), Action::Stop);
|
||||
assert_eq!(g.on_failure(100_000), Action::Stop);
|
||||
// the first has left the window: one more is allowed, and it is the second of the window
|
||||
assert_eq!(g.on_failure(1_000 + WINDOW_MS), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(1_000 + WINDOW_MS + 1), Action::Stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renderer_that_dies_later_starts_over_at_the_first_recovery() {
|
||||
let mut g = Guard::new();
|
||||
assert_eq!(g.on_failure(0), Action::Reload { attempt: 1 });
|
||||
assert_eq!(g.on_failure(10), Action::Reload { attempt: 2 });
|
||||
assert_eq!(g.on_failure(WINDOW_MS + 100), Action::Reload { attempt: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_loop_stays_stopped_until_the_window_has_passed() {
|
||||
let mut g = Guard::new();
|
||||
let mut reloads = 0;
|
||||
// a renderer that dies every second for ten minutes: never more than two reloads in any two-minute window
|
||||
let mut loaded = Vec::new();
|
||||
for second in 0..600u64 {
|
||||
if let Action::Reload { .. } = g.on_failure(second * 1_000) {
|
||||
reloads += 1;
|
||||
loaded.push(second);
|
||||
}
|
||||
}
|
||||
assert!(reloads <= 2 * (600 / (WINDOW_MS / 1_000) as usize + 1));
|
||||
for w in loaded.windows(3) {
|
||||
assert!(w[2] - w[0] >= WINDOW_MS / 1_000, "three reloads inside one window: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_recovery_keeps_the_screen_and_the_second_goes_home() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:47831/?recovery=1#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=1#/chat/s_abc");
|
||||
assert_eq!(recovery_url(o, 2, Some("http://127.0.0.1:47831/#/chat/s_abc")), "http://127.0.0.1:47831/?recovery=2");
|
||||
assert_eq!(recovery_url(o, 1, None), "http://127.0.0.1:47831/?recovery=1");
|
||||
assert_eq!(recovery_url("http://127.0.0.1:47831/", 1, None), "http://127.0.0.1:47831/?recovery=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_that_is_not_ours_or_not_plain_is_not_carried_over() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
for failed in [
|
||||
"https://example.com/#/chat/x",
|
||||
"http://127.0.0.1:47832/#/chat/x",
|
||||
"http://127.0.0.1:47831/#/chat/x y",
|
||||
"http://127.0.0.1:47831/#/chat/\"onload=",
|
||||
"http://127.0.0.1:47831/#/chat/<script>",
|
||||
"http://127.0.0.1:47831/#nothing",
|
||||
"http://127.0.0.1:47831/#/chat/\u{e9}",
|
||||
"chrome-error://chromewebdata/",
|
||||
] {
|
||||
assert_eq!(recovery_url(o, 1, Some(failed)), "http://127.0.0.1:47831/?recovery=1", "{failed}");
|
||||
}
|
||||
let long = format!("http://127.0.0.1:47831/#/{}", "a".repeat(3000));
|
||||
assert_eq!(recovery_url(o, 1, Some(&long)), "http://127.0.0.1:47831/?recovery=1");
|
||||
// a look-alike host is not the origin
|
||||
assert_eq!(recovery_url(o, 1, Some("http://127.0.0.1:478310/#/chat/x")), "http://127.0.0.1:47831/?recovery=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_report_becomes_one_decision() {
|
||||
let o = "http://127.0.0.1:47831";
|
||||
let mut g = Guard::new();
|
||||
let chat = Some("http://127.0.0.1:47831/#/chat/s_1");
|
||||
// a GPU process or a frame going down never costs the person their page, and does not use up the allowance
|
||||
assert_eq!(plan(&mut g, 0, o, Failure::Gpu, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 1, o, Failure::FrameRendererExited, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 2, o, Failure::Unresponsive, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 3, o, Failure::BrowserExited, chat), Plan::Ignore);
|
||||
assert_eq!(plan(&mut g, 1_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 1, url: "http://127.0.0.1:47831/?recovery=1#/chat/s_1".into() });
|
||||
assert_eq!(plan(&mut g, 4_000, o, Failure::RendererExited, chat), Plan::Reload { attempt: 2, url: "http://127.0.0.1:47831/?recovery=2".into() });
|
||||
assert_eq!(plan(&mut g, 7_000, o, Failure::RendererExited, chat), Plan::Stop);
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "strata",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"dependencies": {
|
||||
"html-to-image": "^1.11.13",
|
||||
"katex": "^0.19.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "strata",
|
||||
"private": true,
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"type": "module",
|
||||
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,7 +24,7 @@ async function metrics(tag) {
|
||||
await cdp.send('HeapProfiler.collectGarbage').catch(() => {});
|
||||
const m = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(x => [x.name, x.value]));
|
||||
const dom = await cdp.send('Memory.getDOMCounters').catch(() => ({}));
|
||||
const sess = await page.evaluate(() => { try { return (localStorage.getItem('strata.sessions.v1') ?? '').length; } catch { return -1; } });
|
||||
const sess = await page.evaluate(async () => { try { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return l.chats.reduce((n, c) => n + (c.bytes ?? 0), 0); } catch { return -1; } });
|
||||
console.log(`${tag.padEnd(22)} heap=${(m.JSHeapUsedSize / 1048576).toFixed(0)}MB total=${(m.JSHeapTotalSize / 1048576).toFixed(0)}MB nodes=${dom.nodes ?? m.Nodes} listeners=${dom.jsEventListeners ?? '-'} docs=${m.Documents} ls=${(sess / 1e6).toFixed(2)}M`);
|
||||
return m.JSHeapUsedSize;
|
||||
}
|
||||
|
||||
@@ -1235,14 +1235,17 @@ if (flow === 'unread') {
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// make the older conversation look updated since it was last read
|
||||
await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]');
|
||||
await page.evaluate(async () => {
|
||||
// The chats are files on the host (owner decision 11-04): read them and save the older one again, as the page would.
|
||||
const h = { 'x-strata-client': 'ui' };
|
||||
const list = await (await fetch('/api/chats', { headers: h })).json();
|
||||
const raw = await Promise.all(list.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session));
|
||||
const older = raw.filter((s) => s.messages.length > 0).at(-1);
|
||||
if (older) {
|
||||
older.updatedAt = new Date(Date.now() + 120000).toISOString();
|
||||
older.lastReadAt = new Date(Date.now() - 120000).toISOString();
|
||||
await fetch('/api/chats/' + encodeURIComponent(older.id), { method: 'PUT', headers: h, body: JSON.stringify(older) });
|
||||
}
|
||||
localStorage.setItem('strata.sessions.v1', JSON.stringify(raw));
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1800);
|
||||
@@ -1352,7 +1355,7 @@ if (flow === 'branch') {
|
||||
// remember the original so "untouched" means unchanged by the branch, not a
|
||||
// hardcoded length
|
||||
const before = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
|
||||
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
|
||||
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
|
||||
const originalBefore = before.find((s) => !s.title.includes('branch'));
|
||||
|
||||
@@ -1370,7 +1373,7 @@ if (flow === 'branch') {
|
||||
console.log('from user :', JSON.stringify(await view()), '<- branch must show only the user turn, and the url must follow');
|
||||
|
||||
const sessions = await page.evaluate(() =>
|
||||
JSON.parse(localStorage.getItem('strata.sessions.v1') ?? '[]')
|
||||
(async () => { const h = { 'x-strata-client': 'ui' }; const l = await (await fetch('/api/chats', { headers: h })).json(); return Promise.all(l.chats.map(async (c) => (await (await fetch('/api/chats/' + encodeURIComponent(c.id), { headers: h })).json()).session)); })()
|
||||
.map((s) => ({ title: s.title, roles: s.messages.map((m) => m.role) })));
|
||||
console.log('stored :', JSON.stringify(sessions));
|
||||
const originalAfter = sessions.find((s) => !s.title.includes('branch'));
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Writes src/locales/sav-<lang>.json: the notice for chats the host could not write (owner wording 11-04, #5192).
|
||||
Run from apps/host: python scripts/make-chatsave-locales.py
|
||||
Every entry names all nine languages; the keys are sav01.. in the order of S. sav-en.json is the English source. Placeholders ({0}) must be kept in every
|
||||
language. A separate file keeps this work from colliding with the other catalogs (the same reason upd-, df2-, pick- and the others exist)."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LANGS = ["en", "zh", "ja", "de", "es", "ru", "pt", "ko", "fr"]
|
||||
S = []
|
||||
|
||||
|
||||
def add(en, zh, ja, de, es, ru, pt, ko, fr):
|
||||
S.append(dict(en=en, zh=zh, ja=ja, de=de, es=es, ru=ru, pt=pt, ko=ko, fr=fr))
|
||||
|
||||
|
||||
add("Some chats could not be saved",
|
||||
"部分聊天无法保存",
|
||||
"一部のチャットを保存できませんでした",
|
||||
"Einige Chats konnten nicht gespeichert werden",
|
||||
"No se pudieron guardar algunos chats",
|
||||
"Не удалось сохранить некоторые чаты",
|
||||
"Alguns chats não puderam ser guardados",
|
||||
"일부 채팅을 저장하지 못했습니다",
|
||||
"Certaines conversations n'ont pas pu être enregistrées")
|
||||
add("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
|
||||
"Strata 的聊天存储空间已满,因此 {0} 个聊天的最新更改目前只保存在此窗口中。请删除旧聊天以腾出空间,之后它们会自动保存。",
|
||||
"Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
|
||||
"Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
|
||||
"El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
|
||||
"Хранилище чатов Strata заполнено, поэтому последние изменения в чатах ({0}) есть только в этом окне. Удалите старые чаты, чтобы освободить место; после этого они сохранятся сами.",
|
||||
"O armazenamento de chats do Strata está cheio, por isso as alterações mais recentes de {0} chat(s) só existem nesta janela. Apague chats antigos para libertar espaço; depois eles guardam-se sozinhos.",
|
||||
"Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
|
||||
"L'espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d'anciennes conversations pour faire de la place ; elles s'enregistreront alors d'elles-mêmes.")
|
||||
add("Copy unsaved chats",
|
||||
"复制未保存的聊天",
|
||||
"未保存のチャットをコピー",
|
||||
"Nicht gespeicherte Chats kopieren",
|
||||
"Copiar chats sin guardar",
|
||||
"Скопировать несохранённые чаты",
|
||||
"Copiar chats não guardados",
|
||||
"저장되지 않은 채팅 복사",
|
||||
"Copier les conversations non enregistrées")
|
||||
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
def french(text):
|
||||
text = re.sub(r"(?<=[A-Za-zÀ-ſ])'(?=[A-Za-zÀ-ſ{])", "’", text)
|
||||
text = re.sub(r"(?<=\S) ([:;?!])", NBSP + r"\1", text)
|
||||
return text
|
||||
|
||||
|
||||
for lang in LANGS:
|
||||
data = {}
|
||||
for i, entry in enumerate(S):
|
||||
text = entry[lang]
|
||||
assert sorted(re.findall(r"\{\d+\}", entry["en"])) == sorted(re.findall(r"\{\d+\}", text)), (lang, entry["en"], text)
|
||||
data[f"sav{i + 1:02d}"] = french(text) if lang == "fr" else text
|
||||
with (here / f"sav-{lang}.json").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(json.dumps(data, ensure_ascii=False, indent=1) + "\n")
|
||||
print("written", len(S), "strings in", len(LANGS), "languages")
|
||||
@@ -175,6 +175,33 @@ add("Drafting: MTP (DFlash2 didn't start)",
|
||||
"Previsão: MTP (o DFlash2 não iniciou)",
|
||||
"초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
|
||||
"Anticipation : MTP (DFlash2 n'a pas démarré)")
|
||||
add("Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
|
||||
"自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
|
||||
"自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
|
||||
"Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
|
||||
"Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
|
||||
"Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
|
||||
"O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
|
||||
"자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
|
||||
"Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.")
|
||||
add("The all-cards split engine could not start, so the normal layer split runs.",
|
||||
"全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
|
||||
"全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
|
||||
"Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
|
||||
"El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
|
||||
"Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
|
||||
"O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
|
||||
"모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
|
||||
"Le moteur réparti sur toutes les cartes n'a pas pu démarrer, la répartition normale par couches est donc utilisée.")
|
||||
add("Drafting: off (the cards were short of memory)",
|
||||
"预测草稿:关闭(显卡内存不足)",
|
||||
"先読み:オフ(グラフィックスカードのメモリが不足していました)",
|
||||
"Vorausraten: aus (den Karten fehlte Speicher)",
|
||||
"Predicción: desactivada (a las tarjetas les faltaba memoria)",
|
||||
"Предсказание: выключено (на картах не хватило памяти)",
|
||||
"Previsão: desligada (faltou memória nas placas)",
|
||||
"초안 예측: 꺼짐(카드 메모리가 부족했습니다)",
|
||||
"Anticipation : désactivée (les cartes manquaient de mémoire)")
|
||||
here = Path(__file__).resolve().parent.parent / "src" / "locales"
|
||||
NBSP = " "
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ export async function open(scheme, width, height, sessions, { hash = '#/chat', p
|
||||
try {
|
||||
if (sessionStorage.getItem('ui-do-seeded')) return;
|
||||
sessionStorage.setItem('ui-do-seeded', '1');
|
||||
if (sessions) localStorage.setItem('strata.sessions.v1', JSON.stringify(sessions));
|
||||
// The chats are files on the host: put the seed there (a synchronous request, so it is in place before the page starts).
|
||||
for (const chat of sessions ?? []) { const request = new XMLHttpRequest(); request.open('PUT', '/api/chats/' + encodeURIComponent(chat.id), false); request.setRequestHeader('x-strata-client', 'ui'); request.send(JSON.stringify({ messages: [], ...chat })); }
|
||||
if (Object.keys(prefs).length) localStorage.setItem('strata.prefs.v1', JSON.stringify(prefs));
|
||||
} catch { /* storage refused */ }
|
||||
}, { sessions, prefs });
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildProfile, routeFor, MODE_PRESETS, modeOf, effortOf, localOutputBudg
|
||||
import { TurnProjector, parseArguments as parseArgs } from './events.mjs';
|
||||
import { ChildProjector } from './children.mjs';
|
||||
import { nearestBudget } from './llm-shape.mjs';
|
||||
import { DeliverableTracker, ActivityGroups } from './deliverables.mjs';
|
||||
import { DeliverableTracker, ActivityGroups, FolderClaims } from './deliverables.mjs';
|
||||
import { SkillsUsed, skillInfoFrom } from './skills-used.mjs';
|
||||
import { turnEngineSpeed } from './engine-speed.mjs';
|
||||
import { TurnGuard, guardNote, GUARD_NOTES } from './turn-guard.mjs';
|
||||
@@ -127,6 +127,8 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
const home = join(root, 'home');
|
||||
const sessionsFile = join(root, 'sessions.json');
|
||||
mkdirSync(root, { recursive: true });
|
||||
/** Which chat's file tools wrote which file, for chats that share a folder (#5008). */
|
||||
const folderClaims = new FolderClaims();
|
||||
|
||||
/**
|
||||
* Where the turn's file tracker must never look (deliverables.mjs): the app, the engine and its components, Strata's own data, AppData
|
||||
@@ -367,24 +369,27 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
let hideDeltas = !!researchTopic;
|
||||
signal?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
|
||||
// What the assistant itself wrote in this run (not our own step lines): a plan turn reads its plan from it, a step reads its summary.
|
||||
const written = { all: '', step: '' };
|
||||
const written = { all: '', step: '', reasoning: '' };
|
||||
// The frame that closed the turn, kept with the turn's text for a page that comes back after it ended (attach).
|
||||
let terminal = null;
|
||||
// The turn guard (turn-guard.mjs): counts failing searches, searches, plan rewrites and a nearly full context. Set up once the route is known.
|
||||
let guard = null;
|
||||
const raw = frame => { try { send(frame); } catch { send = () => {}; attached = false; } };
|
||||
const raw = frame => { if (frame.type === 'done' || frame.type === 'error') terminal = frame; try { send(frame); } catch { send = () => {}; attached = false; } };
|
||||
// What the turn made (files) and compact activity lines (notes-split-agent8.md): derived from the cards the turn already sends.
|
||||
const tracker = new DeliverableTracker(workspacePath, trackerFolders());
|
||||
const tracker = new DeliverableTracker(workspacePath, { ...trackerFolders(), claims: folderClaims, owner: chatId });
|
||||
let toolsRan = false; // a turn that ran no tool wrote nothing, so there is nothing to look for at its end
|
||||
try { tracker.snapshot(); } catch { /* a folder that cannot be read has no deliverables */ }
|
||||
const groups = new ActivityGroups({ emit: raw });
|
||||
const skills = new SkillsUsed({ emit: raw, info: skillInfoFrom(skillsDir()) });
|
||||
const emit = frame => {
|
||||
if (frame.type === 'reasoning' && frame.text && written.reasoning.length < 2_000_000) written.reasoning += frame.text;
|
||||
if (frame.type === 'delta' && !frame.own) { written.all += frame.text; written.step += frame.text; if (guard) trip(guard.text(frame.text)); if (hideDeltas) return; }
|
||||
if (frame.own) { const { own, ...rest } = frame; frame = rest; }
|
||||
if (frame.type === 'delta' && frame.text) groups.text();
|
||||
if (frame.type === 'agent-tool' && frame.tool) {
|
||||
if (['done', 'error'].includes(frame.tool.state) && !['read', 'read_image', 'glob', 'grep', 'web_fetch', 'web_search'].includes(frame.tool.name)) toolsRan = true;
|
||||
let made = null;
|
||||
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path); } catch { made = null; } }
|
||||
if (frame.tool.state === 'done' && (frame.tool.name === 'write' || frame.tool.name === 'edit')) { try { made = tracker.check(frame.tool.args?.file_path ?? frame.tool.args?.path, { explicit: true }); } catch { made = null; } }
|
||||
raw(frame);
|
||||
if (made) raw(made);
|
||||
if (guard) trip(guard.card(frame.tool));
|
||||
@@ -463,6 +468,20 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
research: researchTopic ? { phase: 'gather', nudged: false, waitingStart: false } : null,
|
||||
};
|
||||
c.turn = turn;
|
||||
if (workspacePath) folderClaims.begin(workspacePath, chatId);
|
||||
/**
|
||||
* A page that comes back (a reload, a new window) takes the running turn over again. Everything that was said while no page was there is
|
||||
* sent as one catch-up: the start, the text and thinking so far (a `resync`), every tool card as it is now (the ones waiting for a yes
|
||||
* included) and the questions waiting for an answer. After that the turn's frames flow to the new page as they did to the old one.
|
||||
*/
|
||||
turn.reattach = ({ onFrame, signal: again }) => {
|
||||
send = onFrame; attached = true;
|
||||
again?.addEventListener('abort', () => { send = () => {}; attached = false; }, { once: true });
|
||||
raw({ type: 'start', model: route.name, contextMax: route.contextWindow, demo: false, remoteModel: route.kind === 'api', agent: true, mode: appliedMode, sessionId, requestId, skills: [], resumed: true, ...(planTask ? { planning: true } : {}) });
|
||||
raw({ type: 'resync', content: hideDeltas ? '' : written.all, reasoning: written.reasoning });
|
||||
for (const t of projector.tools.values()) if (t.card) raw({ type: 'agent-tool', tool: t.card });
|
||||
for (const q of questions.values()) if (q.sessionId === sessionId) raw({ type: 'agent-question', callId: q.callId, questions: q.items });
|
||||
};
|
||||
/**
|
||||
* A turn must always end. If the Harness sends nothing at all for a while (a tool that never returns, a model server that went
|
||||
* quiet, a lost event) the turn is stopped and ended with a plain message instead of showing "thinking" forever. A running tool gets
|
||||
@@ -500,6 +519,7 @@ export function createAgentBridge({ dataDir, componentsDir = null, bundledDir =
|
||||
reporter.clear();
|
||||
const extra = [];
|
||||
try { if (toolsRan) for (const d of tracker.sweep()) { emit(d); groups.addFile(d); } } catch { /* never blocks the end of a turn */ }
|
||||
if (workspacePath) folderClaims.end(workspacePath, chatId);
|
||||
groups.end();
|
||||
if (researchTopic && !failure && !turn.stopping) { const sources = parseSources(written.all); if (sources.length) extra.push({ type: 'research', topic: researchTopic, sources }); }
|
||||
try { extra.push(...settlePlan(failure)); } catch { /* a plan frame must never break the end of a turn */ }
|
||||
@@ -529,6 +549,8 @@ ${note.text}`, key: note.key, values: note.values, noteReason: 'cut' });
|
||||
}
|
||||
for (const f of frames.slice(-1)) emit(f);
|
||||
c.turn = null;
|
||||
// What a page that was away needs to show the end of this turn (attach, below).
|
||||
c.last = { requestId, sessionId, mode: appliedMode, model: route.name, contextMax: route.contextWindow, remoteModel: route.kind === 'api', content: hideDeltas ? '' : written.all.slice(0, 4_000_000), reasoning: written.reasoning, cards: [...projector.tools.values()].filter(t => t.card).map(t => t.card), terminal };
|
||||
turn.ended.resolve({ reason: projector.ended, failure: failure ?? null });
|
||||
notifyIdle();
|
||||
// A turn that filled the context, or that the guard stopped, leaves a long history behind: summarise the older part now so the next message has room.
|
||||
@@ -1402,6 +1424,34 @@ ${note.text}
|
||||
return { stopped: true, ended: result.reason?.kind ?? 'unknown', ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
/** The turn running for a chat now, for a page that has just loaded; null when none. */
|
||||
function turnOf(chatId) {
|
||||
const t = chats.get(chatId)?.turn;
|
||||
if (!t || t.finished) return null;
|
||||
return { chatId, sessionId: t.sessionId, requestId: t.requestId, mode: t.mode, stopping: !!t.stopping, pending: pendingFor(chatId) };
|
||||
}
|
||||
/** Every chat with a turn running. */
|
||||
function runningTurns() { return [...chats.keys()].map(turnOf).filter(Boolean); }
|
||||
/**
|
||||
* Take a turn over from a new page. A running turn: the catch-up, then the live frames until it ends or the page leaves again. A turn that
|
||||
* ended while no page was there (`requestId` names it): its text, tool cards and closing frame once. null when there is nothing to take over.
|
||||
*/
|
||||
function attach({ chatId, requestId = null, onFrame, signal }) {
|
||||
const c = chats.get(chatId);
|
||||
const turn = c?.turn;
|
||||
if (turn && !turn.finished && typeof turn.reattach === 'function') {
|
||||
if (requestId && turn.requestId !== requestId) return null;
|
||||
turn.reattach({ onFrame, signal });
|
||||
return { ended: turn.ended.promise.then(() => undefined), live: true };
|
||||
}
|
||||
const last = c?.last;
|
||||
if (!last || !requestId || last.requestId !== requestId) return null;
|
||||
onFrame({ type: 'start', model: last.model, contextMax: last.contextMax, demo: false, remoteModel: last.remoteModel, agent: true, mode: last.mode, sessionId: last.sessionId, requestId: last.requestId, skills: [], resumed: true });
|
||||
onFrame({ type: 'resync', content: last.content, reasoning: last.reasoning });
|
||||
for (const card of last.cards) onFrame({ type: 'agent-tool', tool: card });
|
||||
if (last.terminal) onFrame(last.terminal);
|
||||
return { ended: Promise.resolve(), live: false };
|
||||
}
|
||||
function pendingFor(chatId) {
|
||||
const sessionId = sessionMap[chatId]?.sessionId;
|
||||
const helpers = chats.get(chatId)?.turn?.children;
|
||||
@@ -1423,7 +1473,7 @@ ${note.text}
|
||||
/** Let go of a chat's Harness session mapping (a task starts every run in a session of its own). */
|
||||
function forget(chatId) { if (chats.get(chatId)?.turn) return; delete sessionMap[chatId]; chats.delete(chatId); persist(); }
|
||||
|
||||
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
return { compactCommand, messageHelper, answerQuestion, goalState, goalControl, harnessDir, status, turnOf, runningTurns, attach, runTurn, goalCommand, planCommand, researchCommand, llmTarget, setPlanBriefing, resetSession, reopenPlan, skipStep, planGoal, planControl, planState, queueMessage, cancelQueued, takeQueue, forget, approve, setMode, stop, pendingFor, pendingAll, close, sessionOf: chatId => sessionMap[chatId]?.sessionId ?? null, workingFolder: chatId => sessionMap[chatId]?.cwd ?? null, isRunning: chatId => !!chats.get(chatId)?.turn, _debug: () => ({ rt, sessionMap }) };
|
||||
}
|
||||
|
||||
/** A goal that stopped by itself, in words. */
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* timeoutSec the time allowed, 1 to 3600 (default 120). A command that runs over is ended together with the programs it started.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { endTree as endTreeNow, ownGroup } from '../process-tree.mjs';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
@@ -67,10 +68,7 @@ export const clampTimeoutSec = value => {
|
||||
};
|
||||
|
||||
function endTree(child) {
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, timeout: 10000 });
|
||||
else child.kill('SIGKILL');
|
||||
} catch { /* gone */ }
|
||||
endTreeNow(child, 'SIGKILL'); // the command's whole process group on Linux and macOS (it is started as its own group below), taskkill /T on Windows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +83,7 @@ export function runCommand({ command, input = '', cwd, callCwd, shell = 'auto',
|
||||
const limit = clampTimeoutSec(timeoutSec);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child = spawn(plan.file, plan.args, { shell: plan.shell, windowsHide: true, detached: ownGroup(), cwd: where.cwd, env: { ...process.env, STRATA_INPUT: String(input ?? '') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (e) { return resolve({ ok: false, text: `The command could not start in ${plan.name}: ${e.message}` }); }
|
||||
let text = '', cut = false, timedOut = false;
|
||||
const take = chunk => { if (text.length < OUT_MAX + 1) text += chunk.toString(); else cut = true; };
|
||||
|
||||
@@ -49,6 +49,44 @@ export function isWithin(parent, child) {
|
||||
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Who made which file when several chats work in one folder (#5008). A folder chosen for two chats is shared on purpose, but a
|
||||
* chat's list of "files this turn made" is private to that turn: a file another chat wrote must not be shown as this chat's work.
|
||||
* One registry is shared by the turns of a host. A turn `begin`s on its folder, `claim`s the files its own file tools wrote, and
|
||||
* `end`s. Turns that were running at the same time in the same folder are `overlapped`: for them the end-of-turn sweep reports only
|
||||
* files nobody else claimed AND that no overlapping turn could have written; since a script's output cannot be told apart, it is
|
||||
* left out for an overlapped turn rather than given to the wrong chat.
|
||||
*/
|
||||
export class FolderClaims {
|
||||
#active = new Map(); // folder key -> Set(owner)
|
||||
#overlap = new Map(); // folder key + owner -> true
|
||||
#claims = new Map(); // folder key -> Map(rel path -> owner)
|
||||
#key(root) { return same(resolve(String(root))); }
|
||||
begin(root, owner) {
|
||||
const k = this.#key(root);
|
||||
const set = this.#active.get(k) ?? new Set();
|
||||
const others = [...set].filter(o => o !== owner);
|
||||
if (others.length) { for (const o of [...others, owner]) this.#overlap.set(`${k}|${o}`, true); }
|
||||
set.add(owner); this.#active.set(k, set);
|
||||
}
|
||||
end(root, owner) {
|
||||
const k = this.#key(root);
|
||||
const set = this.#active.get(k);
|
||||
if (!set) return;
|
||||
set.delete(owner); this.#overlap.delete(`${k}|${owner}`);
|
||||
if (!set.size) { this.#active.delete(k); this.#claims.delete(k); }
|
||||
}
|
||||
claim(root, rel, owner) {
|
||||
const k = this.#key(root);
|
||||
if (!this.#active.has(k)) return;
|
||||
const m = this.#claims.get(k) ?? new Map();
|
||||
m.set(rel, owner); this.#claims.set(k, m);
|
||||
}
|
||||
ownerOf(root, rel) { return this.#claims.get(this.#key(root))?.get(rel) ?? null; }
|
||||
/** True when another turn was running in this folder at any time during this owner's turn. */
|
||||
overlapped(root, owner) { return this.#overlap.get(`${this.#key(root)}|${owner}`) === true; }
|
||||
}
|
||||
|
||||
export class DeliverableTracker {
|
||||
/**
|
||||
* `exclude`: folders that are never searched when they lie inside the chat's folder (the app, the engine and its components, AppData,
|
||||
@@ -56,8 +94,9 @@ export class DeliverableTracker {
|
||||
* tracked at all. The turn only reports files the turn itself wrote: an explicit file-tool call, or (for scripts) a file whose modified
|
||||
* or creation time is after the turn started.
|
||||
*/
|
||||
constructor(root, { now = Date.now, exclude = [], forbid = [] } = {}) {
|
||||
constructor(root, { now = Date.now, exclude = [], forbid = [], claims = null, owner = null } = {}) {
|
||||
this.root = root ? resolve(root) : null;
|
||||
this.claims = claims; this.owner = owner;
|
||||
this.now = now;
|
||||
if (this.root && forbid.filter(Boolean).some(p => isWithin(p, this.root))) this.root = null;
|
||||
// only the folders that lie inside the chat's folder matter for the search
|
||||
@@ -104,13 +143,15 @@ export class DeliverableTracker {
|
||||
}
|
||||
|
||||
/** The deliverable for one path now, compared with the snapshot (and the last report), or null when nothing changed. */
|
||||
check(rawPath) {
|
||||
check(rawPath, { explicit = false } = {}) {
|
||||
if (!this.root) return null;
|
||||
const full = resolve(this.root, String(rawPath ?? ''));
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split('/').some(seg => SKIP_DIR.test(seg)) || this.#excluded(dirname(full))) return null;
|
||||
let st; try { st = statSync(full); } catch { return null; }
|
||||
if (!st.isFile()) return null;
|
||||
// A file this chat's own file tool wrote is this chat's, whoever else works in the folder (#5008).
|
||||
if (explicit && this.claims && this.owner) this.claims.claim(this.root, rel, this.owner);
|
||||
const old = this.before.get(rel);
|
||||
if (old && old.size === st.size && old.mtimeMs === st.mtimeMs) return null;
|
||||
const type = fileType(rel);
|
||||
@@ -139,7 +180,14 @@ export class DeliverableTracker {
|
||||
// modified during the turn; or created during it (a copy that kept its old modified time), which is only certain when the first look saw the whole folder
|
||||
const fresh = st.mtimeMs >= since || (!this.snapshotTruncated && (st.birthtimeMs || 0) >= since);
|
||||
if (!fresh) return;
|
||||
const d = this.check(relative(this.root, full)); if (d) out.push(d);
|
||||
const rel = relative(this.root, full).split(sep).join('/');
|
||||
if (this.claims && this.owner) {
|
||||
// Another chat's file tool wrote it: not ours. Written by a script while another chat was working in the same folder: nobody can say whose it is.
|
||||
const by = this.claims.ownerOf(this.root, rel);
|
||||
if (by && by !== this.owner) return;
|
||||
if (!by && this.claims.overlapped(this.root, this.owner)) return;
|
||||
}
|
||||
const d = this.check(rel); if (d) out.push(d);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function parseCsv(text, delimiter = ',', maxRows = 500) {
|
||||
export function previewFile(root, rawPath, sessionId) {
|
||||
const file = resolveChatFile(root, rawPath);
|
||||
const st = statSync(file); const kind = fileType(file);
|
||||
const base = { kind, name: basename(file), path: file, size: st.size, url: pathToFileURL(file).href };
|
||||
const base = { kind, name: basename(file), path: file, size: st.size, mtime: Math.round(st.mtimeMs), url: pathToFileURL(file).href };
|
||||
const raw = `/api/files/raw?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file)}`;
|
||||
if (kind === 'image' || kind === 'pdf') return { ...base, rawUrl: raw, mime: mimeOf(file) };
|
||||
if (kind === 'other') return { ...base, note: 'This kind of file has no preview. Open it in its own app.' };
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* The chats, saved by the host as files (owner decision 11-04: the browser's 5 MB storage limit is gone).
|
||||
*
|
||||
* <data folder>/chats/index.json { v: 1, chats: [{ id, rev, createdAt, updatedAt, bytes, title }] }
|
||||
* <data folder>/chats/<id>.json { "v": 1, "rev": N, "savedAt": ISO, "session": { ...the chat exactly as the page sent it... } }
|
||||
* <data folder>/chats/conflicts/ the version a later save displaced when two windows saved the same chat from an older copy
|
||||
*
|
||||
* - One file per chat. A save writes a temporary file next to it, flushes it and renames it over the chat: a crash or a full disk
|
||||
* in the middle leaves the older file whole. Temporary files left by a crash are removed at the next start.
|
||||
* - `rev` counts the saves of a chat. A save that names a `baseRev` older than the stored one (a second window saved first) is still
|
||||
* written, but the version it displaces is kept in conflicts/ first, so nothing is lost.
|
||||
* - The index is a list for fast start. If it is missing or does not match the files it is rebuilt from the files; a file that cannot
|
||||
* be read is reported and left exactly as it is.
|
||||
* - Nothing is cut: the chat is stored byte for byte as received (a text splice, no re-serialising).
|
||||
* - Owner-only: the folder is created private (POSIX 0700 and files 0600; on Windows the folder gets a protected ACL that its files
|
||||
* inherit), the same level as the rest of the profile.
|
||||
*/
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from 'node:fs';
|
||||
import { open, readFile, rename, unlink, mkdir, copyFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const CHAT_FORMAT = 1;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
const RESERVED = /^(con|prn|aux|nul|com\d|lpt\d|index|import|conflicts)(\.|$)/i;
|
||||
const KEEP_CONFLICTS = 5;
|
||||
|
||||
export class ChatStoreError extends Error {
|
||||
constructor(message, status, code) { super(message); this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
/** The disk operations, replaceable in tests to make a disk full, a permission failure or a crash in the middle of a write. */
|
||||
export const realIo = {
|
||||
async writeFile(path, text) {
|
||||
const handle = await open(path, 'w', 0o600);
|
||||
try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
|
||||
},
|
||||
rename, unlink, copyFile, readFile, readdir, mkdir,
|
||||
};
|
||||
|
||||
export const validChatId = id => typeof id === 'string' && ID.test(id) && !RESERVED.test(id);
|
||||
|
||||
/** Map a failed disk operation to what the page is told: a full disk and a refused write are different words for the same "not saved". */
|
||||
export function storeFailure(error) {
|
||||
if (error instanceof ChatStoreError) return error;
|
||||
const code = error?.code;
|
||||
if (code === 'ENOSPC' || code === 'EDQUOT' || code === 'EFBIG') return new ChatStoreError('The disk is full.', 507, 'disk-full');
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return new ChatStoreError('Strata may not write its chat folder.', 403, 'permission');
|
||||
return new ChatStoreError(error?.message ?? 'The chat could not be written.', 500, code ?? 'write-failed');
|
||||
}
|
||||
|
||||
export function createChatStore({ dir, io = realIo, privateDir = () => {}, now = () => new Date() }) {
|
||||
const root = join(dir, 'chats');
|
||||
const conflictsDir = join(root, 'conflicts');
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
try { privateDir(root); } catch { /* the folder is inside the profile; the tighter ACL is best effort */ }
|
||||
let tmpCount = 0;
|
||||
let index = new Map(); // id -> entry
|
||||
const damaged = new Set();
|
||||
|
||||
const fileOf = id => join(root, `${id}.json`);
|
||||
const bytesOf = text => Buffer.byteLength(text, 'utf8');
|
||||
const entryOf = (id, rev, session, bytes) => ({ id, rev, createdAt: session?.createdAt ?? null, updatedAt: session?.updatedAt ?? null, bytes, title: typeof session?.title === 'string' ? session.title.slice(0, 200) : '' });
|
||||
|
||||
// A chain, so saves of one host never overlap: the file, the index and the conflicts folder change one save at a time.
|
||||
let chain = Promise.resolve();
|
||||
const queue = work => { const run = chain.then(work, work); chain = run.catch(() => {}); return run; };
|
||||
|
||||
function loadSync() {
|
||||
// Temporary files from a write that never finished.
|
||||
let names = [];
|
||||
try { names = readdirSync(root); } catch { /* empty */ }
|
||||
for (const name of names) if (/\.tmp-\d+-\d+$/.test(name)) { try { unlinkSync(join(root, name)); } catch { /* left */ } }
|
||||
const files = names.filter(n => n.endsWith('.json') && n !== 'index.json' && !/\.tmp-/.test(n)).map(n => n.slice(0, -5)).filter(validChatId);
|
||||
let saved = null;
|
||||
try { const parsed = JSON.parse(readFileSync(join(root, 'index.json'), 'utf8')); if (parsed?.v === CHAT_FORMAT && Array.isArray(parsed.chats)) saved = parsed.chats; } catch { /* rebuilt below */ }
|
||||
const map = new Map();
|
||||
if (saved) for (const e of saved) if (e && validChatId(e.id) && files.includes(e.id)) map.set(e.id, e);
|
||||
// Files the index does not know (a crash before the index was written, an index lost): read from the files.
|
||||
for (const id of files) {
|
||||
if (map.has(id)) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(fileOf(id), 'utf8'));
|
||||
if (parsed?.v !== CHAT_FORMAT || !parsed.session || parsed.session.id !== id) throw new Error('not a chat file');
|
||||
map.set(id, entryOf(id, Number(parsed.rev) || 1, parsed.session, statSync(fileOf(id)).size));
|
||||
} catch { damaged.add(id); }
|
||||
}
|
||||
index = map;
|
||||
}
|
||||
loadSync();
|
||||
|
||||
async function writeAtomic(path, text) {
|
||||
const temp = `${path}.tmp-${process.pid}-${++tmpCount}`;
|
||||
try {
|
||||
await io.writeFile(temp, text);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await io.rename(temp, path); break; }
|
||||
catch (e) { if (attempt >= 5 || !['EPERM', 'EBUSY', 'EACCES'].includes(e?.code)) throw e; await new Promise(r => setTimeout(r, 40 * (attempt + 1))); } // an antivirus scan holds the file for a moment on Windows
|
||||
}
|
||||
} catch (e) {
|
||||
try { await io.unlink(temp); } catch { /* nothing to remove */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex() {
|
||||
const list = [...index.values()];
|
||||
try { await writeAtomic(join(root, 'index.json'), JSON.stringify({ v: CHAT_FORMAT, savedAt: now().toISOString(), chats: list })); }
|
||||
catch { /* only a list: it is rebuilt from the files when it does not match */ }
|
||||
}
|
||||
|
||||
async function keepDisplaced(id, rev) {
|
||||
try {
|
||||
await io.mkdir(conflictsDir, { recursive: true });
|
||||
await io.copyFile(fileOf(id), join(conflictsDir, `${id}.rev${rev}.json`));
|
||||
const mine = (await io.readdir(conflictsDir)).filter(n => n.startsWith(`${id}.rev`)).sort((a, b) => Number(a.match(/rev(\d+)/)?.[1]) - Number(b.match(/rev(\d+)/)?.[1]));
|
||||
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP_CONFLICTS))) { try { await io.unlink(join(conflictsDir, old)); } catch { /* left */ } }
|
||||
} catch (e) {
|
||||
throw storeFailure(e); // not saved rather than saved over the only copy of the other window's work
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** The chats, newest first. */
|
||||
list() {
|
||||
const chats = [...index.values()].sort((a, b) => String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')) || a.id.localeCompare(b.id));
|
||||
return { v: CHAT_FORMAT, chats, damaged: [...damaged] };
|
||||
},
|
||||
has: id => index.has(id),
|
||||
rev: id => index.get(id)?.rev ?? 0,
|
||||
/** The stored file text of a chat ({"v","rev","savedAt","session"}), or null. */
|
||||
async read(id) {
|
||||
if (!validChatId(id) || !index.has(id)) return null;
|
||||
try { return await io.readFile(fileOf(id), 'utf8'); }
|
||||
catch (e) { if (e?.code === 'ENOENT') { index.delete(id); return null; } throw storeFailure(e); }
|
||||
},
|
||||
/**
|
||||
* Save one chat. `sessionText` is the chat's JSON as the page wrote it. Resolves { id, rev, bytes, conflict }.
|
||||
* Rejects with a ChatStoreError (status 400 for a chat that is not a chat, 507 disk full, 403 refused, 500 other): nothing was changed.
|
||||
*/
|
||||
save(id, sessionText, { baseRev = null } = {}) {
|
||||
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
|
||||
let session;
|
||||
try { session = JSON.parse(sessionText); } catch { return Promise.reject(new ChatStoreError('The chat must be JSON.', 400, 'bad-json')); }
|
||||
if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== id) return Promise.reject(new ChatStoreError('The chat does not match its id.', 400, 'bad-chat'));
|
||||
const body = sessionText.trim();
|
||||
return queue(async () => {
|
||||
const current = index.get(id);
|
||||
const rev = (current?.rev ?? 0) + 1;
|
||||
let conflict = false;
|
||||
if (current && baseRev !== null && Number(baseRev) !== current.rev) { conflict = true; await keepDisplaced(id, current.rev); }
|
||||
const text = `{"v":${CHAT_FORMAT},"rev":${rev},"savedAt":${JSON.stringify(now().toISOString())},"session":${body}}`;
|
||||
try { await writeAtomic(fileOf(id), text); } catch (e) { throw storeFailure(e); }
|
||||
const bytes = bytesOf(text);
|
||||
index.set(id, entryOf(id, rev, session, bytes));
|
||||
damaged.delete(id);
|
||||
await writeIndex();
|
||||
return { id, rev, bytes, conflict };
|
||||
});
|
||||
},
|
||||
/** Delete a chat (the person deleted it). A chat that is not there is already gone. */
|
||||
remove(id) {
|
||||
if (!validChatId(id)) return Promise.reject(new ChatStoreError('That is not a chat id.', 400, 'bad-id'));
|
||||
return queue(async () => {
|
||||
try { await io.unlink(fileOf(id)); } catch (e) { if (e?.code !== 'ENOENT') throw storeFailure(e); }
|
||||
const had = index.delete(id);
|
||||
await writeIndex();
|
||||
return { id, removed: had };
|
||||
});
|
||||
},
|
||||
/** Chats from the browser's old storage: every one the host does not have yet is saved; the ones it has are left alone. */
|
||||
async importMany(sessions) {
|
||||
const imported = [], skipped = [], failed = [];
|
||||
for (const session of Array.isArray(sessions) ? sessions : []) {
|
||||
const id = session?.id;
|
||||
if (!validChatId(id)) { failed.push({ id: String(id ?? ''), error: 'not a chat id' }); continue; }
|
||||
if (index.has(id)) { skipped.push(id); continue; }
|
||||
try { await this.save(id, JSON.stringify(session)); imported.push(id); }
|
||||
catch (e) { failed.push({ id, error: e.message, code: e.code }); if (e.code === 'disk-full') break; }
|
||||
}
|
||||
return { imported, skipped, failed };
|
||||
},
|
||||
/** Wait until every save queued so far is on disk (tests, shutdown). */
|
||||
idle: () => chain,
|
||||
};
|
||||
}
|
||||
@@ -288,21 +288,32 @@ export function createControl({ control = findControl(), env = process.env, run
|
||||
throw Object.assign(failure(result, 'The partial copies could not be removed.'), { code: result.json?.error?.code ?? 'leave_failed', details: result.json?.error?.details ?? '' });
|
||||
},
|
||||
/**
|
||||
* The DFlash2 drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter }. Control decides
|
||||
* (the table of strata_control/drafting.py); the app never reads it. Nothing is downloaded.
|
||||
* The drafter for a model's run config and the drafting choice (`strata drafting plan`): { needed, gb, line, note, drafter, split, mtp_needed, mtp_gb }. Control decides
|
||||
* (the table of strata_control/drafting.py and the all-cards split engine's rule, strata_control/split_engine.py: the same answer as the Terminal's); the app never reads
|
||||
* it. Nothing is downloaded. `noSplit` asks as if the split engine will not take the start (only after the draft layer for the split could not be downloaded).
|
||||
*/
|
||||
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null } = {}) {
|
||||
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), '--no-split', '--output', 'json'], { timeoutMs: 30_000 });
|
||||
async draftingPlan({ configPath = null, mode = null, family = null, dataDir = null, noSplit = false, unloading = false } = {}) {
|
||||
const result = await call(['drafting', 'plan', ...(configPath ? ['--config', configPath] : []), ...(mode ? ['--mode', mode] : []), ...(family ? ['--family', family] : []), ...(dataDir ? ['--data-dir', dataDir] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--output', 'json'], { timeoutMs: 30_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The drafter could not be checked.');
|
||||
},
|
||||
/** Fetch and pack the drafter when the choice would use it and it is not here (`strata drafting fetch --if-needed`); `onEvent` gets {step, done, total}. */
|
||||
async draftingFetch({ configPath, mode = null, onEvent = null, signal = null } = {}) {
|
||||
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), '--no-split', '--if-needed', '--output', 'jsonl'];
|
||||
async draftingFetch({ configPath, mode = null, noSplit = false, unloading = false, onEvent = null, signal = null } = {}) {
|
||||
const args = ['drafting', 'fetch', '--config', configPath, ...(mode ? ['--mode', mode] : []), ...(noSplit ? ['--no-split'] : []), ...(unloading ? ['--engines-unloading'] : []), '--if-needed', '--output', 'jsonl'];
|
||||
const result = await call(args, { timeoutMs: 3 * 3600_000, acquire: true, onLine: onEvent ?? (() => {}), signal });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw Object.assign(new ControlError(result.json?.error?.message ?? (result.stderr.split(/\r?\n/).filter(Boolean).pop() || 'The DFlash2 drafter could not be downloaded.'), { exitCode: result.code, output: result.json }), { code: 'drafting_failed' });
|
||||
},
|
||||
/**
|
||||
* The all-cards split engine for the run config the app is about to start (`strata split-engine apply`, the step `strata launch` does itself): Control checks the rule, the
|
||||
* engine package and the draft layer on this computer and writes the split engine's run config to `outPath` when the answer is yes. Resolves { split, line, warn, reasons, cards, written };
|
||||
* `configPath` is never changed, nothing is downloaded.
|
||||
*/
|
||||
async splitEngine({ configPath, outPath } = {}) {
|
||||
const result = await call(['split-engine', 'apply', '--config', configPath, '--out', outPath, '--output', 'json'], { timeoutMs: 60_000 });
|
||||
if (result.json?.ok) return result.json;
|
||||
throw failure(result, 'The all-cards split engine could not be checked.');
|
||||
},
|
||||
/**
|
||||
* The picture files of an installed model that has none (strata_control/pictures.py): found or downloaded once (about 0.9 GB, shared), and the run
|
||||
* config's `vision` section worked out for this engine. Nothing else of the model is touched and nothing is written to its run config: the caller does.
|
||||
|
||||
@@ -46,12 +46,15 @@ import * as engine from './engine.mjs';
|
||||
import * as monitor from './monitor.mjs';
|
||||
import * as serving from './serving.mjs';
|
||||
import * as store from './store.mjs';
|
||||
import { createChatStore, storeFailure } from './chat-store.mjs';
|
||||
import { makePrivateDir } from './private-file.mjs';
|
||||
import * as supervisor from './supervisor.mjs';
|
||||
import * as engineInstall from './engine-install.mjs';
|
||||
import { detectBackend, currentBackend, streamChat, inferenceLine } from './inference.mjs';
|
||||
import { CONTEXT_TOKENS, VARIANTS, localModels, variant } from './catalog.mjs';
|
||||
import { browseLocalModels, inspectLocalModel } from './local-models.mjs';
|
||||
import { EventHub, error, json, readBody, readRaw, uid } from './util.mjs';
|
||||
import { keepStreamAlive } from './stream-beat.mjs';
|
||||
import { EventHub, badRequest, error, json, readBody, readRaw, requestUrl, sameSecret, uid } from './util.mjs';
|
||||
import { createUploads } from './chat-uploads.mjs';
|
||||
import { createNativeDrops } from './native-drop.mjs';
|
||||
import { createVisionProbe, effectiveVision, readsPicturesByDefault } from './vision-probe.mjs';
|
||||
@@ -118,6 +121,8 @@ const WORKSPACE_NAME = process.env.STRATA_WORKSPACE ? basename(WORKSPACE) : 'Str
|
||||
const insideApp = path => { const rel = relative(ROOT, resolve(path)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
const hub = new EventHub();
|
||||
/** The chats, as files in the profile's data folder (chat-store.mjs). */
|
||||
const chatStore = createChatStore({ dir: store.DATA_DIR, privateDir: makePrivateDir });
|
||||
const modelProviders = createModelProviders({ directory: store.DATA_DIR, knownThinks: (connection, modelId) => visionProbe.knownThinks(connection, modelId), changed: value => hub.send('providers', withPictureAnswers(value)) });
|
||||
const imageModels = createImageModels({ dataDir: store.DATA_DIR, modelsDir: () => store.MODELS_DIR, real: models.REAL, changed: value => hub.send('image-models', value) });
|
||||
/* The installed app ships Strata Control (python + strata_control) next to the host; a source checkout has none and keeps its older paths. */
|
||||
@@ -943,7 +948,9 @@ const taskTimer = setInterval(() => tasks.tick(), 15000);
|
||||
taskTimer.unref();
|
||||
|
||||
// The share and tunnel listeners take a free port (the next ones above the host's own) instead of a fixed PORT+1 / PORT+2 that Docker, WSL or Hyper-V may hold or reserve.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '0.0.0.0', { skip: [PORT] });
|
||||
// The probe listens on the loopback address only: a probe on 0.0.0.0 made Windows Defender Firewall ask "Allow Node.js to communicate on these networks?" on the
|
||||
// first start of every new install path (QA 0213). Reserved or taken ports are caught on any address; the share listener itself binds 0.0.0.0 only when the person turns sharing on.
|
||||
const sharePort = await findFreePort(Number(process.env.STRATA_SHARE_PORT ?? PORT + 1), '127.0.0.1', { skip: [PORT] });
|
||||
const localShare = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, port: sharePort, changed: () => serving.setSharing(localShare.state()) });
|
||||
const tunnelPort = await findFreePort(Number(process.env.STRATA_TUNNEL_PORT ?? PORT + 2), '127.0.0.1', { skip: [PORT, sharePort] });
|
||||
const tunnelOrigin = createLocalShare({ handle: openai, hasKey: () => !!store.get().serving?.apiKey, host: '127.0.0.1', port: tunnelPort });
|
||||
@@ -954,7 +961,8 @@ await cloudflare.inspect();
|
||||
/* --------------------------------------------------------------- routing --- */
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
const url = requestUrl(req);
|
||||
if (!url) return badRequest(res); // #4397: a bad Host or request target is a 400, never an unhandled rejection
|
||||
const path = url.pathname;
|
||||
const method = req.method ?? 'GET';
|
||||
|
||||
@@ -985,12 +993,55 @@ const server = http.createServer(async (req, res) => {
|
||||
return await staticFile(req, res, path);
|
||||
} catch (err) {
|
||||
console.error('[strata]', err);
|
||||
if (res.headersSent) { res.end(); return; } // a stream already began: it ends here (a second writeHead would throw out of this handler and end the process)
|
||||
error(res, 500, err.message ?? 'Internal error');
|
||||
}
|
||||
});
|
||||
|
||||
async function api(req, res, path, method, url) {
|
||||
if(path==='/api/fonts'&&method==='GET')return json(res,200,await systemFonts());
|
||||
// --- chats: one file each in the data folder -----------------------------
|
||||
// Not held back by a model folder move: saving a chat has nothing to do with the models. A page must name itself (x-strata-client), which
|
||||
// a web page from another site cannot do without the browser asking the host first.
|
||||
const chatsRoute = /^\/api\/chats(?:\/([^/]+))?$/.exec(path);
|
||||
if (chatsRoute) {
|
||||
if (!req.headers['x-strata-client']) return error(res, 403, 'Not allowed.');
|
||||
let id = null;
|
||||
try { id = chatsRoute[1] ? decodeURIComponent(chatsRoute[1]) : null; } catch { return error(res, 400, 'That is not a chat id.', { code: 'bad-id' }); }
|
||||
const from = typeof req.headers['x-strata-window'] === 'string' ? req.headers['x-strata-window'].slice(0, 64) : null;
|
||||
try {
|
||||
if (!id && method === 'GET') return json(res, 200, chatStore.list());
|
||||
if (id === 'import' && method === 'POST') {
|
||||
const body = JSON.parse((await readRaw(req, 512 * 1024 * 1024)).toString('utf8') || '{}');
|
||||
const result = await chatStore.importMany(body.sessions);
|
||||
for (const imported of result.imported) hub.send('chats', { id: imported, rev: chatStore.rev(imported), from });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
if (id && method === 'GET') {
|
||||
const text = await chatStore.read(id);
|
||||
if (text === null) return error(res, 404, 'No such chat.', { code: 'not-found' });
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text), 'cache-control': 'no-store' });
|
||||
res.end(text);
|
||||
return undefined;
|
||||
}
|
||||
if (id && method === 'PUT') {
|
||||
const text = (await readRaw(req, 256 * 1024 * 1024)).toString('utf8');
|
||||
const base = req.headers['x-strata-base-rev'];
|
||||
const saved = await chatStore.save(id, text, { baseRev: typeof base === 'string' && /^\d+$/.test(base) ? Number(base) : null });
|
||||
hub.send('chats', { id, rev: saved.rev, from });
|
||||
return json(res, 200, saved);
|
||||
}
|
||||
if (id && method === 'DELETE') {
|
||||
const removed = await chatStore.remove(id);
|
||||
hub.send('chats', { id, deleted: true, from });
|
||||
return json(res, 200, removed);
|
||||
}
|
||||
} catch (e) {
|
||||
const failure = e instanceof SyntaxError ? { status: 400, message: 'The request must be JSON.', code: 'bad-json' } : storeFailure(e);
|
||||
return error(res, failure.status, failure.message, { code: failure.code });
|
||||
}
|
||||
return error(res, 405, 'Method not allowed.');
|
||||
}
|
||||
if (storageChanging && !['GET', 'OPTIONS'].includes(method)) return error(res, 409, 'Wait for the model folder check to finish.');
|
||||
// --- events -------------------------------------------------------------
|
||||
if (path === '/api/events') return hub.add(res);
|
||||
@@ -1195,9 +1246,7 @@ async function api(req, res, path, method, url) {
|
||||
}
|
||||
// One revision attempt for a job this host started: the app's assistant works inside the candidate folder.
|
||||
if (path === '/api/internal/timeskip-revise' && method === 'POST') {
|
||||
const given = Buffer.from(String(req.headers['x-strata-internal'] ?? ''));
|
||||
const want = Buffer.from(INTERNAL_SECRET);
|
||||
if (given.length !== want.length || !timingSafeEqual(given, want)) return error(res, 403, 'Not allowed.');
|
||||
if (!sameSecret(req.headers['x-strata-internal'], INTERNAL_SECRET)) return error(res, 403, 'Not allowed.');
|
||||
const body = await readBody(req);
|
||||
const cwd = String(body.cwd ?? '');
|
||||
// Only the job's own candidate folders may be worked in.
|
||||
@@ -1307,8 +1356,7 @@ async function api(req, res, path, method, url) {
|
||||
// The shell (and nothing else) holds the secret that lets it end the host or tell it which window is in use.
|
||||
const fromShell = () => {
|
||||
const token = process.env.STRATA_SHUTDOWN_TOKEN ?? '';
|
||||
const given = String(req.headers['x-strata-shutdown'] ?? '');
|
||||
return token.length >= 32 && given.length === token.length && timingSafeEqual(Buffer.from(given), Buffer.from(token));
|
||||
return token.length >= 32 && sameSecret(req.headers['x-strata-shutdown'], token);
|
||||
};
|
||||
// Which window the person is using: the page shows "Quit Strata" in browser mode only.
|
||||
if (path === '/api/app/mode' && method === 'GET') return json(res, 200, appUi.state());
|
||||
@@ -1614,7 +1662,7 @@ async function api(req, res, path, method, url) {
|
||||
if (cancelMatch && method === 'POST') return json(res, 200, serving.cancel(cancelMatch[1]));
|
||||
|
||||
// --- monitor ------------------------------------------------------------
|
||||
if (path === '/api/monitor') { monitor.watch(); return json(res, 200, monitor.snapshot()); }
|
||||
if (path === '/api/monitor') { monitor.watch(); if (url.searchParams.get('drives') === '1') monitor.watchDrives(); return json(res, 200, monitor.snapshot()); }
|
||||
|
||||
// --- skills / workspace -------------------------------------------------
|
||||
if (path === '/api/skills' && method === 'GET') {
|
||||
@@ -1989,6 +2037,28 @@ async function api(req, res, path, method, url) {
|
||||
if (typeof b.sessionId !== 'string' || !['manual', 'plan', 'bypass'].includes(b.mode)) return error(res, 400, 'sessionId and mode (manual, plan or bypass) are required');
|
||||
try { return json(res, 200, await agent.setMode(b.sessionId, b.mode)); } catch (e) { return error(res, e.code === 'mode-unavailable' ? 409 : 502, e.message); }
|
||||
}
|
||||
/* A page that has just loaded asks which chats have a turn running (a reload does not stop a turn), and takes one over again: the catch-up
|
||||
of what was said while no page was there, then the live frames, as on /api/chat/stream. A turn that ended while the page was away
|
||||
(the page names it by requestId) is sent once, whole. 404 when there is nothing to take over. */
|
||||
if (path === '/api/chat/active' && method === 'GET') return json(res, 200, { turns: agent.runningTurns() });
|
||||
if (path === '/api/chat/attach' && method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
if (typeof b.sessionId !== 'string' || !b.sessionId) return error(res, 400, 'sessionId is required');
|
||||
const controller = new AbortController();
|
||||
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
|
||||
let started = false;
|
||||
const write = frame => {
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
if (!started) { started = true; res.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive' }); keepStreamAlive(res); }
|
||||
res.write(`${JSON.stringify(frame)}
|
||||
`);
|
||||
};
|
||||
const attached = agent.attach({ chatId: b.sessionId, requestId: typeof b.requestId === 'string' ? b.requestId : null, onFrame: write, signal: controller.signal });
|
||||
if (!attached) return error(res, 404, 'There is no turn to take over.', { code: 'no-turn' });
|
||||
await Promise.race([attached.ended, new Promise(r => controller.signal.addEventListener('abort', r, { once: true }))]);
|
||||
if (!res.writableEnded) res.end();
|
||||
return undefined;
|
||||
}
|
||||
/* Stop one chat's turn. Resolves once the turn has really ended, not when the cancel was admitted. */
|
||||
if (path === '/api/chat/stop' && method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
@@ -2018,6 +2088,7 @@ async function api(req, res, path, method, url) {
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
});
|
||||
keepStreamAlive(res); // #4878: a quiet turn stays open; the timer ends with the response
|
||||
const controller = new AbortController();
|
||||
res.on('close', () => { if (!res.writableEnded) controller.abort(); });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import { apiPath } from './api-paths.mjs';
|
||||
import { listenProblem } from './ports.mjs';
|
||||
import { badRequest, requestUrl } from './util.mjs';
|
||||
/** Sharing without a key is allowed (owner decision, 0.2.2): the person is told once, in plain words, and the app keeps offering to make a key. */
|
||||
export const NO_KEY_WARNING = 'No API key is set, so any device on this network can use your model. Create an API key to protect it.';
|
||||
export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0.0.0.0', port = 8792 }) {
|
||||
@@ -13,7 +14,9 @@ export function createLocalShare({ handle, hasKey, changed = () => {}, host = '0
|
||||
if (active) return state();
|
||||
if (listener) throw new Error('Sharing is already starting.');
|
||||
listener = http.createServer(async (req, res) => {
|
||||
const path = apiPath(new URL(req.url ?? '/', 'http://localhost').pathname);
|
||||
const url = requestUrl({ url: req.url, headers: {} }); // the Host header is not read here; a request target that is not a path is a 400 (#4397)
|
||||
if (!url) return badRequest(res);
|
||||
const path = apiPath(url.pathname);
|
||||
const method = req.method ?? 'GET';
|
||||
// #3208: with an API key set, a browser page of any site can call the shared API - the key decides, not the
|
||||
// origin. The browser's preflight (OPTIONS) never carries the key (the spec says so), so it is answered here,
|
||||
|
||||
@@ -62,7 +62,12 @@ let ticker = null;
|
||||
let memoryWarningShown = false;
|
||||
let sensorsPending = false, sensorsAt = 0;
|
||||
/* The Monitor screen asks (GET /api/monitor) every few seconds while it is open; a model that is loaded counts as live (#4066). */
|
||||
let watchedUntil = 0, liveProbe = () => false;
|
||||
let watchedUntil = 0, drivesUntil = 0, liveProbe = () => false;
|
||||
const hostStartedAt = Date.now();
|
||||
/** The drive-temperature reader is not started in the first minute of the host: the app's own start-up traffic is not a person looking at the Monitor (#4661). */
|
||||
export const DRIVES_AFTER_MS = 60000;
|
||||
/** Only the Monitor screen's own ask (GET /api/monitor?drives=1) wants drive temperatures; the start-up fetch and the Models page do not show them. */
|
||||
export function watchDrives() { drivesUntil = Date.now() + WATCH_MS; }
|
||||
export function watch() {
|
||||
const wasIdle = Date.now() >= watchedUntil;
|
||||
watchedUntil = Date.now() + WATCH_MS;
|
||||
@@ -127,14 +132,14 @@ function tick() {
|
||||
if(!sensorsPending&&Date.now()-sensorsAt>=pace.intervalMs){
|
||||
sensorsPending=true;sensorsAt=Date.now();
|
||||
const hardware=cachedHardware()??hwCache;
|
||||
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<watchedUntil?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
|
||||
void Promise.all([readGpuTelemetry(hardware?.gpus??[],{transfer:pace.transfer}),sampleSystem(hardware?.processors??[]),(process.env.STRATA_REAL_ENGINE==='1'&&Date.now()<drivesUntil&&Date.now()-hostStartedAt>DRIVES_AFTER_MS?readTemperatures():Promise.resolve(temperatures)).catch(()=>({cpuTempC:null,ssdTempC:null}))])
|
||||
.then(([readings,system,temps])=>{state.hardware.gpus=readings;systemReading=system;temperatures=temps;})
|
||||
.catch(()=>{state.hardware.gpus=[];systemReading=null;})
|
||||
.finally(()=>{sensorsPending=false;});
|
||||
}
|
||||
/* #4661: the drive-temperature reader asks every physical drive for its temperature. Only while the Monitor is looked at, never from the first second of the app
|
||||
(on some PCs the storage queries stall the whole system); it ends a couple of minutes after the Monitor was last seen. */
|
||||
if(Date.now()>watchedUntil+120000)stopTemperatures();
|
||||
if(Date.now()>drivesUntil+120000)stopTemperatures();
|
||||
addEnergy(hardwareSnapshot().powerW,Date.now());
|
||||
const hw = state.hardware;
|
||||
const busy = state.phase === 'generating' || state.phase === 'reading' || state.phase === 'thinking';
|
||||
|
||||
@@ -27,6 +27,8 @@ import { parseSaved as parseRoles, mirror as mirrorRoles } from './gpu-roles.mjs
|
||||
import { configContextLimit } from './dsv41-context.mjs';
|
||||
|
||||
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
||||
/* What the load says when the server had to start the normal layer split because the all-cards split engine would not come up (the engine's own reason stays in its log). */
|
||||
const SPLIT_FELL_BACK='The all-cards split engine could not start, so the normal layer split runs.';
|
||||
const safeId=value=>typeof value==='string'&&/^[a-z0-9](?:[a-z0-9_-]|\.(?!\.)){0,79}$/i.test(value); /* a dot inside the name: deepseek-v4.1-flash-ist-das-2.00bpw */
|
||||
const busy=requests=>requests.some(r=>['queued','generating'].includes(r.state));
|
||||
function readJson(path,fallback={}){try{return JSON.parse(readFileSync(path,'utf8').replace(/^\uFEFF/,''));}catch{return fallback;}}
|
||||
@@ -38,6 +40,8 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
const settingsPath=join(directory,'native-host.json');
|
||||
let preferences=readJson(settingsPath,{repo:defaultRepo});
|
||||
let child=null,seq=0,buffer='',pending=new Map(),definitions=[],refreshing=null,lastRead=0,connecting=null;
|
||||
/* QAGUI 0213: until Control has answered once, the state says it is still being asked (`checking`), not that nothing is installed. A failed read during Control's start is read again after a few seconds instead of standing for 45 s; after READ_TRIES failures the answer is final and the error shows. */
|
||||
let everRead=false,failedReads=0;const READ_TRIES=4,RETRY_MS=3000;
|
||||
let snapshot={available:false,repo:preferences.repo,summary:'Connect a local Strata installation.',catalog:null,installed:null,hardware:null,error:null};
|
||||
const instances=new Map(),processes=new Map(),jobs=new Map(),logs=[];
|
||||
const savedJobs=readJson(join(directory,'native-jobs.json'),[]);/* An app update ends the app on purpose: the installer replaces it and starts it again. The job that asked for it was saved as running together with the app version it started from (appUpdateFrom); a start that finds a different version knows the update finished, instead of calling it an interrupted operation. */
|
||||
@@ -71,7 +75,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
/* Does the engine in this release serve DFlash2? Control knows (strata_control/drafting.py); asked once, false until it answers. The Models screen offers the choice only when true. */
|
||||
let dflash2Served=null,dflash2Support=null,dflash2Selq4=false,dflash2Split=false;
|
||||
function probeDrafting(){if(!packaged||dflash2Served!==null||typeof control.draftingPlan!=='function')return;dflash2Served=false;control.draftingPlan({mode:'auto'}).then(p=>{dflash2Served=p.serves===true;dflash2Support=p.serves===true&&p.support&&p.support_line?{level:p.support,line:p.support_line}:null;dflash2Selq4=p.serves===true&&p.selq4===true;dflash2Split=p.split===true;emit();}).catch(()=>{});}
|
||||
function state(){const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
|
||||
function state(){const checking=!everRead&&failedReads<READ_TRIES;const folder=preferences.downloadDirectory??(modelsDirectory?modelsDirectory():snapshot.installed?.data_folder),space=folder?diskSpace(folder):null;return {...snapshot,checking,downloadDirectory:folder,downloadFreeBytes:space?.free??null,downloadTotalBytes:space?.total??null,fields:nativeFields(),instances:[...instances.values()].map(({key,configPath,fingerprint,mcpServers,...i})=>structuredClone(i)),defaultId,stoppedForUpdate:stoppedForUpdate?{updated:stoppedForUpdate.updated,at:stoppedForUpdate.at,models:stoppedForUpdate.instances.map(i=>({id:i.id,label:i.label}))}:null,jobs:[...jobs.values()].slice(-12).map(({process,abort,engineAbort,rawResult,...j})=>j),capabilities:{responses:sourceText().includes('/v1/responses'),updates:!!snapshot.available,rollback:previousEngine(),componentUpdates:packaged&&typeof control.update==='function',calibration:packaged?false:snapshot.hardware?.gpus?.some(g=>/nvidia/i.test(g.name??''))??false,dflash2:dflash2Served===true,dflash2Support,dflash2Selq4,dflash2Split}};}
|
||||
let sourceCache={path:'',time:0,text:''};
|
||||
function sourceText(){try{const path=join(packaged?control.control.dir:preferences.repo,'serve/server.py'),time=statSync(path).mtimeMs;if(sourceCache.path===path&&sourceCache.time===time)return sourceCache.text;sourceCache={path,time,text:readFileSync(path,'utf8')};return sourceCache.text;}catch{return '';}}
|
||||
function nativeFields(){const source=sourceText();return NATIVE_FIELDS.map(field=>({...field,supported:APP_OWNED_FIELDS.includes(field.id)||field.id.startsWith('vision_')||field.id.startsWith('mcp_')||field.id==='vram_reserve_mib'||source.includes(field.id)}));}
|
||||
@@ -148,7 +152,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
is refreshed in the background. Only the first read, and a forced one after an operation, waits. */
|
||||
if(refreshing)return !force&&snapshot.available?state():force?refreshing.then(()=>refresh(true)):refreshing; /* a forced read that meets one already running (started before an engine update finished) waits for it and reads again, so it never settles on the old answer */if(!force&&Date.now()-lastRead<45000)return state();
|
||||
probeDrafting();
|
||||
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){snapshot={...snapshot,available:false,error:error.message};lastRead=Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
|
||||
refreshing=(async()=>{try{const [catalog,status]=packaged?await (async()=>{const s=await tool('strata_status',{hardware:true});const c=await tool('strata_models').catch(error=>({families:[],contexts:[],recommendation:{family:null,model:null},context_note:'',error:error.message}));return [c,s];})():await Promise.all([tool('strata_models'),tool('strata_status',{hardware:true})]);let visionReader=false;if(packaged){try{const e=await control.engine();const root=e?.engine?.root??(e?.engine?.exe?dirname(e.engine.exe):'');visionReader=!!root&&['strata-vision','strata-vision.exe'].some(n=>existsSync(join(root,n)));}catch{visionReader=snapshot.visionReader===true;}}const markImages=c=>markPictures(c,visionReader);snapshot={available:true,repo:packaged?status.installDir:repo(),version:packaged?(control.version??null):await execute('git',['describe','--tags','--always'],repo()).then(s=>s.trim()).catch(()=>null),summary:status.summary,catalog:markEngine(packaged?markImages(catalog):catalog,status.engine),engineInfo:{version:status.engine?.version??null,source:status.engine?.source??null},visionReader:packaged?visionReader:true,installed:installedInfo(status.installed),hardware:status.hardware,error:null,install:status.install,external:status.running??null,...(packaged?{packaged:true,installDir:status.installDir,engine:status.engine,installerError:status.installerError}:{})};lastRead=Date.now();everRead=true;failedReads=0;if(status.install?.running&&![...jobs.values()].some(j=>j.state==='running'))begin('install',j=>watchInstall(j,status.install));}catch(error){failedReads++;snapshot={...snapshot,available:false,error:error.message};lastRead=!everRead&&failedReads<READ_TRIES?Date.now()-45000+RETRY_MS:Date.now();}emit();return state();})().finally(()=>{refreshing=null;});const run=refreshing;if(!force&&snapshot.available){run.catch(()=>{});return state();}return run;
|
||||
}
|
||||
function configure({repository}){if(packaged){if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));if(!statSync(next).isDirectory())throw new Error('Choose the folder that holds your Strata model configurations.');preferences={...preferences,installDir:next};packagedInstall=null;disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}if(instances.size||[...jobs.values()].some(j=>j.state==='running'))throw new Error('Unload models and finish operations before changing the installation.');const next=realpathSync(String(repository));const old=preferences;preferences={...preferences,repo:next};try{repo();}catch(e){preferences=old;throw e;}disconnect();writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});lastRead=0;return refresh(true);}
|
||||
function setDownloadDirectory(input,{insideJob=false}={}){if(!insideJob&&[...jobs.values()].some(j=>j.state==='running'))throw new Error('Finish the current operation before changing the folder.');if(typeof input!=='string'||!input.trim()||/[\r\n\0]/.test(input))throw new Error('Choose a model folder.');let path;try{path=realpathSync(input.trim());}catch{throw new Error('That folder does not exist. Check the path, or that the drive is connected.');}if(!statSync(path).isDirectory())throw new Error('Choose a folder, not a file.');/* A whole drive (D:\) is not filled with models: D:\Strata is made and used. */if(isDriveRoot(path)){const own=dataFolderFromPick(path).path;makeFolder(own);path=realpathSync(own);}try{accessSync(path,constants.R_OK|constants.W_OK);}catch{throw new Error('Strata cannot write in that folder. Choose a folder you can save files in.');}preferences={...preferences,downloadDirectory:path};/* the same choice for the installer and the Terminal: the Control settings file */if(packaged)rememberDataFolder(controlSettingsFile(),path,Date.now()/1000,true);writeFileSync(settingsPath,JSON.stringify(preferences,null,2),{mode:0o600});shareDataFolder();emit();return state();}
|
||||
@@ -164,7 +168,7 @@ export function createNativeControl({directory,requests=()=>[],changed=()=>{},sp
|
||||
function previousEngine(){if(packaged)return !!snapshot.engine?.previous;try{const data=snapshot.installed?.data_dir??snapshot.installed?.data_folder;return [join(preferences.repo,'engine/.previous'),data&&join(data,'engine/.previous')].filter(Boolean).some(existsSync)&&readFileSync(join(preferences.repo,'setup.py'),'utf8').includes('--rollback-engine');}catch{return false;}}
|
||||
async function plan(args){await refresh();if(!snapshot.available)throw new Error(snapshot.error);const result=await tool('strata_install',installArgs(args,false));
|
||||
/* A chosen drafter that is not on this computer yet is part of the download: its size is listed beside the model's (Control decides; nothing is fetched here). */
|
||||
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
|
||||
const choice=args.drafting??(dflash2Served===true?'auto':undefined);if(packaged&&typeof choice==='string'&&typeof control.draftingPlan==='function'){try{const size=snapshot.catalog?.families?.find(f=>f.family===args.family)?.sizes.find(s=>s.model===args.model),here=snapshot.installed?.models?.find(m=>m.model===size?.id);const d=await control.draftingPlan({mode:choice,family:args.family??null,dataDir:here?.runDataFolder??snapshot.installed?.data_folder??null,...(unloadsRunning(args)?{unloading:true}:{})});if(d.needed||d.mtp_needed)return {...result,drafting:{needed:true,drafter:d.needed?'dflash2':'mtp',gb:d.needed?d.gb:d.mtp_gb,line:d.line}};}catch{/* the model plan still stands */}}
|
||||
return result;}
|
||||
/* The folder the person picked in the first-run chooser can be the one that holds their GGUF files (or a folder inside it). It is theirs, not a Strata data folder: packs are never made in it, and a Strata folder around it (<Strata>/models/<tag>) is used instead (#3507, #3424). */
|
||||
function ownFilesDataFolder(candidate,args){if(!candidate||args.data_dir!==undefined||!args.gguf_dir)return candidate;const same=(a,b)=>{const x=resolve(a),y=resolve(b);return process.platform==='win32'?x.toLowerCase()===y.toLowerCase():x===y;};const inside=(a,b)=>{const rel=relative(resolve(b),resolve(a));return rel===''||(!!rel&&!rel.startsWith('..')&&!isAbsolute(rel));};if(!same(candidate,args.gguf_dir)&&!inside(candidate,args.gguf_dir))return candidate;const models=dirname(resolve(args.gguf_dir)),top=dirname(models);return basename(models).toLowerCase()==='models'&&/strata/i.test(basename(top))?top:undefined;}
|
||||
@@ -317,22 +321,28 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
|
||||
}
|
||||
/* One drafter per run. Control (strata_control/drafting.py) says which one this load runs and whether its file is on this computer: the plan of the saved model file with the choice the Load carries. */
|
||||
async function draftPlan(args,configFile){
|
||||
/* The engines this Load is about to unload (the load below unloads every instance unless it adds one beside them): their graphics memory is given back before the new engine starts, so Control must not count it as busy memory when it asks whether the all-cards split engine fits (`--engines-unloading`; the Terminal does the same, Runner.card_probe). A model added beside the others (`multiple`) keeps their memory busy. */
|
||||
function unloadsRunning(args){return instances.size>0&&args?.multiple!==true;}
|
||||
async function draftPlan(args,configFile,{noSplit=false}={}){
|
||||
if(!packaged||typeof control.draftingPlan!=='function')return null;
|
||||
const asked=args.values?.drafting;
|
||||
try{return await control.draftingPlan({configPath:configFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked})});}catch{return null;}
|
||||
/* The plan is for the cards this Load uses: the screen's own card choice goes over the ones the saved settings name (the all-cards split engine needs two or more). */
|
||||
let planFile=configFile,temporary=null;
|
||||
if(Array.isArray(args.gpus)&&args.gpus.length&&args.gpus.every(Number.isInteger)){try{const work=join(directory,'native-instances');mkdirSync(work,{recursive:true});temporary=join(work,'drafting-plan-'+randomUUID()+'.json');writeFileSync(temporary,JSON.stringify({...readJson(configFile),gpu:args.gpus.length===1?args.gpus[0]:args.gpus}),{mode:0o600});planFile=temporary;}catch{temporary=null;planFile=configFile;}}
|
||||
try{return await control.draftingPlan({configPath:planFile,...(asked===undefined?{}:{mode:asked===null?'auto':asked}),...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{})});}catch{return null;}
|
||||
finally{if(temporary){try{rmSync(temporary,{force:true});}catch{/* best effort */}}}
|
||||
}
|
||||
/* The choice changed to the MTP and its layer is not here yet: it is fetched once, by the installer (finished steps are skipped), in this Load. Only the Models screen's own Load carries the choice; a restart or a tuning run never fetches. */
|
||||
async function installMtpLayer(job,args,config){
|
||||
const fam=snapshot.catalog?.families?.find(f=>f.sizes.some(s=>s.id===args.model)),size=fam?.sizes.find(s=>s.id===args.model);
|
||||
if(!fam||!size)return;
|
||||
job.summary='Downloading the MTP drafter…';emit();
|
||||
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});}
|
||||
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}}/* one short line, not the installer's own text; Drafting below says what runs instead */
|
||||
/* the model's own files folder (the run config's --native): without it the installer, asked for this model by name, would fetch the whole model again next to the layer (an app install adopted from the person's own GGUF folder has its files elsewhere) */const native=(()=>{const a=(config.args??[]).map(String),i=a.indexOf('--native');return i>=0?(a[i+1]??''):'';})();try{await install(job,{family:fam.family,model:size.model,drafter:'mtp',vision:args.vision??(config.vision?'yes':'no'),...(native?{gguf_dir:dirname(native)}:{})});return true;}
|
||||
catch{if(job.state==='running'){job.drafterNote='The MTP drafter could not be downloaded.';emit();}return false;}/* one short line, not the installer's own text; Drafting below says what runs instead */
|
||||
}
|
||||
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
|
||||
Never stops the load: a drafter that cannot be had leaves the MTP running if its layer is here, else no drafter, and the job says so. The choice itself is remembered in the model's settings file. */
|
||||
async function prepareDrafter(job,args,cfg,configFile,drafts){
|
||||
async function prepareDrafter(job,args,cfg,configFile,drafts,{noSplit=false}={}){
|
||||
const asked=args.values?.drafting;
|
||||
if(asked!==undefined)rememberSetting(configFile,'drafting',asked===null||asked==='auto'?null:asked);
|
||||
/* Only the Models screen's own Load passes the choice (its dialog has listed the download): a restart, a tuning run or any other load leaves `asked` undefined and never fetches; the model then starts with the drafter that is on this computer and says why. */
|
||||
@@ -341,7 +351,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
try{
|
||||
writeFileSync(probe,JSON.stringify(cfg),{mode:0o600});
|
||||
job.engineAbort??=new AbortController();
|
||||
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
|
||||
await control.draftingFetch({configPath:probe,mode:cfg.drafting??'auto',...(noSplit?{noSplit:true}:{}),...(unloadsRunning(args)?{unloading:true}:{}),signal:job.engineAbort.signal,onEvent:event=>{if(event.event==='progress'&&job.state==='running'){job.summary=event.step;job.details=event.total>1&&event.done<event.total?`${(event.done/1e9).toFixed(2)} of ${(event.total/1e9).toFixed(2)} GB`:undefined;emit();}}});
|
||||
if(job.state==='running'){job.summary='Loading the model…';job.details=undefined;emit();}
|
||||
}catch(error){if(job.state==='running'){job.drafterNote=error.message;job.summary=drafts.mtp_here===false?'The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…':'The DFlash2 drafter could not be downloaded, so the model starts with the MTP. Loading the model…';emit();}}
|
||||
finally{try{rmSync(probe,{force:true});}catch{/* best effort */}}
|
||||
@@ -349,7 +359,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* One key of a model's settings file, set or removed (the Drafting choice): written like the Settings screen writes it, to be there for the next load. */
|
||||
function rememberSetting(configFile,key,value){try{const current=readJson(configFile);if(value===null?!(key in current):current[key]===value)return;const next={...current};if(value===null)delete next[key];else next[key]=value;const temp=configFile+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,configFile);}catch{/* the load goes on with the choice for this start */}}
|
||||
/* Which drafter runs, when it is not the one that was asked for (the engine's own words, "Drafting: MTP (DFlash2 didn't fit)"): under Details of the load. */
|
||||
async function noteDrafting(job,instance,key){let note='';try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok)note=String((await r.json())?.engine?.drafting_note??'');}catch{/* a convenience */}const lines=[job.drafterNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
|
||||
async function noteDrafting(job,instance,key){let note='',fellBack=false;try{const r=await fetcher(`http://127.0.0.1:${instance.port}/metrics`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(r.ok){const engine=(await r.json())?.engine;note=String(engine?.drafting_note??'');fellBack=engine?.split_fallback===true;}}catch{/* a convenience */}/* the all-cards split engine: the line of what runs (the server starts the normal layer split by itself when the split engine would not come up) */const lines=[job.drafterNote,fellBack?SPLIT_FELL_BACK:instance.splitNote,note].filter(Boolean);if(lines.length)job.details=[job.details,...lines].filter(Boolean).join('\n');}
|
||||
/* ---- The picture files of an installed model that has none (strata_control/pictures.py, run by Control) ----
|
||||
Only the Load or the "Download picture-reading files" button the person pressed reaches the network, and only for the picture file (about 0.9 GB, one copy
|
||||
shared by the models of a data folder). The model's own files, its pack, its draft layer, the data folder and the settings' data_dir are not touched; the one change
|
||||
@@ -420,11 +430,13 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* Read pictures is on and the picture files are not here: the Load fetches those files (about 0.9 GB, once) and nothing else of the model, then goes on. No internet: the model loads without pictures and the page says so. */
|
||||
let picturesNote=null;{const got=await ensurePictures(job,args,configFile,config,{missing:entry.picturesMissing===true});if(job.state!=='running')return;config=got.config;args=got.args;picturesNote=got.note;}
|
||||
/* One drafter per run (strata_control/drafting.py): switching to the MTP on a model set up for DFlash2 fetches the layer once, here; DFlash2 is fetched below. */
|
||||
let drafts=await draftPlan(args,configFile);
|
||||
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);}
|
||||
let drafts=await draftPlan(args,configFile),noSplit=false;
|
||||
if(drafts?.mtp_needed===true&&(args.values?.drafting!==undefined||drafts.split===true&&(config.drafting==null||String(config.drafting).toLowerCase()==='auto'))){/* the Models screen's own Load carries the choice; a plain Auto load fetches the layer too when Control says the all-cards split engine will take the start (it runs the MTP and nothing else): the same as the Terminal */await installMtpLayer(job,args,config);if(job.state!=='running')return;await refresh(true);config=checkedSettingsConfig(readFileSync(recordFile(args.model).path,'utf8'));drafts=await draftPlan(args,configFile);
|
||||
/* The draft layer for the split could not be had (no internet, disk): the load goes on as the 0.2.10 app did, the normal layer split with the plan DFlash2's again (the Models screen's own Load fetches that drafter below). One plain line says so. */
|
||||
if(drafts?.split===true&&drafts.mtp_needed===true){noSplit=true;job.drafterNote=job.drafterNote??'The MTP drafter could not be downloaded.';job.summary='Loading the model…';emit();drafts=await draftPlan(args,configFile,{noSplit:true});}}
|
||||
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config,drafts?.choice==='dflash2'&&drafts.needed===true&&args.values?.drafting===undefined?'mtp':drafts?.choice??null);/* (a drafter that is not here and will not be fetched by this load: the MTP runs, and the request slots stay) */
|
||||
let parallelNote=chosenNote;
|
||||
await prepareDrafter(job,args,cfg,configFile,drafts);if(job.state!=='running')return;
|
||||
await prepareDrafter(job,args,cfg,configFile,drafts,{noSplit});if(job.state!=='running')return;
|
||||
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
|
||||
const placement=Array.isArray(cfg.gpu)?cfg.gpu:cfg.gpu===undefined?[]:[cfg.gpu];const matching=[...instances.values()].find(i=>i.model===args.model&&JSON.stringify(i.gpus)===JSON.stringify(placement));
|
||||
if(matching?.status==='ready'&&matching.fingerprint===fingerprint&&(args.multiple||instances.size===1))return {summary:`${matching.label} is already ready.`,instanceId:matching.id};
|
||||
@@ -443,25 +455,43 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
|
||||
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
|
||||
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
|
||||
let proc=null,started=null,retried=null,startWait=null;
|
||||
let proc=null,started=null,retried=null,startWait=null,adopted=false;
|
||||
if(packaged){
|
||||
/* How long this start waits, and the server's own engine wait kept above it. The server ends the engine when its engine wait runs
|
||||
out (900 s, or the config's engine_ready_s), so a start that waits longer than that can never win: as in `strata launch`, the
|
||||
server gets a wait above the start's. It is only ever raised, never lowered, so a config that already names a longer one
|
||||
(setup's 3600 for DeepSeek V4.1-Flash) is left alone. */
|
||||
startWait=startWaitFor(config,entry,args.model);
|
||||
/* The split engine reads every expert into the cards before it answers, and a start that falls back to the layer split begins again: the 10 minutes the Terminal gives a launch (never above the ceiling). */
|
||||
if(!noSplit&&drafts?.split===true)startWait=Math.min(startWaitCeiling(),Math.max(startWait,600));
|
||||
if(startWait+60>Math.max(900,Number(cfg.engine_ready_s)||0))cfg.engine_ready_s=startWait+60;
|
||||
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
|
||||
const used=new Set([...instances.values()].map(i=>i.profile));let slot=1;while(used.has('app-'+slot))slot++;instance.profile='app-'+slot;
|
||||
instances.set(id,instance);if(!defaultId||!args.multiple||wasDefault)defaultId=id;emit();
|
||||
if(dsNote)append(id,dsNote);
|
||||
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
|
||||
/* The all-cards split engine: where the plan says it takes this start, Control turns the run config into the split engine's (the step `strata launch` does itself: the rule, the engine package and the draft layer are checked on this computer) and says so in one line. Any "no" leaves the layer split config as it is. The second try after a failed start is never split. */
|
||||
const splitStart=async value=>{
|
||||
if(noSplit||drafts?.split!==true||typeof control.splitEngine!=='function')return value;
|
||||
const input=join(work,id+'.layer.json'),output=join(work,id+'.split.json');
|
||||
try{
|
||||
writeFileSync(input,JSON.stringify(value,null,2),{mode:0o600});rmSync(output,{force:true});
|
||||
const answer=await control.splitEngine({configPath:input,outPath:output});
|
||||
if(answer.line){instance.splitNote=answer.line;append(id,answer.line);}
|
||||
if(answer.split===true&&answer.written)return JSON.parse(readFileSync(output,'utf8'));
|
||||
}catch{/* the layer split config stands */}
|
||||
finally{for(const f of [input,output]){try{rmSync(f,{force:true});}catch{/* best effort */}}}
|
||||
return value;
|
||||
};
|
||||
const startWith=async(value,split=true)=>{const run=split?await splitStart(value):value;writeFileSync(configPath,JSON.stringify(run,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
|
||||
/* A Control whose wait ran out has NOT stopped the engine: `runtime start` answers "startup_wait_expired" (exit 8), or "runtime operation wait expired" when the owner was too busy to answer in time, while the engine is still loading (#5177 #4293). That is not a failure: Control is asked, and a start it still reports as starting (or ready) is kept and watched below instead of being stopped and marked failed. */
|
||||
const stillLoading=async e=>{if(!/startup_wait_expired|runtime operation wait expired/i.test(String(e?.message??'')+JSON.stringify(e?.output??'')))return false;if(!instance.profile||typeof control.status!=='function'||job.state!=='running')return false;try{const st=await control.status(instance.profile);return st?.state==='starting'||st?.state==='ready';}catch{return false;}};
|
||||
started=startWith(cfg);
|
||||
started.then(()=>append(id,'Strata Control reports the engine is ready.'),error=>{append(id,error.message);});
|
||||
try{await started;}catch(firstError){let error=firstError;
|
||||
/* A settings, profile or run file an elevated run made can be closed to this user: repair the access in Strata's own folders and start again (this is not the memory try below). */
|
||||
const isAccess=e=>/PermissionError|access is denied|EACCES/i.test(String(e?.message??'')+JSON.stringify(e?.output??''));
|
||||
if(isAccess(error)&&typeof control.repairAccess==='function'&&job.state==='running'){await control.repairAccess();append(id,'Strata repaired access to its own files; starting again.');try{await startWith(cfg);started=Promise.resolve();error=null;}catch(again){error=again;}}
|
||||
if(error&&await stillLoading(error)){adopted=true;error=null;}
|
||||
if(error&&sharedSentence&&!isAccess(error)){instance.status='error';instance.error=sharedSentence;emit();throw new Error(sharedSentence);}
|
||||
if(error){const said=humanStartError(error);
|
||||
if(isAccess(error)){instance.status='error';instance.error=said;emit();throw new Error(said);} // access errors are not memory stops: no safer retry
|
||||
@@ -476,16 +506,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
const words=safer?safer.words:FALLBACK_NOTE,detail=[safer?.detail,dropParallel?'one request at a time':null].filter(Boolean).join('; ');
|
||||
retried={words,detail};if(dropParallel){instance.parallel=1;if(!safer)parallelNote=FALLBACK_NOTE;}
|
||||
append(id,`The first start stopped (${said}). Starting once more: ${detail}`);job.summary=words;job.details=[said,`Second try: ${detail}`];instance.notice=words;emit();
|
||||
try{await startWith(retryConfig);append(id,'Strata Control reports the engine is ready (second try).');}
|
||||
catch(second){const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}
|
||||
delete instance.splitNote;
|
||||
try{await startWith(retryConfig,false);append(id,'Strata Control reports the engine is ready (second try).');}
|
||||
catch(second){if(await stillLoading(second))adopted=true;else{const said2=humanStartError(second);instance.status='error';instance.error=said2;/* the first stop and the second try stay in job.details; the pop-up shows the last stop once */emit();throw new Error(instance.error);}}}}
|
||||
} else {
|
||||
proc=spawnProcess(py,[join(repo(),'serve/server.py'),'--engine','strata','--config',configPath,'--port',String(cfg.port),'--host','127.0.0.1'],{cwd:repo(),detached:process.platform!=='win32',stdio:['ignore','pipe','pipe'],env:sourceServerEnvironment()});
|
||||
instances.set(id,instance);processes.set(id,proc);if(!defaultId||!args.multiple||wasDefault)defaultId=id;
|
||||
for(const stream of [proc.stdout,proc.stderr])stream.on('data',chunk=>{const lines=chunk.toString().split('\n').filter(Boolean);lines.forEach(line=>append(id,line.replaceAll(key,'[redacted]')));job.summary=lines.at(-1)??job.summary;emit();});
|
||||
proc.on('error',error=>{instance.status='error';instance.error=error.message;emit();});proc.on('exit',code=>{if(instances.has(id)&&instance.status!=='unloading'){instance.status='error';instance.error=`Engine stopped${code===null?'':` (exit ${code})`}. Open diagnostics or reload.`;emit();}});emit();
|
||||
}
|
||||
const deadline=Date.now()+300000;while(Date.now()<deadline&&job.state==='running'){
|
||||
/* A start Control stopped waiting for is watched for as long as the start asked for (the server ends the engine itself when its own wait runs out, a minute after this one), and Control is asked now and then whether the engine is still there. */
|
||||
const deadline=Date.now()+(adopted?Math.max(300,startWait??300)*1000:300000);let lastAsked=0;while(Date.now()<deadline&&job.state==='running'){
|
||||
if(instance.status==='error')throw new Error(instance.error);
|
||||
if(adopted&&instance.profile&&Date.now()-lastAsked>10000){lastAsked=Date.now();const st=await control.status(instance.profile).catch(()=>null);if(st&&['failed','stopped','stale'].includes(st.state)){instance.status='error';instance.error=humanStartError({message:st.error||'server_exited',output:st});emit();throw new Error(instance.error);}}
|
||||
try{const response=await fetcher(`http://127.0.0.1:${instance.port}/health`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(2000)});if(job.state!=='running'){await unload(id);return;}if(response.ok){instance.status='ready';instance.phase='idle';await noteDrafting(job,instance,key);if(resources||args.vision!==undefined||args.contextChosen===true||JSON.stringify(cfg[USER_KEY]??null)!==JSON.stringify(config[USER_KEY]??null))writeModelConfig(record(args.model).path,{...config,...(resources?{...resources,ui_resource_profile:args.resourceProfile}:{}),...visionPreferences,...(args.contextChosen===true?{ui_context_chosen:true}:{}),...(cfg[USER_KEY]?{[USER_KEY]:cfg[USER_KEY]}:{})});emit();ensurePoll();instance.parallelNote=parallelNote;return {summary:retried?`${instance.label} is ready, after a second try: ${retried.words}`:`${instance.label} is ready.${parallelNote?' '+parallelNote:''}${picturesNote?' '+picturesNote:''}`,instanceId:id};}}catch{}
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));
|
||||
}
|
||||
@@ -496,7 +529,9 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
|
||||
let pollCount=0;
|
||||
/* The engine's metrics carry every finished request with `?requests=all`: a long session makes that a large JSON that the host parses and
|
||||
pushes to the window every 2 s. The last dozen is what the app shows live; the full list is refreshed every tenth poll. */
|
||||
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';}catch{instance.metrics=null;instance.phase='unavailable';}}));emit();}finally{polling=false;}}
|
||||
async function poll(){if(polling||closing)return;polling=true;const full=pollCount++%10===0;try{await Promise.all([...instances.values()].map(async instance=>{if(instance.status!=='ready')return;try{const response=await fetcher(`http://127.0.0.1:${instance.port}/metrics${full?'?requests=all':''}`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(!response.ok)throw new Error('Metrics unavailable');const fresh=await response.json();const older=instance.metrics?.requests,recent=fresh.requests;instance.metrics=full||!Array.isArray(older)||!Array.isArray(recent)||!recent.length?fresh:{...fresh,requests:[...recent,...older.filter(r=>Number(r.time)<Math.min(...recent.map(x=>Number(x.time))))]};const tools=await fetcher(`http://127.0.0.1:${instance.port}/mcp`,{headers:{authorization:`Bearer ${instance.key}`},signal:AbortSignal.timeout(1500)});if(tools.ok)instance.metrics.mcp=await tools.json();instance.phase=instance.metrics.live?.state??'idle';instance.misses=0;}catch{instance.metrics=null;instance.phase='unavailable';instance.misses=(instance.misses??0)+1;await reconcileLost(instance);}}));emit();}finally{polling=false;}}
|
||||
/* The engine of a ready instance stopped answering. After a few looks in a row Control is asked: an owner that says the engine is stopped, failed or lost ends the "ready" the app was still showing (the model looked loaded for good while every request failed, #4973); an engine that is only busy stays as it is. */
|
||||
async function reconcileLost(instance){if(instance.misses<3||instance.status!=='ready'||!instance.profile||typeof control.status!=='function')return;let st=null;try{st=await control.status(instance.profile);}catch{return;}if(instance.status==='ready'&&['stopped','failed','stale'].includes(st?.state)){instance.status='error';instance.error='Engine stopped. Open diagnostics or reload.';}}
|
||||
function ensurePoll(){if(!timer){timer=setInterval(()=>void poll(),2000);timer.unref?.();}void poll();}
|
||||
async function benchmark(job,args){const instance=resolveInstance(args.instanceId);job.instanceId=instance.id;if(busy(requests().filter(r=>r.instanceId===instance.id)))throw new Error('Wait for this model to finish before benchmarking.');const started=Date.now();job.abort=new AbortController();const response=await fetcher(`http://127.0.0.1:${instance.port}/v1/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${instance.key}`},signal:AbortSignal.any([job.abort.signal,AbortSignal.timeout(180000)]),body:JSON.stringify({model:instance.apiModelId,messages:[{role:'user',content:'Explain how a rainbow forms in clear, simple language.'}],temperature:0,reasoning_effort:'none',max_tokens:200,stream:false})});const body=await response.json();if(!response.ok)throw new Error(body.error?.message??'Benchmark failed.');if(job.state!=='running')return;const result={summary:'Measured benchmark complete.',at:new Date().toISOString(),model:instance.apiModelId,durationMs:Date.now()-started,tokensIn:body.usage?.prompt_tokens??null,tokensOut:body.usage?.completion_tokens??null,outputTps:body.timings?.predicted_per_second??null,promptTps:body.timings?.prompt_per_second??null,source:'engine',settings:'Greedy · thinking off · up to 200 output tokens'};writeFileSync(join(directory,'native-benchmark.json'),JSON.stringify(result,null,2),{mode:0o600});return result;}
|
||||
function resolveInstance(id){const aliases=[...instances.values()].filter(i=>i.aliases?.includes(id));if(aliases.length>1&&!instances.has(id)&&![...instances.values()].some(i=>i.apiModelId===id))throw new Error('This alias matches more than one instance. Choose its exact model ID.');const i=instances.get(id)??[...instances.values()].find(i=>i.apiModelId===id)??aliases[0]??(!id&&instances.size===1?[...instances.values()][0]:undefined);if(!i)throw new Error('Choose a loaded model ID.');if(i.status!=='ready')throw new Error('This model is not ready.');return i;}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function engineFacts(config = {}) {
|
||||
* @param {object} [input.values] what the person set for this load (Expert mode's "Concurrent requests")
|
||||
* @param {object} [input.hardware] the host's hardware read (`gpus[{index, vram_gb}]`)
|
||||
* @param {number} [input.requested] the default to aim for
|
||||
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): dflash2 answers one request at a time
|
||||
* @param {string} [input.drafter] the drafter this load runs (resolveDrafter): with dflash2 the app fills in one request at a time unless the person chose a number
|
||||
* @returns {{slots:number, batchMtp:boolean, explicit:boolean, requested:number, note:string|null, slotGb:number}}
|
||||
*/
|
||||
export function chooseParallel({ config = {}, values = {}, hardware = null, requested = DEFAULT_SLOTS, drafter = null }) {
|
||||
@@ -112,10 +112,14 @@ export function chooseParallel({ config = {}, values = {}, hardware = null, requ
|
||||
// A number someone chose (including 1) is the person's decision, and so is an explicit --batch in the engine arguments.
|
||||
if (facts.explicitBatch || (chosen !== null && Number.isFinite(chosen))) {
|
||||
const slots = facts.explicitBatch ? null : Math.max(1, Math.min(1024, Math.trunc(chosen)));
|
||||
// DFlash2 chosen by name with a number of requests (owner, 2026-11-03: the person's own choice wins) runs DFlash2 with that many requests, a drafter per request
|
||||
// (strata_control/drafting.py blocked_by `named`). Expert mode's batch-mtp wish is still added here as in 0.2.10: the server knows the backend and the engine, takes
|
||||
// --batch-mtp out when DFlash2 runs, and keeps it when the MTP runs instead (Metal, which has no per-request drafters).
|
||||
const wantMtp = (values.batchMtp ?? config.batchMtp) === true && !facts.batchMtp && facts.mtp && facts.gpus.length === 1 && (slots ?? 0) > 1;
|
||||
return { slots: slots ?? 0, batchMtp: wantMtp, explicit: true, requested, note: null, slotGb: slot };
|
||||
}
|
||||
// DFlash2 chosen on purpose: it answers one request at a time, and request slots would keep the MTP (the engine says "not --batch"). Nobody chose a number here, so one.
|
||||
// DFlash2 (named, or Auto where the engine has it) and nobody chose a number of requests: one at a time, the app does not fill in slots beside it. (A number the person chose is kept
|
||||
// above: DFlash2 named runs it with that many requests; Auto keeps the MTP for several.)
|
||||
if ((drafter ?? ((values.drafting ?? config.drafting) === 'dflash2' ? 'dflash2' : null)) === 'dflash2') return { slots: 1, batchMtp: false, explicit: false, requested, note: DFLASH2_NOTE, slotGb: slot };
|
||||
const cards = (hardware?.gpus ?? []).filter(g => facts.gpus.includes(g.index)).map(g => Number(g.vram_gb)).filter(n => Number.isFinite(n) && n > 0);
|
||||
// Without a hardware read nothing can be sized: one at a time is the safe default (a number can still be chosen).
|
||||
|
||||
@@ -125,6 +125,8 @@ export const engineVersionOfExe=exe=>{const m=/engine[\\/](\d+(?:\.\d+)+)-/.exec
|
||||
technical line for Details. The caller tries once and never again, and does not save this config. */
|
||||
export function saferStart(config,hardware,model,failure){
|
||||
const text=String(failure??'');
|
||||
// The engine refused an oversized prompt chunk at the start (an APU whose pool the expert cache fills; the person or the environment named the chunk): the same chunk would fail again, and a memory setting is not the cause.
|
||||
if(/prompt chunk does not fit/i.test(text))return null;
|
||||
// A start that died for a reason that is not memory (a missing or bad DLL, a crash, a blocked file) is not helped by a memory setting, and must not be called a memory failure.
|
||||
if(/0xC0000135|0xC000007B|0xC0000142|0xC000001D|0xC0000005|0xC0000409|0xC0000022|error while loading shared libraries|cannot open shared object file|\bENOENT\b|exit code 127|exit code 139|illegal instruction/i.test(text))return null;
|
||||
const ranOut=/out of memory|memory guard|\boom\b|\bkilled\b|exit code 137|cannot allocate|not enough memory|bad_alloc|insufficient memory/i.test(text),first=ranOut?'The first start ran out of memory':'The first start did not finish';
|
||||
|
||||
@@ -9,7 +9,7 @@ export const NATIVE_FIELDS=[
|
||||
{id:'idle_unload_s',label:'Unload after idle seconds',group:'Serving',type:'number',min:0,max:86400,hint:'Zero keeps the model loaded.'},
|
||||
{id:'lazy_load',label:'Load on first request',group:'Serving',type:'boolean'},
|
||||
{id:'mtp',notForModel:'deepseek',label:'Draft layer (MTP)',group:'Serving',type:'boolean',hint:'The draft layer guesses several words ahead, which makes answers faster, and costs about 1 GB of graphics memory and some RAM. Turn it off if the model stops while it starts, or the card is small: the expert cache gets that memory back and answers are slower. Default is on.'},
|
||||
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. If DFlash2 cannot run (several requests at once), the MTP is used alone and Details says why. Off turns the drafter off.'},
|
||||
{id:'drafting',notForModel:'deepseek',label:'Drafting',group:'Serving',type:'enum',choices:['auto','mtp','dflash2','off'],hint:'Which drafter guesses words ahead to make answers faster, one at a time. Auto (recommended) is DFlash2 for Qwen3.8-Flash-Next where the engine has it, the MTP otherwise; on two or more identical graphics cards that the all-cards split engine takes, it is that engine with the MTP draft layer. DFlash2 downloads a 1.2 GB drafter once, the MTP its draft layer once. With Auto, several requests at once use the MTP. If you choose DFlash2 yourself, it runs with the number of requests you set. If DFlash2 cannot run, the MTP is used alone and Details says why. Off turns the drafter off.'},
|
||||
{id:'batchMtp',notForModel:'deepseek',label:'MTP per request',group:'Serving',type:'boolean',hint:'Each concurrent request uses the draft layer. Left alone, Strata turns it on for one graphics card with 24 GB or more and several requests at once, and off otherwise; choose on or off here to decide yourself.'},
|
||||
{id:'ds_experts',label:'Speed (DeepSeek)',group:'Serving',type:'enum',choices:['5','6'],forModel:'deepseek',hint:'Faster uses 5 of 6 experts per word: a small quality change, usually not noticeable. Full quality uses all 6. Left alone, the engine picks Faster on a PC whose graphics cards total 16 GB or less.'},
|
||||
{id:'parallel',label:'Concurrent requests',group:'Serving',type:'number',min:1,max:8,hint:'More slots need more memory and can slow each response.'},
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import net from 'node:net';
|
||||
|
||||
/** Why a listen failed, in words a person can act on (Windows keeps whole ranges for Hyper-V, WSL and Docker). */
|
||||
export function listenProblem(error, port, host = '127.0.0.1') {
|
||||
export function listenProblem(error, port, host = '127.0.0.1', platform = process.platform) {
|
||||
const code = error?.code;
|
||||
if (code === 'EADDRINUSE') return `Port ${port} is already in use. Strata may already be running; close it or the other program, or start Strata with another port (STRATA_PORT).`;
|
||||
if (code === 'EACCES' && platform !== 'win32') return `This system does not let Strata use port ${port}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801.`;
|
||||
if (code === 'EACCES') return `Windows or a firewall does not let Strata use port ${port}. Hyper-V, WSL and Docker keep ranges of ports for themselves (see "netsh int ipv4 show excludedportrange protocol=tcp"). Start Strata with another port (STRATA_PORT), for example 8801.`;
|
||||
if (code === 'EADDRNOTAVAIL') return `${host} is not an address of this computer, so Strata cannot listen on it. Check STRATA_BIND.`;
|
||||
return `Strata cannot listen on ${host}:${port} (${error?.message ?? code}).`;
|
||||
|
||||
@@ -24,3 +24,13 @@ export function makePrivate(path, { platform = process.platform } = {}) {
|
||||
// The owner too, so a file an elevated run made is not left owned by Administrators (needs the right to; best effort).
|
||||
try { execFileSync(system32('icacls.exe'), [path, '/setowner', `*${currentUserSid()}`], { stdio: 'ignore', timeout: 15000, windowsHide: true }); } catch { /* the explicit entry above already opens it to the user */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-only folder. POSIX: mode 0700. Windows: a protected ACL naming only the current user that the files made inside inherit,
|
||||
* so a file written later (a chat saved every second) is private without a tool run per write.
|
||||
*/
|
||||
export function makePrivateDir(path, { platform = process.platform } = {}) {
|
||||
if (platform !== 'win32') { chmodSync(path, 0o700); return; }
|
||||
execFileSync(system32('icacls.exe'), [path, '/grant:r', `*${currentUserSid()}:(OI)(CI)F`, '/inheritance:r'],
|
||||
{ stdio: 'ignore', timeout: 15000, windowsHide: true });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// End a program this host started together with everything that program started (#4529 #4570).
|
||||
//
|
||||
// Windows: `taskkill /T` walks the tree. Linux and macOS have no such call, and signalling the child alone leaves its own children running (they are
|
||||
// re-parented to init). A child started with `detached: ownGroup()` leads a process group of its own, so a signal to the negative pid reaches the
|
||||
// whole group and nothing else: the group is the ownership record, no other process (a same-named one included) is ever signalled by name.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
/** The spawn option that makes a child lead its own process group on Linux and macOS (Windows needs none: taskkill /T). */
|
||||
export const ownGroup = () => process.platform !== 'win32';
|
||||
|
||||
/** Whether the group the child leads still has a member. */
|
||||
export function groupAlive(child) {
|
||||
if (process.platform === 'win32' || !child?.pid) return false;
|
||||
try { process.kill(-child.pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the child's whole tree once. A child that does not lead its own group (it was not started with ownGroup()) is signalled alone, which is what
|
||||
* every caller did before.
|
||||
*/
|
||||
export function endTree(child, signal = 'SIGTERM') {
|
||||
if (!child) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
if (child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 10000 });
|
||||
return;
|
||||
}
|
||||
if (child.pid) {
|
||||
try { process.kill(-child.pid, signal); return; } catch { /* no such group: it did not lead one, or it is gone */ }
|
||||
}
|
||||
child.kill(signal);
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/** SIGTERM to the tree, then SIGKILL to whatever of its group is still there after `graceMs`. Returns the timer so a caller may clear it. */
|
||||
export function endTreeThenKill(child, graceMs = 2000) {
|
||||
endTree(child, 'SIGTERM');
|
||||
if (process.platform === 'win32' || !child?.pid) return null;
|
||||
const timer = setTimeout(() => { if (groupAlive(child)) endTree(child, 'SIGKILL'); }, graceMs);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { NATIVE_FIELDS } from './native-settings.mjs';
|
||||
/** Every top-level key a run config may carry: what setup writes, what serve/server.py reads, the app's own fields. */
|
||||
export const KNOWN_KEYS = new Set([
|
||||
...NATIVE_FIELDS.map(f => f.id),
|
||||
'exe', 'args', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
|
||||
'exe', 'args', 'args_written', 'user_args', 'gpus_by', 'cwd', 'tokenizer', 'model_name', 'model', 'arch', 'log', 'lib_dirs', 'port', 'host', 'api_key', 'cuda', 'backend', 'gpu', 'gpus_asked',
|
||||
'gpu_policy', 'hip_ordinal', 'env', 'env_to_user', 'sampling', 'mcp_servers', 'mcpServers', 'before_load', 'before_load_ignore_failure',
|
||||
'before_load_strict', 'before_load_timeout_s', 'cors_origins', 'trusted_origins', 'allowed_hosts', 'open_browser', 'prevent_sleep',
|
||||
'max_queue', 'queue_timeout', 'max_tokens', 'min_tokens', 'answer_room', 'threads', 'replicas', 'replica_cpus', 'replica_load_tokens',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { get as getStore, update } from './store.mjs';
|
||||
import { nowIso, round, uid } from './util.mjs';
|
||||
import { nowIso, round, sameSecret, uid } from './util.mjs';
|
||||
|
||||
const requests = [];
|
||||
const MAX_REQUESTS = 60;
|
||||
@@ -85,10 +85,13 @@ export function patch(body) {
|
||||
export function authorized(req) {
|
||||
const key = getStore().serving?.apiKey;
|
||||
if (!key) return true;
|
||||
const header = req.headers.authorization ?? '';
|
||||
const bearer = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
const alt = req.headers['x-api-key'];
|
||||
return bearer === key || alt === key;
|
||||
const header = req.headers?.authorization;
|
||||
const bearer = typeof header === 'string' && header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
const alt = req.headers?.['x-api-key'];
|
||||
// #4535: constant time over fixed-length digests, and both headers are always compared so the time does not show which one was sent.
|
||||
const viaBearer = sameSecret(bearer, key);
|
||||
const viaHeader = sameSecret(alt, key);
|
||||
return viaBearer || viaHeader;
|
||||
}
|
||||
|
||||
export function open({ client, endpoint, tokensIn = null, model, instanceId, gpuIds, cancel }) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* A quiet NDJSON answer stays open (#4878). A chat turn can say nothing for minutes (the model reads a long prompt, a tool
|
||||
* runs, an approval waits), and the desktop's web view gives up on a request that has been silent for 60 seconds ("Load failed")
|
||||
* while the turn goes on in the background. A beat frame, the same `{"type":"beat"}` the Time-skip stream uses and the chat
|
||||
* consumer ignores, goes out every 15 seconds. It changes nothing else: a client that goes away still cancels (or detaches
|
||||
* from) its turn through the response's own close, exactly as before, and the timer ends on finish, on close and on stop().
|
||||
*/
|
||||
export const BEAT_MS = 15_000;
|
||||
const BEAT_LINE = `${JSON.stringify({ type: 'beat' })}\n`;
|
||||
|
||||
/** The interval (a tester may shorten it: STRATA_STREAM_BEAT_MS); anything that is not a sensible number is the default. */
|
||||
export function beatInterval(env = process.env) {
|
||||
const n = Number(env.STRATA_STREAM_BEAT_MS);
|
||||
return Number.isFinite(n) && n >= 20 && n <= 60_000 ? n : BEAT_MS;
|
||||
}
|
||||
|
||||
/** Start beating on `res`. Returns stop(), which is safe to call more than once. */
|
||||
export function keepStreamAlive(res, { ms = beatInterval() } = {}) {
|
||||
let timer = setInterval(() => {
|
||||
if (res.writableEnded || res.destroyed) { stop(); return; }
|
||||
try { res.write(BEAT_LINE); } catch { stop(); }
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
function stop() {
|
||||
if (timer === null) return;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
res.off?.('finish', stop);
|
||||
res.off?.('close', stop);
|
||||
res.off?.('error', stop);
|
||||
}
|
||||
res.once('finish', stop);
|
||||
res.once('close', stop);
|
||||
res.once('error', stop);
|
||||
return stop;
|
||||
}
|
||||
@@ -10,8 +10,11 @@
|
||||
* macOS: none.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createPowerShellLoop, endWithHost } from './windows-poll.mjs';
|
||||
import { stateRoot } from './platform-paths.mjs';
|
||||
|
||||
const CPU_SENSORS = new Set(['coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'cpu-thermal']);
|
||||
const DISK_SENSORS = new Set(['nvme', 'drivetemp']);
|
||||
@@ -82,28 +85,66 @@ while($true){
|
||||
if($c.Count -gt 0){ $max=($c | Measure-Object Temperature -Maximum).Maximum }
|
||||
else {
|
||||
if(-not $compiled){ Add-Type -TypeDefinition $cs; $compiled=$true }
|
||||
foreach($n in 0..15){ $v=[StrataDiskTemp]::Read($n); if($v -gt $max){ $max=$v } }
|
||||
# only drives Windows names as NVMe, one at a time: a RAID, SATA, USB or unknown drive is never asked (a driver stack that stalls on this query
|
||||
# froze whole PCs, #4661)
|
||||
foreach($d in @(Get-PhysicalDisk | Where-Object { $_.BusType -eq 'NVMe' })){ $v=[StrataDiskTemp]::Read([int]$d.DeviceId); if($v -gt $max){ $max=$v } }
|
||||
}
|
||||
if($max -gt 0){ [Console]::Out.WriteLine('ssd '+$max) } else { [Console]::Out.WriteLine('ssd -') }
|
||||
[Console]::Out.Flush()
|
||||
Start-Sleep -Seconds 30
|
||||
}`;
|
||||
|
||||
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30 } = {}) {
|
||||
let latest = null;
|
||||
/** How long the first pass of the reader may take before it is given up for good (a healthy pass takes a second or two). */
|
||||
export const FIRST_PASS_MS = 45_000;
|
||||
|
||||
/** The marker file the reader leaves before it asks the drives and removes after its first answer. */
|
||||
export const sentinelPath = (env = process.env, platform = process.platform) => join(stateRoot(env, platform), 'drive-temperature.probe');
|
||||
|
||||
/**
|
||||
* `sentinel`: { exists(), write(), remove() }. The reader leaves the marker before it first asks a drive and removes it when the first answer has come. A host
|
||||
* that is still there with a marker at its next start died, or the PC did, in the middle of the question: the reader stays off from then on (the line is left
|
||||
* out of the Monitor; deleting the file brings it back). It never asks again after a first pass that took longer than FIRST_PASS_MS either.
|
||||
*/
|
||||
export function createWindowsSsdReader({ spawnFn = spawn, maxStarts = 2, now = Date.now, intervalSec = 30, sentinel = null, firstPassMs = FIRST_PASS_MS } = {}) {
|
||||
let latest = null, off = false, startedAt = null;
|
||||
const loop = createPowerShellLoop({
|
||||
script: WINDOWS_SCRIPT, spawnFn, maxStarts,
|
||||
onLine: line => {
|
||||
const parsed = parseSsdLine(line);
|
||||
if (parsed === undefined) return false;
|
||||
latest = { value: parsed, at: now() };
|
||||
if (sentinel) { try { sentinel.remove(); } catch { /* best effort */ } }
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
read() { loop.start(); return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null; },
|
||||
read() {
|
||||
if (off) return null;
|
||||
if (startedAt === null) {
|
||||
if (sentinel) {
|
||||
try { if (sentinel.exists()) { off = true; return null; } sentinel.write(); } catch { off = true; return null; } // cannot leave the marker: do not ask
|
||||
}
|
||||
startedAt = now();
|
||||
} else if (latest === null && now() - startedAt > firstPassMs) {
|
||||
off = true; // the first pass is stuck: ended, and the marker stays
|
||||
loop.stop();
|
||||
return null;
|
||||
}
|
||||
loop.start();
|
||||
return latest && now() - latest.at < 4 * intervalSec * 1000 ? latest.value : null;
|
||||
},
|
||||
stop() { loop.stop(); },
|
||||
get starts() { return loop.starts; },
|
||||
get off() { return off; },
|
||||
};
|
||||
}
|
||||
|
||||
/** The real marker file under Strata's state folder. */
|
||||
export function fileSentinel(path = sentinelPath()) {
|
||||
return {
|
||||
exists: () => existsSync(path),
|
||||
write: () => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, String(Date.now())); },
|
||||
remove: () => rmSync(path, { force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,7 +158,7 @@ export async function readTemperatures({ platform = process.platform, now = Date
|
||||
return linuxCache.value;
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
windowsReader ??= endWithHost(createWindowsSsdReader());
|
||||
windowsReader ??= endWithHost(createWindowsSsdReader({ sentinel: fileSentinel() }));
|
||||
return { cpuTempC: null, ssdTempC: windowsReader.read() };
|
||||
}
|
||||
return { cpuTempC: null, ssdTempC: null };
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* A run is a job, not a request: it outlives the HTTP call that started it. The endpoint returns the
|
||||
* run id immediately and the client follows it with `/api/timeskip/status`.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { endTreeThenKill, ownGroup } from './process-tree.mjs';
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { timeSkipDir } from './platform-paths.mjs';
|
||||
@@ -146,10 +147,7 @@ export function timeskipRevisionMessage(request) {
|
||||
|
||||
/** End a process and everything it started (the job, the Python it runs, the revision command and its Node). */
|
||||
function killTree(child) {
|
||||
try {
|
||||
if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
||||
else child.kill('SIGTERM');
|
||||
} catch { /* already gone */ }
|
||||
endTreeThenKill(child); // Windows: taskkill /T. Linux and macOS: the job leads its own process group (startDetached), so the group is ended (#4529 #4570)
|
||||
}
|
||||
|
||||
/** How big a folder is, stopping early once it is clearly too big to copy for a trial. */
|
||||
@@ -452,7 +450,7 @@ export function createTimeSkip({ repoRoot, python, pythonArgs = [], timeoutMs =
|
||||
'--attempts', String(Math.max(0, Math.min(10, Number(attempts) || 0))), '--output', 'json'];
|
||||
if (reviseCommand) args.push('--revise-command', reviseCommand);
|
||||
const child = spawn(python, [...pythonArgs, cli, ...args], {
|
||||
cwd: repoRoot, windowsHide: true, env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd: repoRoot, windowsHide: true, detached: ownGroup(), env: { ...env, PYTHONUTF8: '1', PYTHONIOENCODING: 'utf-8', ...extraEnv, STRATA_TIMESKIP_DIR: root }, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '', err = '', closed = null;
|
||||
child.stdout.on('data', b => { out += b.toString(); });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Shared helpers for the Strata host. */
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { whichCommand } from './platform-paths.mjs';
|
||||
@@ -24,6 +24,46 @@ export function error(res, status, message, extra = {}) {
|
||||
json(res, status, { error: message, ...extra });
|
||||
}
|
||||
|
||||
/**
|
||||
* Two secrets compared in constant time (#4535). Both sides are hashed first, so the comparison always runs over two
|
||||
* 32-byte digests: neither the content nor the length of the given value can end it early. Anything that is not a
|
||||
* non-empty string never matches (an empty secret is "not set", not "equal to an empty header").
|
||||
*/
|
||||
export function sameSecret(given, wanted) {
|
||||
const ok = typeof given === 'string' && typeof wanted === 'string' && given.length > 0 && wanted.length > 0;
|
||||
const digest = value => createHash('sha256').update(typeof value === 'string' ? value : '', 'utf8').digest();
|
||||
return timingSafeEqual(digest(given), digest(wanted)) && ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request's URL, or null when the request cannot be read as one (#4397). The Host header is the caller's: an empty
|
||||
* value, one with a space, a path, credentials or an unclosed bracket used to make `new URL` throw before the routing
|
||||
* try/catch and end the whole process. A missing Host (HTTP/1.0) is read as localhost; a good one keeps its port and IPv6 form.
|
||||
*/
|
||||
export function requestUrl(req) {
|
||||
const host = req.headers?.host;
|
||||
try {
|
||||
let authority = 'localhost';
|
||||
if (host !== undefined) {
|
||||
if (typeof host !== 'string' || !host.trim() || /[\s/?#@\\]/.test(host)) return null;
|
||||
const base = new URL(`http://${host}`);
|
||||
if (!base.host || base.username || base.password || base.search || base.hash || base.pathname !== '/') return null;
|
||||
authority = host;
|
||||
}
|
||||
return new URL(req.url ?? '/', `http://${authority}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer to a request that could not be read as a URL: bounded, and the connection closes. */
|
||||
export function badRequest(res) {
|
||||
if (res.headersSent) return res.end();
|
||||
const payload = '{"error":"bad_host"}';
|
||||
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload), 'cache-control': 'no-store', connection: 'close' });
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
export async function readBody(req, limit = 4 * 1024 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TaskTree } from './TaskTree';
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FloatingMenu } from '../components/FloatingMenu';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
import { QuitStrataDialog } from '../components/QuitStrata';
|
||||
import { useAppMode } from '../lib/app-mode';
|
||||
import { href, useLocation, type Route } from '../lib/router';
|
||||
@@ -162,8 +163,9 @@ function RecentChatLink({ session, active, slot }: {
|
||||
const rect = anchor.current?.getBoundingClientRect();
|
||||
if (!rect)
|
||||
return;
|
||||
const height = tooltip.current?.getBoundingClientRect().height ?? 80;
|
||||
setPosition({ left: rect.right + 12, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) });
|
||||
const scale = measureScale();
|
||||
const height = tooltip.current?.getBoundingClientRect().height ?? 80 * scale;
|
||||
setPosition({ left: (rect.right + 12) / scale, top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12)) / scale });
|
||||
};
|
||||
place();
|
||||
window.addEventListener('resize', place);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { PreviewControls } from './PreviewControls';
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
|
||||
import { CopyBtn, CopyMenuItem } from './CopyBtn';
|
||||
import { Markdown } from './Markdown';
|
||||
import { PreviewMarkdown } from './PreviewMarkdown';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { FloatingMenu } from './FloatingMenu';
|
||||
import { TablePreview } from './TablePreview';
|
||||
import { artifactFilename, artifactIcon, safePreviewUrl, type PreviewArtifact } from '../lib/artifacts';
|
||||
import { scrollBehavior } from '../lib/responsive';
|
||||
import { previewVersion } from '../lib/preview-merge';
|
||||
const PdfPreview = lazy(() => import('./PdfPreview'));
|
||||
export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab, inline }: {
|
||||
items: PreviewArtifact[];
|
||||
@@ -20,17 +21,22 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
inline: boolean;
|
||||
}) {
|
||||
const current = items.find(a => a.id === selected);
|
||||
// What the text loading depends on: the file's identity and its content version, not the object (the chat hands over new objects often).
|
||||
const version = useMemo(() => current ? previewVersion(current) : '', [current]);
|
||||
const [text, setText] = useState(''), [error, setError] = useState(''), [mode, setMode] = useState<'preview' | 'source'>('preview'), [revision, setRevision] = useState(0), [loading, setLoading] = useState(false), [imageSize, setImageSize] = useState('fit');
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLElement | null>(null);
|
||||
const [fileMenu, setFileMenu] = useState(false);
|
||||
const fileMenuRef = useRef<HTMLButtonElement>(null);
|
||||
useLayoutEffect(() => { setToolbarHost(inline && current ? document.getElementById('preview-file-actions') : null); }, [inline, current?.id]);
|
||||
// A different file starts in the reading view; the same file with new content keeps the person's view.
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setMode('preview');
|
||||
setLoading(false);
|
||||
setImageSize('fit');
|
||||
setFileMenu(false);
|
||||
}, [current?.id]);
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setLoading(false);
|
||||
if (!current || !['markdown', 'code', 'table'].includes(current.kind))
|
||||
return;
|
||||
if (current.content !== undefined) {
|
||||
@@ -61,7 +67,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
setLoading(false);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [current?.id, revision]);
|
||||
}, [current?.id, version, revision]);
|
||||
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
|
||||
const hadCurrent = useRef(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
@@ -84,7 +90,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
|
||||
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
|
||||
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
|
||||
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
|
||||
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><Markdown text={text}/></article>)}
|
||||
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}-${version}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}-${version}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}-${version}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><PreviewMarkdown text={text}/></article>)}
|
||||
</div>
|
||||
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
|
||||
</aside>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { measureScale, placeAnchored } from '../lib/overlay-place';
|
||||
export function FloatingMenu({ anchor, title, onClose, children, width = 232 }: {
|
||||
anchor: HTMLElement | null;
|
||||
title: string;
|
||||
@@ -9,22 +10,28 @@ export function FloatingMenu({ anchor, title, onClose, children, width = 232 }:
|
||||
width?: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [placed, setPlaced] = useState({ left: 12, top: 12 });
|
||||
const [placed, setPlaced] = useState<{ left: number; top: number; maxWidth?: number; maxHeight?: number }>({ left: 12, top: 12 });
|
||||
useLayoutEffect(() => {
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 200;
|
||||
const left = Math.max(12, Math.min(rect?.left ?? 12, window.innerWidth - Math.min(width, window.innerWidth - 24) - 12));
|
||||
const below = (rect?.bottom ?? 12) + 8; // a little air between the button and its panel
|
||||
const top = below + height + 12 <= window.innerHeight ? below : Math.max(12, (rect?.top ?? window.innerHeight) - height - 6);
|
||||
setPlaced({ left, top });
|
||||
// Placed in visual pixels and written in the page's own pixels (lib/overlay-place.ts): the page may be scaled by "Interface size" (#4653).
|
||||
const place = () => {
|
||||
const el = ref.current;
|
||||
const scale = measureScale();
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const natural = el ? Math.max(el.getBoundingClientRect().height, el.scrollHeight * scale) : 200 * scale;
|
||||
const home = { left: 12, top: 12, right: 12, bottom: 12 };
|
||||
const p = placeAnchored({ anchor: rect ?? home, size: { width: width * scale, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale });
|
||||
setPlaced({ left: p.left, top: p.top, maxWidth: p.maxWidth, maxHeight: p.maxHeight });
|
||||
};
|
||||
place();
|
||||
ref.current?.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)')?.focus();
|
||||
const outside = (e: PointerEvent) => { if (!ref.current?.contains(e.target as Node) && !anchor?.contains(e.target as Node))
|
||||
onClose(); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => { document.removeEventListener('pointerdown', outside); if (anchor?.isConnected)
|
||||
window.addEventListener('resize', place);
|
||||
return () => { document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); if (anchor?.isConnected)
|
||||
anchor.focus({ preventScroll: true }); };
|
||||
}, [anchor, width]);
|
||||
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100dvw - 24px)', maxHeight: `calc(100dvh - ${placed.top + 12}px)`, overflowY: 'auto' }} onKeyDown={e => {
|
||||
return createPortal(<div className="menu" role="menu" aria-label={tr(title)} ref={ref} style={{ ...placed, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: placed.maxWidth ?? 'calc(100dvw - 24px)', maxHeight: placed.maxHeight ?? 'calc(100dvh - 24px)', overflowY: 'auto' }} onKeyDown={e => {
|
||||
const nodes = Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)') ?? []);
|
||||
const at = nodes.indexOf(document.activeElement as HTMLElement);
|
||||
if (['Escape', 'Tab'].includes(e.key)) {
|
||||
|
||||
@@ -70,7 +70,9 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
|
||||
imageSession?: string | null;
|
||||
}) {
|
||||
const nodes: ReactNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
// A file written on Windows has \r\n: the \r stays at the end of every line, where `.` does not match it, so a list line was never taken and the
|
||||
// list loop below never moved on (an endless loop that filled the page's memory, found in the sidebar preview). Line ends are read as one.
|
||||
const lines = text.split(/\r\n|\r|\n/);
|
||||
for (let i = 0; i < lines.length;) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
@@ -111,6 +113,11 @@ export function Markdown({ text, onOpenLink, previewId, onPreview, onClosePrevie
|
||||
items.push(<li key={i}>{tr(inline(lines[i].replace(re, '$1'), onOpenLink))}</li>);
|
||||
i++;
|
||||
}
|
||||
if (i === key) { // never stand still: a line the list start accepted but the item pattern did not is shown as text and passed
|
||||
nodes.push(<p key={key}>{line}</p>);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
nodes.push(ordered ? <ol key={key}>{tr(items)}</ol> : <ul key={key}>{tr(items)}</ul>);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { NativeDeleteDialog } from './NativeDelete';
|
||||
import { plainFailureText } from '../lib/plain-errors';
|
||||
import { api } from '../lib/client';
|
||||
import { go, setSettingsSection, selectRemoteModel, pushToast } from '../lib/store';
|
||||
import { nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { installedListPending, nativeAction, useNative, type NativeJob } from '../lib/native';
|
||||
import { tuneEffective } from '../lib/native-tune';
|
||||
import { tunedFields } from '../lib/tune-values';
|
||||
import { shownVision, steadyNoReader } from '../lib/pictures-choice';
|
||||
@@ -69,7 +69,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [expert]);
|
||||
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
|
||||
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
|
||||
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id), listPending = installedListPending(native);
|
||||
// The sizes this model offers for the conversation context; it opens at 128K (or its largest size when that is smaller).
|
||||
const contextList = selectedSize?.multi_file ? deepseekContexts(installed?.contextMax) : native?.catalog?.contexts ?? [8192, 32768, 65536, 131072, 262144];
|
||||
// The model that is running is shown as running, with Unload: not offered for loading again.
|
||||
@@ -135,8 +135,10 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
useEffect(() => { if (cardsGpus.length <= 1 && split !== 'auto') setSplit('auto'); }, [cardsGpus.length]);
|
||||
// Drafting (which drafter guesses ahead): a choice exists only for Qwen3.8-Flash-Next (the qwen and gyro families) on a computer whose engine can run DFlash2; every other model has no dropdown.
|
||||
const draftingChoices = selectedSize ? draftingOptions(selectedSize.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2) : null;
|
||||
// Control's plan says the all-cards split engine takes an Auto start on this computer (it runs the MTP draft layer); it needs two or more cards, so one card chosen in the roles is not it.
|
||||
const splitCards = native?.capabilities?.dflash2Split === true && cardsGpus.length !== 1;
|
||||
// The drafter this run loads (one per run): the fit and the plan use its graphics memory (DFlash2 0.89 GB, MTP 1.08 GB).
|
||||
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, native?.capabilities?.dflash2Split) : 'mtp';
|
||||
const nowDrafter = draftingChoices ? resolveDrafter(drafting, selectedSize?.sourceFamily ?? selectedFamily?.family, native?.capabilities?.dflash2, splitCards) : 'mtp';
|
||||
// DFlash2 with the converted pack (the selector as Q4_0 rows) takes 175 MiB less graphics memory: the fit says the true number (Control's `selq4`, strata_control/drafting.py vram_gb).
|
||||
const fitDrafter = nowDrafter === 'dflash2' && native?.capabilities?.dflash2Selq4 === true ? 'dflash2_selq4' : nowDrafter;
|
||||
const draftingWords: Record<string, string> = { auto: 'Auto (recommended)', mtp: 'MTP', dflash2: 'DFlash2', off: 'Off' };
|
||||
@@ -244,7 +246,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
<div className="native-model-layout native-model-layout--fit">
|
||||
<div className="stack">
|
||||
{tr(native?.instances.length && expert ? <section className="st-card native-running"><h2 className="h4">{tr(native.instances.length === 1 ? 'Loaded model' : 'Loaded models')}</h2>{tr(native.instances.map(i => <div key={i.id}><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.length ? i.gpus.map((g, n) => `GPU ${g + 1}${i.gpus.length > 1 && n === i.gpus.length - 1 ? ' (Main)' : ''}`).join(' + ') : 'Automatic GPU')} · {tr(i.status === 'ready' ? i.phase : i.status)}{tr(native.defaultId === i.id ? ' · Chat default' : '')}</small>{tr(i.error ? <small className="inline-error">{tr(plainFailureText(i.error).message)}</small> : null)}</span>{tr(expert && multiple && native.defaultId !== i.id ? <Btn size="sm" variant="ghost" onClick={() => void loadedAction(`instances/${i.id}/default`)}>{tr("Use in chat")}</Btn> : null)}<Btn size="sm" disabled={pending || !!active} onClick={() => void loadedAction(`instances/${i.id}/unload`)}>{tr("Unload")}</Btn></div>))}{tr(loadedError ? <ErrorNotice message={loadedError} next={tr("Stop or answer the chat that is still using this model, then try again.")}/> : null)}</section> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily && !listPending ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
|
||||
{tr(selectedSize ? <>
|
||||
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
|
||||
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
|
||||
@@ -266,12 +268,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
|
||||
<EngineUpdateCard native={native} busy={!!active}/>
|
||||
<EngineUpdateDialog open={false} onClose={() => {}}/>
|
||||
{tr(active ? <NativeProgress job={active} label={jobLabel(active)}/> : null)}
|
||||
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? "Choosing MTP downloads its drafter: about {0} GB, once." : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
|
||||
{tr(active && drafterGb ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(nowDrafter === 'mtp' ? (splitCards && drafting === 'auto' ? "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once." : "Choosing MTP downloads its drafter: about {0} GB, once.") : "Choosing DFlash2 downloads its drafter: about {0} GB, once.", [drafterGb])}</p> : null)}
|
||||
{tr(job?.state === 'error' ? <ErrorNotice message={job.error ?? job.summary} action={tr("Retry")} onRetry={() => void load()}/> : null)}
|
||||
{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}
|
||||
{tr(error ? <ErrorNotice message={error}/> : null)}
|
||||
{selectedSize && installed?.ready ? <NativeTuneBadge model={selectedSize.id} resourceProfile={resourceProfile} vision={selectedSize.images ? vision : 'no'}/> : null}
|
||||
<div className="native-model-actions">{tr(!loadedSelected && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={!installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
<div className="native-model-actions">{tr(!loadedSelected && !listPending && !installed?.ready && selectedSize?.download_gb && !active ? <span className="native-download-size small muted">{tr("{0} download", [sizeGb(selectedSize.download_gb)])}</span> : null)}{tr(loadedSelected ? <><Btn disabled={pending || !!active} onClick={() => void perform(() => nativeAction(`instances/${loadedSelected.id}/unload`))}>{tr("Unload")}</Btn>{tr(onReady ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <span className="native-ready">{tr("Ready")}</span>)}</> : onReady && native?.instances.some(i => i.status === 'ready') ? <Btn variant="primary" onClick={onReady}>{tr("Start chatting")}</Btn> : <><Btn icon="gauge" disabled={listPending || !installed?.ready || !native?.available || !!active || !selectedSize || selectedSize.needsNewerEngine} onClick={() => setTuneOpen(true)}>{tr("Tune model")}</Btn><Btn variant="primary" icon="models" disabled={pending || listPending || !native?.available || !!active || !selectedSize || !!selectedSize.needsNewerEngine} onClick={() => void load()}>{tr(pending || listPending ? 'Checking…' : installed?.ready ? 'Load model' : 'Download & load')}</Btn></>)}</div>
|
||||
</section>
|
||||
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
|
||||
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={KV_CHOICES.map(c => ({ value: c.value, label: c.label, title: c.hint }))} onChange={setKv}/>{tr(KV_CHOICES.find(c => c.value === kv)?.hint ? <small className="muted native-kv-hint">{tr(KV_CHOICES.find(c => c.value === kv)!.hint)}</small> : null)}</label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tr } from '../lib/i18n';
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconBtn } from './ui';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
/** Anchored, non-modal surface. Focus and dismissal stay with its own trigger. */
|
||||
export function Popover({ anchor, title, detail, onClose, children, above = false, width = 360 }: {
|
||||
anchor: HTMLElement | null;
|
||||
@@ -15,8 +16,10 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
const [maxWidth, setMaxWidth] = useState<number | string>('calc(100vw - 24px)');
|
||||
useLayoutEffect(() => {
|
||||
const place = () => { const r = anchor?.getBoundingClientRect(); const h = ref.current?.offsetHeight ?? 200; const w = Math.min(width, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)), top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) }); };
|
||||
// The numbers from the anchor are visual pixels and the numbers written on the panel are the page's own: they differ when the page is scaled (#4653).
|
||||
const place = () => { const scale = measureScale(); const r = anchor?.getBoundingClientRect(); const h = (ref.current?.offsetHeight ?? 200) * scale; const w = Math.min(width * scale, window.innerWidth - 24); const y = above ? (r?.top ?? h) - h - 10 : (r?.bottom ?? 12) + 8; setPosition({ left: Math.max(12, Math.min(r?.left ?? 12, window.innerWidth - w - 12)) / scale, top: Math.max(12, Math.min(y, window.innerHeight - h - 12)) / scale }); setMaxWidth((window.innerWidth - 24) / scale); };
|
||||
place();
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
const outside = (e: PointerEvent) => {
|
||||
@@ -31,7 +34,7 @@ export function Popover({ anchor, title, detail, onClose, children, above = fals
|
||||
return () => { resize.disconnect(); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); };
|
||||
}, [anchor, width, above]);
|
||||
function close() { onClose(); anchor?.focus({ preventScroll: true }); }
|
||||
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth: 'calc(100vw - 24px)' }} onKeyDown={e => {
|
||||
return createPortal(<div ref={ref} role="dialog" aria-label={tr(title)} tabIndex={-1} className="telepop anchored-popover" style={{ ...position, zIndex: anchor?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)', width, maxWidth }} onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The Markdown preview of the sidebar, bounded (#5180). Same elements as <Markdown>, but:
|
||||
* - memoised by the text, so a chat that renders for every group of streamed tokens does not rebuild an unchanged file;
|
||||
* - drawn in slices (lib/preview-render.ts) that are memoised one by one, so a file changed in one place re-renders that slice;
|
||||
* - only a few slices at a time, the rest added by one waiting step that a newer text or leaving the view cancels;
|
||||
* - a block too large to parse, and a file above the limit, shown as plain text.
|
||||
*/
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SyntaxCode } from './SyntaxCode';
|
||||
import { previewSlices, previewTier, slicesAfter, slicesAtStart, textRevision } from '../lib/preview-render';
|
||||
|
||||
const Slice = memo(function Slice({ text, plain }: { text: string; plain: boolean }) {
|
||||
return plain ? <SyntaxCode text={text}/> : <Markdown text={text}/>;
|
||||
});
|
||||
|
||||
export const PreviewMarkdown = memo(function PreviewMarkdown({ text }: { text: string }) {
|
||||
if (previewTier(text) === 'plain')
|
||||
return <SyntaxCode text={text}/>;
|
||||
return <Sliced text={text}/>;
|
||||
});
|
||||
|
||||
function Sliced({ text }: { text: string }) {
|
||||
const slices = useMemo(() => previewSlices(text), [text]);
|
||||
const revision = useMemo(() => textRevision(text), [text]);
|
||||
const [shown, setShown] = useState(() => slicesAtStart(0, slices.length));
|
||||
// one step waiting at most; a new revision or leaving the view clears it, so work for an old text never runs
|
||||
useEffect(() => {
|
||||
if (shown >= slices.length)
|
||||
return;
|
||||
const timer = setTimeout(() => setShown(n => slicesAfter(n, slices.length)), 24);
|
||||
return () => clearTimeout(timer);
|
||||
}, [revision, shown, slices.length]);
|
||||
const count = Math.min(Math.max(shown, slicesAtStart(0, slices.length)), slices.length);
|
||||
return <>{slices.slice(0, count).map(s => <Slice key={s.key} text={s.text} plain={s.plain}/>)}</>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
|
||||
/** What Strata collects and where things go: one compact paragraph (the six sentences read together, translated as one in every language). */
|
||||
const NOTES = 'Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.';
|
||||
const NOTES = "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.";
|
||||
|
||||
export function PrivacyNotes() {
|
||||
return <div className="privacy-notes" aria-label={tr("Collection and logs")}>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SettingsUpdates() {
|
||||
</>;
|
||||
}
|
||||
|
||||
/** "Strata 0.2.10 · Engine 0.1.56": the app's own version first, the engine's beside it (About). */
|
||||
/** "Strata 0.2.11 · Engine 0.1.57": the app's own version first, the engine's beside it (About). */
|
||||
export function AboutVersions() {
|
||||
const native = useNative();
|
||||
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { tr } from '../lib/i18n';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { measureScale } from '../lib/overlay-place';
|
||||
export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLeave }: {
|
||||
id: string;
|
||||
anchor: HTMLElement;
|
||||
@@ -12,11 +13,12 @@ export function VersionMemoryTooltip({ id, anchor, text, onClose, onEnter, onLea
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: 12, top: 12 });
|
||||
useLayoutEffect(() => {
|
||||
const rect = anchor.getBoundingClientRect(), width = Math.min(260, window.innerWidth - 24);
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 80;
|
||||
const scale = measureScale();
|
||||
const rect = anchor.getBoundingClientRect(), width = Math.min(260 * scale, window.innerWidth - 24);
|
||||
const height = ref.current?.getBoundingClientRect().height ?? 80 * scale;
|
||||
const left = Math.max(12, Math.min(rect.right - width, window.innerWidth - width - 12));
|
||||
const top = rect.bottom + height + 20 <= window.innerHeight ? rect.bottom + 8 : Math.max(12, rect.top - height - 8);
|
||||
setPosition({ left, top });
|
||||
setPosition({ left: left / scale, top: top / scale });
|
||||
}, [anchor, text]);
|
||||
useEffect(() => {
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') {
|
||||
|
||||
@@ -7,12 +7,13 @@ import { tr } from '../lib/i18n';
|
||||
* the same system. Anything added here is a documented extension that only uses
|
||||
* kit tokens — see src/styles/app.css.
|
||||
*/
|
||||
import { createContext, useContext, useCallback, useEffect, useId, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type AriaAttributes, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cx, userError } from '../lib/format';
|
||||
import { plainFailure } from '../lib/plain-errors';
|
||||
import { dismissActionError, dismissToast, holdToast, releaseToast, useStore } from '../lib/store';
|
||||
import { isTopSurface, useSurfaceFocus } from '../lib/focus';
|
||||
import { measureScale, placeAnchored } from '../lib/overlay-place';
|
||||
/* -------------------------------------------------------------------- icon */
|
||||
export type IconName = 'pause' | 'play' | 'activity' | 'plus' | 'shield' | 'computer'
|
||||
// Authored for the shell: see docs/design-system-notes.md.
|
||||
@@ -248,7 +249,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
up: boolean;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [typed, setTyped] = useState('');
|
||||
@@ -260,34 +261,47 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
if (refocus)
|
||||
triggerRef.current?.focus();
|
||||
}, []);
|
||||
const openMenu = useCallback(() => {
|
||||
/* Where the list goes, in screen pixels, written in the page's own pixels (lib/overlay-place.ts; the page may be scaled by "Interface size", #4653):
|
||||
below its button when the whole list fits there, else above, else the side with more room; as tall as the list needs, and no taller than
|
||||
the room (it then scrolls, using all of it). It used to be capped at 320 px whatever the window, and opened upward by a transform that
|
||||
sent it off the top of a short window. `measured` is the rendered popup, when there is one. */
|
||||
const placeList = useCallback((measured: HTMLElement | null) => {
|
||||
const el = triggerRef.current;
|
||||
if (!el)
|
||||
return;
|
||||
return null;
|
||||
const scale = measureScale();
|
||||
const r = el.getBoundingClientRect();
|
||||
const height = Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
|
||||
const below = window.innerHeight - r.bottom;
|
||||
// Flip above the trigger when there is more room there than below.
|
||||
const up = below < height + 12 && r.top > below;
|
||||
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
|
||||
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
|
||||
const width = Math.min((menuWidth ?? Math.max(r.width / scale, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180))) * scale, window.innerWidth - 24);
|
||||
const natural = measured
|
||||
? measured.scrollHeight * scale + (measured.getBoundingClientRect().height - measured.clientHeight * scale)
|
||||
: (options.length * (menuSize === 'comfortable' ? 52 : 38) + 12) * scale;
|
||||
const p = placeAnchored({ anchor: r, size: { width, height: natural }, viewport: { width: window.innerWidth, height: window.innerHeight }, scale, gapBelow: 6, gapAbove: 6 });
|
||||
return { left: p.left, top: p.top, width: width / scale, maxHeight: p.maxHeight };
|
||||
}, [options.length, menuSize, menuMinWidth, menuWidth]);
|
||||
const openMenu = useCallback(() => {
|
||||
const next = placeList(null);
|
||||
if (!next)
|
||||
return;
|
||||
setPlaced(next);
|
||||
setCursor(Math.max(0, options.findIndex((o) => o.value === value)));
|
||||
setOpen(true);
|
||||
}, [options, value, menuSize, menuMinWidth, menuWidth]);
|
||||
}, [options, value, placeList]);
|
||||
// Once the list is on the screen its real height is known: place it again with that.
|
||||
useLayoutEffect(() => {
|
||||
if (!open)
|
||||
return;
|
||||
const next = placeList(popupRef.current);
|
||||
if (next)
|
||||
setPlaced(next);
|
||||
}, [open, placeList]);
|
||||
// The popup is fixed-positioned, so it has to follow its trigger.
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return undefined;
|
||||
const reposition = () => {
|
||||
const el = triggerRef.current;
|
||||
if (!el)
|
||||
return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const height = popupRef.current?.offsetHeight ?? Math.min(320, options.length * (menuSize === 'comfortable' ? 52 : 38) + 12);
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const up = below < height + 12 && r.top > below;
|
||||
const width = Math.min(menuWidth ?? Math.max(r.width, menuMinWidth ?? (menuSize === 'comfortable' ? 340 : 180)), window.innerWidth - 24);
|
||||
setPlaced({ left: Math.max(12, Math.min(r.left, window.innerWidth - width - 12)), top: up ? r.top - 6 : r.bottom + 6, width, up });
|
||||
const next = placeList(popupRef.current);
|
||||
if (next)
|
||||
setPlaced(next);
|
||||
};
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (!triggerRef.current?.contains(e.target as Node) && !popupRef.current?.contains(e.target as Node))
|
||||
@@ -301,7 +315,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
window.removeEventListener('resize', reposition);
|
||||
document.removeEventListener('pointerdown', onDocDown, true);
|
||||
};
|
||||
}, [open, options.length, close, menuSize, menuMinWidth, menuWidth]);
|
||||
}, [open, close, placeList]);
|
||||
useEffect(() => {
|
||||
if (open)
|
||||
document.getElementById(`${listId}-${cursor}`)?.scrollIntoView({ block: 'nearest' });
|
||||
@@ -368,7 +382,7 @@ export function Select<T extends string>({ value, valueSuffix, options, onChange
|
||||
<Icon name="chevron" size={16}/>
|
||||
</button>
|
||||
{tr(open && placed
|
||||
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} data-up={placed.up || undefined} style={{ left: placed.left, top: placed.top, width: placed.width, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
|
||||
? createPortal(<div ref={popupRef} id={listId} className={cx('select__pop', menuSize === 'comfortable' && 'select__pop--comfortable', singleLine && 'select__pop--single-line', menuClassName)} role="listbox" aria-label={tr(ariaLabel)} style={{ left: placed.left, top: placed.top, width: placed.width, maxHeight: placed.maxHeight, zIndex: triggerRef.current?.closest('[role="dialog"]') ? 'var(--layer-dialog-popover)' : 'var(--layer-popover)' }}>
|
||||
{tr(options.map((o, i) => (<div key={o.value} id={`${listId}-${i}`} role="option" aria-selected={o.value === value} aria-disabled={o.disabled || undefined} tabIndex={-1} className="select__opt" data-active={i === cursor || undefined} onMouseEnter={() => setCursor(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => {
|
||||
if (o.disabled)
|
||||
return;
|
||||
@@ -469,6 +483,7 @@ export function Toasts() {
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="st-toast__title">{tr(t.title)}</div>
|
||||
{tr(t.body ? <div className="st-toast__body">{tr(t.body)}</div> : null)}
|
||||
{tr(t.action ? <button type="button" className="st-btn st-btn--sm" style={{ marginTop: 8 }} onClick={t.action.run}>{tr(t.action.label)}</button> : null)}
|
||||
</div>
|
||||
<button type="button" className="st-btn st-btn--icon st-btn--sm" onClick={() => dismissToast(t.id)} aria-label={tr("Dismiss")}>
|
||||
<Icon name="chevron" size={15} style={{ transform: 'rotate(90deg)' }}/>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean; }
|
||||
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean;
|
||||
/** The host's version of the file this was read from (modified time and size), for a preview with an address instead of text. */
|
||||
version?: string;
|
||||
/** When this copy was read or made (ms): a message's copy never replaces a later one (lib/preview-merge.ts). */
|
||||
at?: number; }
|
||||
export const PREVIEW_EXAMPLES: PreviewArtifact[] = [
|
||||
{ id:'sample-website', kind:'website', title:'Fieldnotes — website', url:'/examples/fieldnotes.html', demo:true },
|
||||
{ id:'sample-markdown', kind:'markdown', title:'project-brief.md', url:'/examples/project-brief.md', demo:true },
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Session } from './types';
|
||||
import {
|
||||
HOST_FLAG_KEY, PENDING_KEY, TransportError, readBrowserSessions,
|
||||
type ChatTransport, type HostChatEntry, type StorageLike,
|
||||
} from './session-persist';
|
||||
|
||||
/**
|
||||
* The page's side of the chat files on the host (server/chat-store.mjs): the HTTP transport, the first read at start (with the move of
|
||||
* the browser's old chats to the host), the last-moment copy for a page that goes away, and the merge of what another window saved.
|
||||
*/
|
||||
|
||||
const BASE = () => (globalThis as { __STRATA_HOST__?: string }).__STRATA_HOST__ ?? '';
|
||||
/** Names this window to the host, so the event of its own save is not mistaken for another window's. */
|
||||
export const WINDOW_ID = `w_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
||||
|
||||
export function createHttpTransport(fetchImpl: typeof fetch = (...args) => fetch(...args)): ChatTransport {
|
||||
const headers = (extra: Record<string, string> = {}) => ({ 'x-strata-client': 'ui', 'x-strata-window': WINDOW_ID, ...extra });
|
||||
async function call(method: string, path: string, init: { body?: string; headers?: Record<string, string> } = {}): Promise<Response> {
|
||||
let res: Response;
|
||||
try { res = await fetchImpl(`${BASE()}${path}`, { method, headers: headers(init.headers), body: init.body }); }
|
||||
catch (e) { throw new TransportError(e instanceof Error ? e.message : 'The host did not answer.', 0, 'offline', true); }
|
||||
if (res.ok) return res;
|
||||
let code: string | undefined, message = `Request failed (${res.status})`;
|
||||
try { const data = await res.json() as { error?: string; code?: string }; code = data.code; if (data.error) message = data.error; } catch { /* no body */ }
|
||||
throw new TransportError(message, res.status, code);
|
||||
}
|
||||
return {
|
||||
async list() { return await (await call('GET', '/api/chats')).json() as { chats: HostChatEntry[]; damaged?: string[] }; },
|
||||
async get(id) {
|
||||
try {
|
||||
const data = await (await call('GET', `/api/chats/${encodeURIComponent(id)}`)).json() as { rev: number; session: Session };
|
||||
return { rev: Number(data.rev) || 0, session: data.session };
|
||||
} catch (e) { if (isTransportError(e) && (e as TransportError).status === 404) return null; throw e; }
|
||||
},
|
||||
async put(id, text, baseRev) {
|
||||
const res = await call('PUT', `/api/chats/${encodeURIComponent(id)}`, { body: text, headers: { 'content-type': 'application/json', ...(baseRev !== null ? { 'x-strata-base-rev': String(baseRev) } : {}) } });
|
||||
const data = await res.json() as { rev: number; conflict?: boolean };
|
||||
return { rev: Number(data.rev) || 0, conflict: data.conflict === true };
|
||||
},
|
||||
async remove(id) { await call('DELETE', `/api/chats/${encodeURIComponent(id)}`); },
|
||||
async importMany(sessions) {
|
||||
const res = await call('POST', '/api/chats/import', { body: JSON.stringify({ sessions }), headers: { 'content-type': 'application/json' } });
|
||||
return await res.json() as { imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface HydrateResult {
|
||||
/** The chats to show: the host's, newest first, with this page's own newer ones kept. */
|
||||
sessions: Session[];
|
||||
/** [id, rev] of every chat read from the host. */
|
||||
revs: Map<string, number>;
|
||||
/** The host could not be asked: `sessions` is what the page had, nothing was moved. */
|
||||
offline: boolean;
|
||||
/** Chats moved from the browser's storage to the host this time. */
|
||||
moved: number;
|
||||
/** Chats the host refused during the move (disk full / permission) or could not be read; they stay in the browser. */
|
||||
failed: { id: string; error: string; code?: string }[];
|
||||
/** Chats that came back from the last-moment copy because the host's version was older. */
|
||||
recovered: string[];
|
||||
}
|
||||
|
||||
/** By name, so it holds across two copies of this module (a bundle per test file). */
|
||||
const isTransportError = (e: unknown): boolean => e instanceof TransportError || (e as { name?: string } | null)?.name === 'TransportError';
|
||||
const newer = (a?: string | null, b?: string | null) => String(a ?? '') > String(b ?? '');
|
||||
|
||||
/** Read a batch of chats from the host, a few at a time. */
|
||||
async function readAll(transport: ChatTransport, entries: HostChatEntry[], parallel = 4): Promise<Map<string, { rev: number; session: Session }>> {
|
||||
const out = new Map<string, { rev: number; session: Session }>();
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const entry = entries[next++];
|
||||
if (!entry) return;
|
||||
const got = await transport.get(entry.id);
|
||||
if (got) out.set(entry.id, got);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(parallel, entries.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first read of the host's chats at start. Before the host has ever been given the browser's chats (no flag), they are handed over
|
||||
* (the host takes the ones it does not have, in one request) and the flag is set; the browser's copy is left untouched. When the host
|
||||
* cannot be asked the page keeps what it has and the writer stays off.
|
||||
*/
|
||||
export async function hydrateChats(opts: {
|
||||
transport: ChatTransport;
|
||||
browser: StorageLike;
|
||||
/** The chats in memory now (the browser's copy at start, or what this window already holds). */
|
||||
current: Session[];
|
||||
/** A chat that must not be replaced from the host (a turn is running in it). */
|
||||
isLive?: (id: string) => boolean;
|
||||
/** Whether a chat in memory has changes the host does not have yet. */
|
||||
isDirty?: (session: Session) => boolean;
|
||||
}): Promise<HydrateResult> {
|
||||
const { transport, browser } = opts;
|
||||
const isLive = opts.isLive ?? (() => false);
|
||||
const isDirty = opts.isDirty ?? (() => false);
|
||||
let moved = 0;
|
||||
const failed: HydrateResult['failed'] = [];
|
||||
try {
|
||||
let flagged = false;
|
||||
try { flagged = browser.getItem(HOST_FLAG_KEY) === '1'; } catch { flagged = false; }
|
||||
let list = await transport.list();
|
||||
if (!flagged) {
|
||||
const old = readBrowserSessions(browser).sessions;
|
||||
if (old.length) {
|
||||
const result = await transport.importMany(old);
|
||||
moved = result.imported.length;
|
||||
failed.push(...result.failed);
|
||||
list = await transport.list();
|
||||
}
|
||||
// Set only when nothing was refused: a disk-full move is tried again at the next start.
|
||||
if (!failed.length) { try { browser.setItem(HOST_FLAG_KEY, '1'); } catch { /* the flag is a courtesy; the host skips what it has */ } }
|
||||
}
|
||||
const fromHost = await readAll(transport, list.chats);
|
||||
const revs = new Map([...fromHost].map(([id, v]) => [id, v.rev]));
|
||||
const mine = new Map(opts.current.map(s => [s.id, s]));
|
||||
const merged: Session[] = [];
|
||||
for (const entry of list.chats) {
|
||||
const remote = fromHost.get(entry.id);
|
||||
const local = mine.get(entry.id);
|
||||
if (!remote) { if (local) merged.push(local); continue; }
|
||||
merged.push(local && (isLive(entry.id) || isDirty(local) || newer(local.updatedAt, remote.session.updatedAt)) ? local : remote.session);
|
||||
mine.delete(entry.id);
|
||||
}
|
||||
// Chats only this page has (made while the host was being read): kept, and saved when the writer starts.
|
||||
const hostIds = new Set(list.chats.map(c => c.id));
|
||||
const onlyHere = opts.current.filter(s => !hostIds.has(s.id));
|
||||
const sessions = [...onlyHere, ...merged];
|
||||
const recovered = takeLastMomentCopy(browser, sessions, revs);
|
||||
return { sessions: recovered.sessions, revs, offline: false, moved, failed, recovered: recovered.ids };
|
||||
} catch (e) {
|
||||
if (isTransportError(e)) return { sessions: opts.current, revs: new Map(), offline: true, moved, failed, recovered: [] };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- the last-moment copy */
|
||||
|
||||
/** The page is going away with chats the host has not confirmed: keep them (best effort, may not fit) for the next start. */
|
||||
export function keepLastMomentCopy(storage: StorageLike, sessions: Session[]): boolean {
|
||||
try {
|
||||
if (!sessions.length) { storage.removeItem(PENDING_KEY); return true; }
|
||||
storage.setItem(PENDING_KEY, JSON.stringify(sessions));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** At start: chats from the last-moment copy that are newer than the host's (or the host lacks) replace the host's version in memory, to be saved. */
|
||||
export function takeLastMomentCopy(storage: StorageLike, sessions: Session[], revs: Map<string, number>): { sessions: Session[]; ids: string[] } {
|
||||
let kept: Session[] = [];
|
||||
try { const raw = storage.getItem(PENDING_KEY); if (raw) { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) kept = parsed.filter((s): s is Session => !!s && typeof (s as Session).id === 'string'); } } catch { kept = []; }
|
||||
if (!kept.length) return { sessions, ids: [] };
|
||||
const byId = new Map(sessions.map(s => [s.id, s]));
|
||||
const ids: string[] = [];
|
||||
for (const copy of kept) {
|
||||
const have = byId.get(copy.id);
|
||||
if (!have || newer(copy.updatedAt, have.updatedAt)) { byId.set(copy.id, copy); ids.push(copy.id); }
|
||||
}
|
||||
try { storage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
|
||||
if (!ids.length) return { sessions, ids };
|
||||
const order = sessions.map(s => s.id);
|
||||
const result = order.map(id => byId.get(id)!).concat([...byId.keys()].filter(id => !order.includes(id)).map(id => byId.get(id)!));
|
||||
void revs;
|
||||
return { sessions: result, ids };
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
async function request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
signal,
|
||||
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
@@ -31,7 +32,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
get: <T>(path: string, signal?: AbortSignal) => request<T>('GET', path, undefined, signal),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
@@ -47,7 +48,7 @@ export interface StreamEvent {
|
||||
export function onEvent(handler: (e: StreamEvent) => void, onStatus?: (s: 'live' | 'offline') => void) {
|
||||
let source: EventSource;
|
||||
let seenAt = Date.now();
|
||||
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue'];
|
||||
const names = ['engine', 'monitor', 'models', 'native', 'image-models', 'runtime', 'providers', 'serving', 'download', 'log', 'setup', 'skills', 'task', 'goal', 'timeskip', 'plan', 'queue', 'chats'];
|
||||
const connect = () => {
|
||||
source?.close();
|
||||
seenAt = Date.now();
|
||||
|
||||
@@ -145,18 +145,18 @@ export function fileSize(bytes: number): string {
|
||||
|
||||
const csvText = (rows: string[][]) => rows.map(r => r.map(c => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c)).join(',')).join('\n');
|
||||
|
||||
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
|
||||
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; mtime?: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
|
||||
|
||||
/** The sidebar preview of a file: the host reads it (only inside the chat's folder) and the page draws it as a website, markdown, table, picture, PDF or code. */
|
||||
export async function previewOf(sessionId: string, file: { path: string; name: string }): Promise<PreviewArtifact> {
|
||||
const a = await api.get<PreviewAnswer>(`/api/files/preview?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file.path)}`);
|
||||
const id = `file:${a.path}`, title = a.name || file.name;
|
||||
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl };
|
||||
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl };
|
||||
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text };
|
||||
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text };
|
||||
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows) };
|
||||
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text };
|
||||
const id = `file:${a.path}`, title = a.name || file.name, at = Date.now(), version = a.mtime !== undefined ? `${a.mtime}:${a.size}` : undefined;
|
||||
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl, version, at };
|
||||
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl, version, at };
|
||||
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text, at };
|
||||
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text, at };
|
||||
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows), at };
|
||||
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text, at };
|
||||
throw new Error(a.note || 'This file has no preview. Open it in its own app.');
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,16 @@ import gui_ru from '../locales/gui-ru.json';
|
||||
import gui_pt from '../locales/gui-pt.json';
|
||||
import gui_ko from '../locales/gui-ko.json';
|
||||
import gui_fr from '../locales/gui-fr.json';
|
||||
/* The notice for chats the host could not write (sav-<lang>.json, scripts/make-chatsave-locales.py). */
|
||||
import sav_en from '../locales/sav-en.json';
|
||||
import sav_zh from '../locales/sav-zh.json';
|
||||
import sav_ja from '../locales/sav-ja.json';
|
||||
import sav_de from '../locales/sav-de.json';
|
||||
import sav_es from '../locales/sav-es.json';
|
||||
import sav_ru from '../locales/sav-ru.json';
|
||||
import sav_pt from '../locales/sav-pt.json';
|
||||
import sav_ko from '../locales/sav-ko.json';
|
||||
import sav_fr from '../locales/sav-fr.json';
|
||||
import base_zh from '../locales/zh.json';
|
||||
import base_ja from '../locales/ja.json';
|
||||
import base_de from '../locales/de.json';
|
||||
@@ -129,8 +139,8 @@ export type Language=typeof LANGUAGES[number]['value'];
|
||||
/* The Tune model dialog's strings live in tune-<lang>.json (apps/host/scripts/make-tune-locales.py) so other interface work does not collide with them. */
|
||||
/* The tool-server, picture and agent-result strings of the tools and images fixes live in tools-<lang>.json for the same reason. */
|
||||
/* The strings that had no key in the files above (#3479, #3825) and the 0.2.4.3 fixes live in fix-<lang>.json (scripts/make-fix-locales.py). */
|
||||
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en,...fix_en,...gh_en,...ui_en,...engine_en,...pick_en,...df2_en,...upd_en,...r3_en,...pic_en,...gui_en};
|
||||
const [zh,ja,de,es,ru,pt,ko,fr]=[{...base_zh,...tune_zh,...tools_zh,...fix_zh,...gh_zh,...ui_zh,...engine_zh,...pick_zh,...df2_zh,...upd_zh,...r3_zh,...pic_zh,...gui_zh},{...base_ja,...tune_ja,...tools_ja,...fix_ja,...gh_ja,...ui_ja,...engine_ja,...pick_ja,...df2_ja,...upd_ja,...r3_ja,...pic_ja,...gui_ja},{...base_de,...tune_de,...tools_de,...fix_de,...gh_de,...ui_de,...engine_de,...pick_de,...df2_de,...upd_de,...r3_de,...pic_de,...gui_de},{...base_es,...tune_es,...tools_es,...fix_es,...gh_es,...ui_es,...engine_es,...pick_es,...df2_es,...upd_es,...r3_es,...pic_es,...gui_es},{...base_ru,...tune_ru,...tools_ru,...fix_ru,...gh_ru,...ui_ru,...engine_ru,...pick_ru,...df2_ru,...upd_ru,...r3_ru,...pic_ru,...gui_ru},{...base_pt,...tune_pt,...tools_pt,...fix_pt,...gh_pt,...ui_pt,...engine_pt,...pick_pt,...df2_pt,...upd_pt,...r3_pt,...pic_pt,...gui_pt},{...base_ko,...tune_ko,...tools_ko,...fix_ko,...gh_ko,...ui_ko,...engine_ko,...pick_ko,...df2_ko,...upd_ko,...r3_ko,...pic_ko,...gui_ko},{...base_fr,...tune_fr,...tools_fr,...fix_fr,...gh_fr,...ui_fr,...engine_fr,...pick_fr,...df2_fr,...upd_fr,...r3_fr,...pic_fr,...gui_fr}] as Record<string,string>[];
|
||||
const english:Record<string,string>={...baseEnglish,...tune_en,...tools_en,...fix_en,...gh_en,...ui_en,...engine_en,...pick_en,...df2_en,...upd_en,...r3_en,...pic_en,...gui_en,...sav_en};
|
||||
const [zh,ja,de,es,ru,pt,ko,fr]=[{...base_zh,...tune_zh,...tools_zh,...fix_zh,...gh_zh,...ui_zh,...engine_zh,...pick_zh,...df2_zh,...upd_zh,...r3_zh,...pic_zh,...gui_zh,...sav_zh},{...base_ja,...tune_ja,...tools_ja,...fix_ja,...gh_ja,...ui_ja,...engine_ja,...pick_ja,...df2_ja,...upd_ja,...r3_ja,...pic_ja,...gui_ja,...sav_ja},{...base_de,...tune_de,...tools_de,...fix_de,...gh_de,...ui_de,...engine_de,...pick_de,...df2_de,...upd_de,...r3_de,...pic_de,...gui_de,...sav_de},{...base_es,...tune_es,...tools_es,...fix_es,...gh_es,...ui_es,...engine_es,...pick_es,...df2_es,...upd_es,...r3_es,...pic_es,...gui_es,...sav_es},{...base_ru,...tune_ru,...tools_ru,...fix_ru,...gh_ru,...ui_ru,...engine_ru,...pick_ru,...df2_ru,...upd_ru,...r3_ru,...pic_ru,...gui_ru,...sav_ru},{...base_pt,...tune_pt,...tools_pt,...fix_pt,...gh_pt,...ui_pt,...engine_pt,...pick_pt,...df2_pt,...upd_pt,...r3_pt,...pic_pt,...gui_pt,...sav_pt},{...base_ko,...tune_ko,...tools_ko,...fix_ko,...gh_ko,...ui_ko,...engine_ko,...pick_ko,...df2_ko,...upd_ko,...r3_ko,...pic_ko,...gui_ko,...sav_ko},{...base_fr,...tune_fr,...tools_fr,...fix_fr,...gh_fr,...ui_fr,...engine_fr,...pick_fr,...df2_fr,...upd_fr,...r3_fr,...pic_fr,...gui_fr,...sav_fr}] as Record<string,string>[];
|
||||
const catalogs:Record<Language,Record<string,string>>={en:english,zh,ja,de,fr,es,ru,pt,ko};
|
||||
const entities:Record<string,string>={amp:'&',lt:'<',gt:'>',quot:'"',apos:"'",nbsp:'\u00a0'};
|
||||
const decode=(text:string)=>text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,(whole,key:string)=>key[0]==='#'?String.fromCodePoint(key[1].toLowerCase()==='x'?parseInt(key.slice(2),16):parseInt(key.slice(1),10)):entities[key]??whole);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The Enter that commits an IME conversion must not also send the message (#5071, Japanese input on macOS).
|
||||
*
|
||||
* The browsers disagree on the order of the events when Enter commits a conversion:
|
||||
* Chrome, Edge (WebView2), Firefox: keydown(Enter, keyCode 229, isComposing true) -> compositionend
|
||||
* Safari (WKWebView): compositionend -> keydown(Enter, keyCode 229, isComposing FALSE)
|
||||
* WebKitGTK (IBus), as reported: compositionend -> keydown(Enter, keyCode 13, isComposing false)
|
||||
* `isComposing` alone only covers the first. The rule here covers all three:
|
||||
* 1. a key event that says it is composing (isComposing) or that is the IME's own (keyCode 229) belongs to the IME;
|
||||
* 2. the first Enter right after a compositionend, in the same tick (a few milliseconds), is the one that committed;
|
||||
* 3. every other key is the person's. A second Enter after the conversion is committed sends, exactly once.
|
||||
* The flag of rule 2 is armed by compositionend, used up by the next key whatever it is, and expires on its own, so it can never leave
|
||||
* the composer unable to send. Times are the events' own `timeStamp`s (one clock).
|
||||
*/
|
||||
|
||||
/** How long after a compositionend an Enter still counts as the one that committed. The three engines deliver it in the same tick. */
|
||||
export const IME_COMMIT_WINDOW_MS = 50;
|
||||
|
||||
export interface ImeKey {
|
||||
key: string;
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
}
|
||||
|
||||
export interface ImeGuard {
|
||||
compositionStart(): void;
|
||||
compositionEnd(now: number): void;
|
||||
/** True when this keydown belongs to the IME: the composer must neither send nor run a menu or shortcut action for it. */
|
||||
keyDown(event: ImeKey, now: number): boolean;
|
||||
/** The textarea lost the focus: a conversion that was cut short never sends a compositionend. */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createImeGuard(windowMs = IME_COMMIT_WINDOW_MS): ImeGuard {
|
||||
let armedAt: number | null = null;
|
||||
return {
|
||||
compositionStart() {
|
||||
armedAt = null;
|
||||
},
|
||||
compositionEnd(now) {
|
||||
armedAt = now;
|
||||
},
|
||||
keyDown(event, now) {
|
||||
const armed = armedAt;
|
||||
armedAt = null; // the next key of any kind uses the flag up
|
||||
if (event.isComposing || event.keyCode === 229)
|
||||
return true;
|
||||
return armed !== null && event.key === 'Enter' && now - armed >= 0 && now - armed <= windowMs;
|
||||
},
|
||||
reset() {
|
||||
armedAt = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface NativeField {id:string;label:string;group:string;/** Not shown
|
||||
export interface NativeJob {id:string;kind:string;guided?:boolean;model?:string;family?:string;state:'running'|'done'|'error'|'cancelled';startedAt:string;summary:string;error:string|null;details?:string;result:Record<string,any>|null;progress?:Record<string,any>|null;/** An engine update beside a loaded model: which step it is on and the models it concerns (the app words these itself). */step?:'getting-ready'|'stopping'|'stopped';models?:string[]}
|
||||
export interface NativeInstance {id:string;apiModelId:string;model:string;label:string;aliases?:string[];vision?:boolean;resourceProfile?:'work'|'performance'|null;context?:number;port:number;gpus:number[];status:'loading'|'ready'|'unloading'|'error';phase:string;loadedAt:string;error:string|null;/** One short line when the model was loaded without picture reading although it was on (no internet for the picture files, no room on the disk). */picturesNote?:string;metrics:Record<string,any>|null}
|
||||
export interface NativeState {
|
||||
/** The host has not had an answer from Control yet (it is still starting): not "nothing installed". */
|
||||
checking?: boolean;
|
||||
/** The picture reader (strata-vision) is installed with the engine, so models marked for pictures can read them here. */
|
||||
/** Strata Control's view of the engine for this computer (the app): usable false = none found or recognised; `notice` says why in plain words. */
|
||||
engine?:{usable:boolean;notice?:string|null;wrongMake?:boolean;version?:string|null;/** What the last update check found newer (kept on this PC, read without the network): the Qwen engine, the DeepSeek engine, the app. */updateAvailable?:{engine?:string|null;dsv41?:string|null;app?:string|null}|null};
|
||||
@@ -65,6 +67,8 @@ export function useNative(){
|
||||
return value;
|
||||
}
|
||||
export const nativeState=()=>state;
|
||||
/** QAGUI 0213: the engine answered but Control's list of installed models has not arrived yet (the first seconds after a start): a downloaded model must not be offered as a download. */
|
||||
export const installedListPending=(value:Pick<NativeState,'available'|'installed'|'error'|'checking'>|null|undefined):boolean=>!!value&&(value.checking===true||value.available===true&&!value.installed&&!value.error);
|
||||
/** Connection-list projection only; memory reservations are not inferred. */
|
||||
export function nativeRouting(native:NativeState|null,hardware:Hardware|null):RuntimeModels|null{
|
||||
if(!native?.instances.length)return null;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Where an anchored panel (a menu, a popover) goes, in a page that may be scaled (#4653).
|
||||
*
|
||||
* "Interface size" in Settings scales the whole page with the CSS `zoom` property on the root. In Chromium (WebView2) and the engines
|
||||
* that follow the standard, `getBoundingClientRect()` and `window.innerWidth` then report what is on the screen (visual pixels), but the
|
||||
* `left`, `top`, `width` and `max-height` written on an element are in the element's own zoomed pixels: 100 written is 125 on the screen
|
||||
* at 125%. A menu placed from the anchor's rectangle and written back unchanged therefore lands (scale - 1) of its distance too far
|
||||
* right and down, which at the bottom-left of the window (the sidebar's "More" item) is off the screen: the click "does nothing".
|
||||
*
|
||||
* Everything here works in visual pixels and divides by the scale only at the end, when the numbers become CSS values.
|
||||
* `measureScale()` reads the scale from the page itself (a probe element), so it is right whichever way an engine counts.
|
||||
*/
|
||||
|
||||
export interface Box { left: number; top: number; right: number; bottom: number }
|
||||
export interface Size { width: number; height: number }
|
||||
|
||||
export interface Placement {
|
||||
/** CSS values for `position: fixed` (the element's own pixels). */
|
||||
left: number;
|
||||
top: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
/** True when the panel sits above its anchor. */
|
||||
above: boolean;
|
||||
/** Where the panel is on the screen (visual pixels): for checks. */
|
||||
visual: Box;
|
||||
}
|
||||
|
||||
/** The least room (screen pixels) on one side of the anchor that is worth shortening the panel to. */
|
||||
const MIN_ROOM = 120;
|
||||
|
||||
const finite = (n: number, fallback: number) => (Number.isFinite(n) ? n : fallback);
|
||||
|
||||
/** A scale that makes sense: engines and tests may hand over 0, NaN or nonsense. */
|
||||
export function sane(scale: number): number {
|
||||
return Number.isFinite(scale) && scale >= 0.25 && scale <= 4 ? scale : 1;
|
||||
}
|
||||
|
||||
export interface PlaceOptions {
|
||||
anchor: Box;
|
||||
/** The panel's size on the screen when it has all the room it wants. */
|
||||
size: Size;
|
||||
viewport: Size;
|
||||
/** Visual pixels per CSS pixel of the panel (1.25 at "Interface size" 125%). */
|
||||
scale: number;
|
||||
/** Air between the screen edge and the panel. */
|
||||
margin?: number;
|
||||
/** Air between the anchor and the panel when it opens below / above. */
|
||||
gapBelow?: number;
|
||||
gapAbove?: number;
|
||||
/** Open to the right of the anchor, top aligned (a tooltip beside a sidebar row), instead of below / above. */
|
||||
side?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the anchor when it fits, otherwise above it, otherwise on the side with more room, shortened to fit (the panel scrolls).
|
||||
* Left aligned with the anchor and kept inside the screen. Works for any viewport and scale.
|
||||
*/
|
||||
export function placeAnchored(o: PlaceOptions): Placement {
|
||||
const scale = sane(o.scale);
|
||||
const m = o.margin ?? 12;
|
||||
const vw = Math.max(0, finite(o.viewport.width, 0)), vh = Math.max(0, finite(o.viewport.height, 0));
|
||||
const maxW = Math.max(0, vw - 2 * m);
|
||||
const width = Math.min(Math.max(0, finite(o.size.width, 0)), maxW);
|
||||
let left: number, top: number, above = false, maxH: number;
|
||||
if (o.side) {
|
||||
left = Math.min(o.anchor.right + (o.gapBelow ?? 12), Math.max(m, vw - width - m));
|
||||
maxH = Math.max(0, vh - 2 * m);
|
||||
const height = Math.min(Math.max(0, finite(o.size.height, 0)), maxH);
|
||||
top = Math.max(m, Math.min(o.anchor.top, vh - height - m));
|
||||
}
|
||||
else {
|
||||
left = Math.max(m, Math.min(o.anchor.left, vw - width - m));
|
||||
const gapBelow = o.gapBelow ?? 8, gapAbove = o.gapAbove ?? 6;
|
||||
const height = Math.max(0, finite(o.size.height, 0));
|
||||
const roomBelow = vh - (o.anchor.bottom + gapBelow) - m;
|
||||
const roomAbove = o.anchor.top - gapAbove - m;
|
||||
above = height > roomBelow && roomAbove > roomBelow;
|
||||
const room = Math.max(0, above ? roomAbove : roomBelow);
|
||||
if (room < Math.min(height, MIN_ROOM)) {
|
||||
// a short window: neither side has room worth using, so the panel covers part of its anchor rather than shrinking to nothing
|
||||
above = false;
|
||||
maxH = Math.max(0, vh - 2 * m);
|
||||
const shown = Math.min(height, maxH);
|
||||
top = Math.max(m, Math.min(o.anchor.top, vh - shown - m));
|
||||
}
|
||||
else {
|
||||
maxH = room;
|
||||
const shown = Math.min(height, room);
|
||||
top = above ? o.anchor.top - gapAbove - shown : o.anchor.bottom + gapBelow;
|
||||
}
|
||||
}
|
||||
const heightShown = Math.min(Math.max(0, finite(o.size.height, 0)), maxH);
|
||||
return {
|
||||
left: left / scale,
|
||||
top: top / scale,
|
||||
maxWidth: maxW / scale,
|
||||
maxHeight: maxH / scale,
|
||||
above,
|
||||
visual: { left, top, right: left + width, bottom: top + heightShown },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual pixels per CSS pixel where `host` lives, found by measuring: a probe 100 CSS pixels wide is put in, read and taken out.
|
||||
* 1 when there is no zoom, 1.25 at 125%, and whatever the engine really does if it counts differently.
|
||||
*/
|
||||
export function measureScale(host: HTMLElement = document.body): number {
|
||||
try {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;left:0;top:0;width:100px;height:1px;visibility:hidden;pointer-events:none;';
|
||||
host.appendChild(probe);
|
||||
const scale = probe.getBoundingClientRect().width / 100;
|
||||
probe.remove();
|
||||
return sane(scale);
|
||||
}
|
||||
catch {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Which copy of a file the sidebar shows (#5180 follow-up: the preview kept a file's first content after the file changed).
|
||||
*
|
||||
* A preview tab can come from two places: the person (or a finished run) opening a file, which reads it from the host now, and the
|
||||
* message the file was made in, which carries the content it had then. They share one id (`file:<path>`). The rules, as pure functions:
|
||||
* - opening a file replaces the tab in place when the content differs, and keeps the very same array when it does not;
|
||||
* - a message's artifact only adds a tab that is missing, or replaces one that is OLDER than the message and different; it never
|
||||
* overwrites a copy that was read later;
|
||||
* - both return the array they were given when nothing changed, so no state update, no render and no effect follows: there is no loop to
|
||||
* start however often they are called (the same artifacts are handed over again whenever a message arrives).
|
||||
* The panel reloads when `previewVersion` changes, and only then.
|
||||
*/
|
||||
import type { PreviewArtifact } from './artifacts';
|
||||
import { textRevision } from './preview-render';
|
||||
|
||||
/** What identifies the content of a preview: the host's version for a file (modified time and size), else a hash of the text, else the address. */
|
||||
export function previewVersion(a: Pick<PreviewArtifact, 'version' | 'content' | 'url'>): string {
|
||||
if (a.content !== undefined)
|
||||
return 'c' + textRevision(a.content);
|
||||
return a.version ? 'v' + a.version : 'u' + (a.url ?? '');
|
||||
}
|
||||
|
||||
const sameContent = (a: PreviewArtifact, b: PreviewArtifact) => a.kind === b.kind && a.title === b.title && previewVersion(a) === previewVersion(b);
|
||||
|
||||
/** A file was opened (read from the host just now): it takes the place of its tab when it differs. */
|
||||
export function openItem(old: PreviewArtifact[], item: PreviewArtifact): PreviewArtifact[] {
|
||||
const at = old.findIndex(a => a.id === item.id);
|
||||
if (at < 0)
|
||||
return [...old, item];
|
||||
if (sameContent(old[at], item))
|
||||
return old;
|
||||
const next = old.slice();
|
||||
next[at] = item;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The artifacts the messages carry, merged into the tabs. `items` are in message order, each stamped with the time of its message (`at`);
|
||||
* `closed` are the ids the person closed.
|
||||
*/
|
||||
export function syncArtifacts(old: PreviewArtifact[], items: PreviewArtifact[], closed: readonly string[] | undefined = []): PreviewArtifact[] {
|
||||
let next = old;
|
||||
const edit = () => { if (next === old) next = old.slice(); };
|
||||
for (const item of items) {
|
||||
const at = next.findIndex(a => a.id === item.id);
|
||||
if (closed?.includes(item.id)) {
|
||||
if (at >= 0) { edit(); next.splice(at, 1); }
|
||||
continue;
|
||||
}
|
||||
if (at < 0) {
|
||||
edit();
|
||||
next.push(item);
|
||||
continue;
|
||||
}
|
||||
const have = next[at];
|
||||
// only a message newer than the copy on show, with different content, replaces it
|
||||
if (item.at !== undefined && have.at !== undefined && item.at > have.at && !sameContent(have, item)) {
|
||||
edit();
|
||||
next[at] = item;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The message artifacts of a session, in order and de-duplicated by id (the last of an id wins), each stamped with its message's time. */
|
||||
export function messageArtifacts(messages: ReadonlyArray<{ at?: string; artifacts?: PreviewArtifact[] }> | undefined): PreviewArtifact[] {
|
||||
const byId = new Map<string, PreviewArtifact>();
|
||||
for (const m of messages ?? []) {
|
||||
const at = m.at ? Date.parse(m.at) : NaN;
|
||||
for (const a of m.artifacts ?? [])
|
||||
byId.set(a.id, Number.isFinite(at) && a.at === undefined ? { ...a, at } : a);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Work limits for the Markdown preview in the sidebar (#5180).
|
||||
*
|
||||
* The preview used to parse and rebuild the whole file on every render of the chat, which is once per group of streamed tokens, and a
|
||||
* 60 KB file with many tables and code blocks is tens of thousands of React elements each time. The pieces here are pure text work:
|
||||
* - a revision key of the text, so an unchanged file is the same key however often the chat renders;
|
||||
* - the file cut into slices at block boundaries, each with its own key, so a change in one place re-renders one slice;
|
||||
* - limits: how many slices are drawn at once, when a block is shown as plain text, and when the whole file is.
|
||||
* The components that use them are components/PreviewMarkdown.tsx and components/ArtifactPanel.tsx.
|
||||
*/
|
||||
|
||||
/** Slices of about this many characters; a slice is the unit that is rendered, kept and skipped. */
|
||||
export const PREVIEW_SLICE_CHARS = 6000;
|
||||
/** Slices drawn at once when a file opens; the rest follow a few at a time, so the first paint is bounded however long the file is. */
|
||||
export const PREVIEW_FIRST_SLICES = 2;
|
||||
/** Slices added by each step after that. At most one step is waiting at any time. */
|
||||
export const PREVIEW_STEP_SLICES = 2;
|
||||
/** One block (a table or a fenced code block that cannot be cut) above this is shown as plain text, not parsed. */
|
||||
export const PREVIEW_BLOCK_LIMIT = 40_000;
|
||||
/** A file above this is shown as plain text (the Source view); the Read view would be thousands of elements per render. */
|
||||
export const PREVIEW_RENDER_LIMIT = 200_000;
|
||||
|
||||
/** 32-bit FNV-1a over the UTF-16 units, in base 36; with the length it is the revision key of a text. Not a security hash. */
|
||||
export function textRevision(text: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return `${text.length.toString(36)}-${h.toString(36)}`;
|
||||
}
|
||||
|
||||
export interface PreviewSlice {
|
||||
/** Stable for the same text at the same place: a slice that did not change keeps its key and is not rendered again. */
|
||||
key: string;
|
||||
text: string;
|
||||
/** Too large to parse: shown as plain text. */
|
||||
plain: boolean;
|
||||
}
|
||||
|
||||
/** The longest run of lines a `$$` or `\[` block may span; lib/math.ts displayBlock reads at most this many. */
|
||||
const MATH_LINES = 60;
|
||||
|
||||
/**
|
||||
* Cut Markdown into slices of about `target` characters, only at a blank line that is outside a fenced code block and outside a
|
||||
* display-math block. Blank lines already end a paragraph, a list and a table in components/Markdown.tsx, so the slices drawn one
|
||||
* after the other are the same elements as the whole text drawn once.
|
||||
*/
|
||||
export function splitPreview(text: string, target = PREVIEW_SLICE_CHARS): PreviewSlice[] {
|
||||
const lines = text.split(/\r\n|\r|\n/);
|
||||
const slices: PreviewSlice[] = [];
|
||||
let start = 0, size = 0, fenced = false, mathEnd = -1;
|
||||
const emit = (end: number) => {
|
||||
const body = lines.slice(start, end).join('\n');
|
||||
if (body.trim()) {
|
||||
const plain = body.length > PREVIEW_BLOCK_LIMIT;
|
||||
slices.push({ key: `${slices.length}:${textRevision(body)}`, text: body, plain });
|
||||
}
|
||||
start = end;
|
||||
size = 0;
|
||||
};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
size += line.length + 1;
|
||||
if (/^\s*```/.test(line)) {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
if (fenced)
|
||||
continue;
|
||||
const t = line.trim();
|
||||
if (i > mathEnd) {
|
||||
const opens = t.startsWith('$$') ? '$$' : t.startsWith('\\[') ? '\\[' : '';
|
||||
if (opens && !t.slice(2).includes(opens === '$$' ? '$$' : '\\]'))
|
||||
mathEnd = i + MATH_LINES;
|
||||
}
|
||||
else if (t.includes('$$') || t.includes('\\]'))
|
||||
mathEnd = -1;
|
||||
// a blank line outside code and math, once the slice is big enough, ends it (the blank line goes with the slice before)
|
||||
if (!t && i > mathEnd && size >= target && i + 1 < lines.length)
|
||||
emit(i + 1);
|
||||
}
|
||||
emit(lines.length);
|
||||
return slices;
|
||||
}
|
||||
|
||||
/** How a file is shown: drawn as Markdown in slices, or as plain text when it is above the limit. */
|
||||
export function previewTier(text: string): 'markdown' | 'plain' {
|
||||
return text.length > PREVIEW_RENDER_LIMIT ? 'plain' : 'markdown';
|
||||
}
|
||||
|
||||
/**
|
||||
* How many slices are drawn after a step. `shown` is the count now; the new text may have fewer or more slices than before. A change
|
||||
* in the file never takes back slices already on screen (the page would jump), it only adds what is still missing.
|
||||
*/
|
||||
export function slicesAfter(shown: number, total: number, step = PREVIEW_STEP_SLICES): number {
|
||||
return Math.min(total, Math.max(shown, 0) + step);
|
||||
}
|
||||
|
||||
/** The count to draw before any step has run for this text: the ones already shown, or the first few. */
|
||||
export function slicesAtStart(shown: number, total: number): number {
|
||||
return Math.min(total, Math.max(shown, PREVIEW_FIRST_SLICES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsing cache: the slices of the last few texts, found by revision. A chat that renders again with the same file asks for the same
|
||||
* text, and the answer is the same array (same identity), so nothing downstream sees a change. `stats.cuts` counts the real work.
|
||||
*/
|
||||
const cache = new Map<string, { text: string; slices: PreviewSlice[] }>();
|
||||
export const previewStats = { cuts: 0 };
|
||||
export function previewSlices(text: string): PreviewSlice[] {
|
||||
const key = textRevision(text);
|
||||
const known = cache.get(key);
|
||||
if (known && known.text === text) {
|
||||
cache.delete(key);
|
||||
cache.set(key, known);
|
||||
return known.slices;
|
||||
}
|
||||
previewStats.cuts++;
|
||||
const slices = splitPreview(text);
|
||||
cache.set(key, { text, slices });
|
||||
if (cache.size > 4)
|
||||
cache.delete(cache.keys().next().value as string);
|
||||
return slices;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* After a crash of the page's renderer (#5079). The desktop shell loads the page again at `/?recovery=<n>`; the page then starts with the
|
||||
* sidebar previews closed, once, so the file that may have caused the crash is not opened again by itself. The chats are the host's and
|
||||
* are all still there; only what the sidebar showed is deferred, and only until the person opens a preview themselves.
|
||||
*/
|
||||
|
||||
/** 1..9 from a `recovery=<n>` query, 0 when there is none or it is not a number. */
|
||||
export function parseRecovery(search: string): number {
|
||||
const value = new URLSearchParams(search).get('recovery');
|
||||
if (value === null || !/^[1-9]$/.test(value))
|
||||
return 0;
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/** The query without the recovery parameter (and with its `?` only when something is left). */
|
||||
export function withoutRecovery(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete('recovery');
|
||||
const rest = params.toString();
|
||||
return rest ? `?${rest}` : '';
|
||||
}
|
||||
|
||||
let deferred = false;
|
||||
let deferredSession: string | null = null;
|
||||
|
||||
/**
|
||||
* Read the shell's recovery mark from the address, remove it (a later reload of the page is an ordinary start), and defer the previews.
|
||||
* Called once, before the first render. Returns the recovery number, 0 for an ordinary start.
|
||||
*/
|
||||
export function startRecovery(): number {
|
||||
let level = 0;
|
||||
try {
|
||||
level = parseRecovery(window.location.search);
|
||||
if (level) {
|
||||
const { pathname, search, hash } = window.location;
|
||||
window.history.replaceState(window.history.state, '', pathname + withoutRecovery(search) + hash);
|
||||
}
|
||||
}
|
||||
catch { /* no address to read: an ordinary start */ }
|
||||
deferred = level > 0;
|
||||
deferredSession = null;
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the sidebar previews stay closed after a recovery: for the chat that was open when the renderer died, until the person
|
||||
* opens a preview themselves or goes to another chat. The first chat asked about is that chat.
|
||||
*/
|
||||
export function previewsDeferred(sessionId?: string): boolean {
|
||||
if (!deferred)
|
||||
return false;
|
||||
if (sessionId === undefined)
|
||||
return true;
|
||||
deferredSession ??= sessionId;
|
||||
if (deferredSession !== sessionId) {
|
||||
deferred = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The person opened a preview themselves: from now on previews open as usual. */
|
||||
export function endPreviewDeferral(): void {
|
||||
deferred = false;
|
||||
}
|
||||
@@ -1,71 +1,198 @@
|
||||
import type { Session } from './types';
|
||||
|
||||
/**
|
||||
* Saving the chat list without hurting the page (#3604, #3603).
|
||||
* Saving the chats: by the app host, as files (owner decision 11-04; #5192, #3604, #3603).
|
||||
*
|
||||
* Every streamed token used to stringify every chat and write the lot to localStorage. On a long history that is
|
||||
* megabytes of string work per token, and the WebView2 renderer grew to gigabytes before it died (the window goes
|
||||
* white). Three rules fix it, and the stored format is unchanged, so older builds and the flow scripts still read it:
|
||||
* The chats used to live in the browser's localStorage, which holds about 5 MB for a whole origin. To fit, the writer first cut
|
||||
* long fields and dropped old messages and chats (#5192), and after that fix a full storage meant chats that could not be saved
|
||||
* at all. Now the host keeps one file per chat in the profile's data folder (server/chat-store.mjs) and the page only sends the
|
||||
* chats that changed. The write side keeps the rules of the renderer-memory fix (#3604, #3603):
|
||||
*
|
||||
* 1. Coalesce: the newest state wins and at most one write happens per `delayMs`. The caller flushes when a turn ends
|
||||
* or the page goes away.
|
||||
* 2. Only the chats that changed are serialised again. A chat is replaced by a new object when it changes (the store
|
||||
* never mutates), so the previous JSON of every other chat is reused by object identity.
|
||||
* 3. The stored copy is capped: a huge tool result or an old chat that no longer fits is left out of the saved copy,
|
||||
* never out of memory. The saved list is a cache, not the source of truth.
|
||||
* 1. Coalesce: the newest state wins and at most one save happens per `delayMs`. The caller flushes when a turn ends or the
|
||||
* page goes away.
|
||||
* 2. Only the chats that changed are serialised again (a chat is replaced by a new object when it changes, the store never
|
||||
* mutates), so the previous JSON of every other chat is reused by object identity, and only those are sent.
|
||||
*
|
||||
* And, new with the files:
|
||||
* - A chat is sent exactly as it is (JSON.stringify of the chat): no clipping, no dropped messages or chats.
|
||||
* - A save the host refuses (disk full, no permission) or cannot be asked (the host is not answering) leaves the chat whole in
|
||||
* memory and marked unsaved; it is tried again at the next change and on a timer, and the failure is reported through
|
||||
* `onResult` so the caller can say so and offer the unsaved chats (`unsaved()`, `exportSessions`).
|
||||
* - A chat removed from the list is removed on the host. Only chats this writer has seen are ever removed.
|
||||
* - The writer does not save before `enable()` (the first read of the host's chats has to land first, so an older copy in the
|
||||
* page never replaces a newer file).
|
||||
*
|
||||
* The browser's old storage is read once for the move to files (`readBrowserSessions`) and is never written or emptied again:
|
||||
* it stays as a fallback for one release.
|
||||
*/
|
||||
|
||||
/** Longest text kept for one field in the saved copy. */
|
||||
export const MAX_FIELD_CHARS = 200_000;
|
||||
/** Longest saved copy of one chat (JSON characters). A longer chat is shortened from its oldest messages. */
|
||||
export const MAX_SESSION_CHARS = 1_500_000;
|
||||
/** Longest saved chat list (JSON characters). localStorage allows about 5M; keep a margin for everything else. */
|
||||
export const MAX_TOTAL_CHARS = 3_500_000;
|
||||
/** The old whole-list entry of the released versions (0.2.10 and before). */
|
||||
export const LEGACY_SESSIONS_KEY = 'strata.sessions.v1';
|
||||
/** The order of the chats in the short-lived per-chat browser layout (this branch before the files). */
|
||||
export const SESSION_INDEX_KEY = 'strata.sessions.v2';
|
||||
/** One chat per entry in that layout: this prefix plus the chat id. */
|
||||
export const SESSION_KEY_PREFIX = 'strata.session.v2.';
|
||||
/** Set when the browser's chats were handed to the host. After it, the host's files are the only truth. */
|
||||
export const HOST_FLAG_KEY = 'strata.chats.host.v1';
|
||||
/** Chats that were not yet on the host when the page went away (a last-moment copy, taken back at the next start). */
|
||||
export const PENDING_KEY = 'strata.chats.pending.v1';
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
function clipFields(value: Json, depth = 0): Json {
|
||||
if (typeof value === 'string') return value.length > MAX_FIELD_CHARS ? `${value.slice(0, MAX_FIELD_CHARS)}\n[shortened in the saved copy]` : value;
|
||||
if (depth > 6 || value === null || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(v => clipFields(v, depth + 1));
|
||||
const out: Record<string, Json> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, Json>)) out[k] = clipFields(v, depth + 1);
|
||||
return out;
|
||||
/** The part of `Storage` that is read from. */
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
key(index: number): string | null;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
/** The JSON text kept for one chat: whole when it fits, otherwise clipped and then shortened from the oldest message. */
|
||||
/** The JSON text sent for one chat: the chat exactly as it is. */
|
||||
export function serializeSession(session: Session): string {
|
||||
const whole = JSON.stringify(session);
|
||||
if (whole.length <= MAX_SESSION_CHARS) return whole;
|
||||
let copy = clipFields(session) as Session;
|
||||
let text = JSON.stringify(copy);
|
||||
if (text.length <= MAX_SESSION_CHARS) return text;
|
||||
let messages = copy.messages ?? [];
|
||||
// Drop the oldest messages in halves until it fits; always keep the latest two.
|
||||
while (text.length > MAX_SESSION_CHARS && messages.length > 2) {
|
||||
messages = messages.slice(Math.max(1, Math.floor(messages.length / 2)));
|
||||
copy = { ...copy, messages };
|
||||
text = JSON.stringify(copy);
|
||||
return JSON.stringify(session);
|
||||
}
|
||||
|
||||
/** The chats as one JSON text, for keeping a copy by hand when they cannot be saved. */
|
||||
export function exportSessions(sessions: Session[]): string {
|
||||
return JSON.stringify(sessions);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the browser's old storage (read only) */
|
||||
|
||||
function readIndex(storage: StorageLike): string[] {
|
||||
try {
|
||||
const raw = storage.getItem(SESSION_INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { ids?: unknown };
|
||||
return Array.isArray(parsed.ids) ? parsed.ids.filter((id): id is string => typeof id === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every chat in the browser's storage, from both layouts: one entry per chat (strata.session.v2.<id>) and the old whole list
|
||||
* (strata.sessions.v1). Where a chat is in both, the per-chat entry wins. Nothing is written, moved or removed.
|
||||
*/
|
||||
export function readBrowserSessions(storage: StorageLike): { sessions: Session[]; damaged: string[] } {
|
||||
const damaged: string[] = [];
|
||||
const found = new Map<string, Session>();
|
||||
try {
|
||||
const raw = storage.getItem(LEGACY_SESSIONS_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((entry, n) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const s = entry as Session;
|
||||
const id = typeof s.id === 'string' && s.id ? s.id : `legacy_${n}`;
|
||||
found.set(id, id === s.id ? s : { ...s, id });
|
||||
});
|
||||
} else damaged.push(LEGACY_SESSIONS_KEY);
|
||||
}
|
||||
} catch { damaged.push(LEGACY_SESSIONS_KEY); }
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (key && key.startsWith(SESSION_KEY_PREFIX)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(key) ?? 'null') as Session | null;
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.id === 'string') found.set(parsed.id, parsed);
|
||||
else damaged.push(key);
|
||||
} catch { damaged.push(key); }
|
||||
}
|
||||
} catch { /* storage not readable: nothing to load */ }
|
||||
const rank = new Map(readIndex(storage).map((id, i) => [id, i]));
|
||||
const sessions = [...found.values()].sort((a, b) => {
|
||||
const ra = rank.get(a.id), rb = rank.get(b.id);
|
||||
if (ra !== undefined && rb !== undefined) return ra - rb;
|
||||
if (ra !== undefined) return -1;
|
||||
if (rb !== undefined) return 1;
|
||||
return String(b.createdAt ?? '') < String(a.createdAt ?? '') ? -1 : 1;
|
||||
});
|
||||
return { sessions, damaged };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- the host */
|
||||
|
||||
export class TransportError extends Error {
|
||||
constructor(message: string, readonly status: number, readonly code: string | undefined, readonly network = false) { super(message); this.name = 'TransportError'; }
|
||||
}
|
||||
|
||||
export interface HostChatEntry { id: string; rev: number; createdAt?: string | null; updatedAt?: string | null }
|
||||
|
||||
/** What the page asks of the host about chats (chat-sync.ts makes the real one; a test passes a fake). */
|
||||
export interface ChatTransport {
|
||||
list(): Promise<{ chats: HostChatEntry[]; damaged?: string[] }>;
|
||||
get(id: string): Promise<{ rev: number; session: Session } | null>;
|
||||
put(id: string, text: string, baseRev: number | null): Promise<{ rev: number; conflict: boolean }>;
|
||||
remove(id: string): Promise<void>;
|
||||
importMany(sessions: Session[]): Promise<{ imported: string[]; skipped: string[]; failed: { id: string; error: string; code?: string }[] }>;
|
||||
}
|
||||
|
||||
export type FailureReason = 'disk-full' | 'permission' | 'offline' | 'other';
|
||||
|
||||
export interface SaveFailure {
|
||||
id: string;
|
||||
reason: FailureReason;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SaveResult {
|
||||
ok: boolean;
|
||||
failed: SaveFailure[];
|
||||
/** How many chats were saved by this pass. */
|
||||
written: number;
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
export function failureReason(error: unknown): FailureReason {
|
||||
const e = error as Partial<TransportError> | null;
|
||||
if (e && e.name === 'TransportError') {
|
||||
if (e.network) return 'offline';
|
||||
if (e.code === 'disk-full' || e.status === 507) return 'disk-full';
|
||||
if (e.code === 'permission' || e.status === 403) return 'permission';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export interface SessionWriterOptions {
|
||||
/** Where the list is saved (the storage write). Receives the finished JSON text. */
|
||||
write: (json: string) => void;
|
||||
transport: ChatTransport;
|
||||
delayMs?: number;
|
||||
maxTotalChars?: number;
|
||||
/** After a pass with failures the unsaved chats are tried again after this long. */
|
||||
retryMs?: number;
|
||||
/** Counts a serialisation of one chat; for tests. */
|
||||
onSerialize?: (session: Session) => void;
|
||||
/** Called after every pass, with what was saved and which chats the host did not take. */
|
||||
onResult?: (result: SaveResult) => void;
|
||||
/** The chats the host already has: [id, rev]. Only these (and chats this writer saved) are ever removed from it. */
|
||||
known?: Iterable<[string, number]>;
|
||||
/** Start saving at once. The page leaves it off until the host's chats have been read. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function createSessionWriter(options: SessionWriterOptions) {
|
||||
const transport = options.transport;
|
||||
const delayMs = options.delayMs ?? 1_000;
|
||||
const maxTotal = options.maxTotalChars ?? MAX_TOTAL_CHARS;
|
||||
const retryMs = options.retryMs ?? 8_000;
|
||||
let enabled = options.enabled ?? true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending: Session[] | null = null;
|
||||
let lastSessions: Session[] = [];
|
||||
let running: Promise<SaveResult> | null = null;
|
||||
let rerun = false;
|
||||
let lastResult: SaveResult = { ok: true, failed: [], written: 0, deleted: 0 };
|
||||
let failures = 0;
|
||||
// Chat object -> its JSON. WeakMap: a replaced chat is forgotten with its object.
|
||||
const cache = new WeakMap<Session, string>();
|
||||
// The JSON of each chat as the host last confirmed it (or as it was read from the host).
|
||||
const sent = new Map<string, string>();
|
||||
const revs = new Map<string, number>(options.known ?? []);
|
||||
const known = new Set<string>(revs.keys());
|
||||
const failedIds = new Set<string>();
|
||||
|
||||
const textOf = (s: Session): string => {
|
||||
let text = cache.get(s);
|
||||
@@ -77,34 +204,84 @@ export function createSessionWriter(options: SessionWriterOptions) {
|
||||
return text;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
async function pass(): Promise<SaveResult> {
|
||||
const sessions = pending;
|
||||
pending = null;
|
||||
if (!sessions) return;
|
||||
const parts = sessions.map(s => ({ s, text: textOf(s) }));
|
||||
let total = parts.reduce((n, p) => n + p.text.length + 1, 2);
|
||||
if (total > maxTotal) {
|
||||
// Leave the oldest unpinned chats out of the saved copy until the rest fits. The newest chat is always kept.
|
||||
const order = parts
|
||||
.map((p, i) => ({ i, p }))
|
||||
.sort((a, b) => Number(!!a.p.s.pinned) - Number(!!b.p.s.pinned) || (a.p.s.updatedAt < b.p.s.updatedAt ? -1 : 1));
|
||||
const drop = new Set<number>();
|
||||
for (const { i, p } of order) {
|
||||
if (total <= maxTotal || drop.size >= parts.length - 1) break;
|
||||
drop.add(i);
|
||||
total -= p.text.length + 1;
|
||||
}
|
||||
options.write(`[${parts.filter((_, i) => !drop.has(i)).map(p => p.text).join(',')}]`);
|
||||
return;
|
||||
if (!sessions) return lastResult;
|
||||
lastSessions = sessions;
|
||||
const ids = new Set(sessions.map(s => s.id));
|
||||
const failed: SaveFailure[] = [];
|
||||
let written = 0, deleted = 0;
|
||||
|
||||
// Removed chats first.
|
||||
for (const id of [...known]) {
|
||||
if (ids.has(id)) continue;
|
||||
try { await transport.remove(id); known.delete(id); sent.delete(id); revs.delete(id); failedIds.delete(id); deleted++; }
|
||||
catch (error) { failed.push({ id, reason: failureReason(error), message: error instanceof Error ? error.message : String(error) }); }
|
||||
}
|
||||
options.write(`[${parts.map(p => p.text).join(',')}]`);
|
||||
for (const s of sessions) {
|
||||
const text = textOf(s);
|
||||
if (sent.get(s.id) === text) { failedIds.delete(s.id); continue; }
|
||||
try {
|
||||
const saved = await transport.put(s.id, text, revs.get(s.id) ?? null);
|
||||
revs.set(s.id, saved.rev); known.add(s.id); sent.set(s.id, text); failedIds.delete(s.id);
|
||||
written++;
|
||||
} catch (error) {
|
||||
// The host keeps the last good file of this chat; the chat stays whole in memory and is tried again.
|
||||
failedIds.add(s.id);
|
||||
failed.push({ id: s.id, reason: failureReason(error), message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
lastResult = { ok: failedIds.size === 0 && failed.length === 0, failed, written, deleted };
|
||||
if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }
|
||||
if (!lastResult.ok) {
|
||||
// Tried again, a little later each time (up to a minute); the timer never keeps a closing page or process alive.
|
||||
failures = Math.min(failures + 1, 6);
|
||||
retryTimer = setTimeout(() => { retryTimer = null; if (pending === null) pending = lastSessions; void flush(); }, Math.min(retryMs * 2 ** (failures - 1), 60_000));
|
||||
(retryTimer as unknown as { unref?: () => void }).unref?.();
|
||||
} else failures = 0;
|
||||
options.onResult?.(lastResult);
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
const flush = (): Promise<SaveResult> => {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
if (!enabled) return Promise.resolve(lastResult);
|
||||
// Nothing new, but a chat the host refused earlier: a flush (a turn ended, the page is going away) tries it again.
|
||||
if (pending === null && failedIds.size) pending = lastSessions;
|
||||
if (running) { rerun = true; return running; }
|
||||
running = (async () => {
|
||||
let result = lastResult;
|
||||
do { rerun = false; result = await pass(); } while (rerun || pending !== null);
|
||||
return result;
|
||||
})().finally(() => { running = null; });
|
||||
return running;
|
||||
};
|
||||
|
||||
const schedule = (sessions: Session[]) => {
|
||||
pending = sessions;
|
||||
if (timer === null) timer = setTimeout(flush, delayMs);
|
||||
if (enabled && timer === null) timer = setTimeout(() => { timer = null; void flush(); }, delayMs);
|
||||
};
|
||||
|
||||
return { schedule, flush, hasPending: () => pending !== null };
|
||||
return {
|
||||
schedule,
|
||||
flush,
|
||||
hasPending: () => pending !== null || running !== null,
|
||||
/** Start saving (the host's chats have been read). Whatever was scheduled meanwhile is saved now. */
|
||||
enable() { enabled = true; if (pending !== null) void flush(); },
|
||||
/** The chats whose newest state is not on the host. */
|
||||
unsaved: (): Session[] => lastSessions.filter(s => failedIds.has(s.id)),
|
||||
/** A chat that was read from the host (or arrived from another window): its JSON and revision are what the host has. */
|
||||
adopt(session: Session, rev: number | null) { known.add(session.id); if (rev === null) revs.delete(session.id); else revs.set(session.id, rev); sent.set(session.id, serializeSession(session)); failedIds.delete(session.id); },
|
||||
/** A chat the host no longer has (removed from another window). */
|
||||
forget(id: string) { known.delete(id); revs.delete(id); sent.delete(id); failedIds.delete(id); },
|
||||
rev: (id: string) => revs.get(id) ?? 0,
|
||||
/** True when the page holds a newer state of this chat than the host has (scheduled, in flight or refused). */
|
||||
dirty(session: Session) { return sent.get(session.id) !== textOf(session); },
|
||||
/** The chats not confirmed by the host right now: what to keep if the page goes away this instant. */
|
||||
notOnHost(): Session[] {
|
||||
const list = pending ?? lastSessions;
|
||||
return list.filter(s => sent.get(s.id) !== textOf(s));
|
||||
},
|
||||
};
|
||||
}
|
||||
+278
-68
@@ -1,4 +1,5 @@
|
||||
import { workspaceIdForSession } from './session-workspace';
|
||||
import { shortTitle } from './title-text';
|
||||
import { DEFAULT_CONTENT_FS, DEFAULT_UI_SCALE, SIZE_DEFAULTS_VERSION, savedSizes } from './default-sizes';
|
||||
import { detectLanguage } from './language-detect';
|
||||
import { validWhileBusy, type WhileBusy } from './while-busy';
|
||||
@@ -26,7 +27,9 @@ import { noModelNotice } from './no-model';
|
||||
import { taskHandoverMessage, taskHandoverTitle, chatHandoverMessage, chatHandoverTitle } from './task-handover';
|
||||
import type { Task } from './tasks';
|
||||
import { postWithFolderFallback } from './folder-pick';
|
||||
import { createSessionWriter } from './session-persist';
|
||||
import { createSessionWriter, exportSessions, readBrowserSessions, HOST_FLAG_KEY, PENDING_KEY, type SaveResult, type StorageLike } from './session-persist';
|
||||
import { createHttpTransport, hydrateChats, keepLastMomentCopy, WINDOW_ID } from './chat-sync';
|
||||
import { copyText } from './clipboard';
|
||||
import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n';
|
||||
/**
|
||||
* Strata app store.
|
||||
@@ -36,7 +39,7 @@ import { setTranslationLanguage, tr, validLanguage, type Language } from './i18n
|
||||
* arrive on the event stream. No state library, no dependencies.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { api, onEvent, streamPost, type StreamEvent } from './client';
|
||||
import { api, ApiError, onEvent, streamPost, type StreamEvent } from './client';
|
||||
import type {
|
||||
Workspace,
|
||||
ChatMessage,
|
||||
@@ -67,6 +70,8 @@ export interface Toast {
|
||||
title: string;
|
||||
body?: string;
|
||||
leaving?: boolean;
|
||||
/** A button under the text. A toast with one stays until it is dismissed. */
|
||||
action?: { label: string; run: () => void };
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
@@ -141,9 +146,12 @@ export interface AppState {
|
||||
* with should not. */
|
||||
actionError: { message: string; label: string; retry: () => void } | null;
|
||||
busy: Record<string, boolean>;
|
||||
/** Set while the host cannot write some chats (disk full, no permission); null when every chat is saved (#5192).
|
||||
* The chats stay whole in memory and are tried again at every save; `unsavedSessionsJson()` gives them as text.
|
||||
* A host that does not answer is not this: those saves are simply retried. */
|
||||
sessionSave: { failedIds: string[]; reason: 'disk-full' | 'permission' | 'other' } | null;
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'strata.sessions.v1';
|
||||
const PREFS_KEY = 'strata.prefs.v2';
|
||||
/** What the model has been allowed to read, kept across restarts. */
|
||||
const TOUCHED_KEY = 'strata.touched.v1';
|
||||
@@ -202,21 +210,37 @@ function uid(prefix = 'id'): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
||||
}
|
||||
|
||||
/** localStorage, looked up at each call (it may not exist when this file is loaded outside a browser). */
|
||||
const browserStorage: StorageLike = {
|
||||
getItem: key => localStorage.getItem(key),
|
||||
setItem: (key, value) => localStorage.setItem(key, value),
|
||||
removeItem: key => localStorage.removeItem(key),
|
||||
key: index => localStorage.key(index),
|
||||
get length() { return localStorage.length; },
|
||||
};
|
||||
/** A chat as the page works with it: a reply that was streaming when the page went away is marked interrupted, not left running. */
|
||||
function fixSession(session: Session): Session {
|
||||
return {
|
||||
...session,
|
||||
...(session.showcase && !session.showcaseAgentStates ? {agents:SHOWCASE_AGENTS,showcaseAgentStates:true}:{}),
|
||||
...(!session.showcase && session.agents ? { agents: settleAgents(session.agents) } : {}),
|
||||
messages: (session.messages ?? []).map(message => message.streaming ? {
|
||||
...message,
|
||||
streaming: false,
|
||||
error: message.error ?? 'Response interrupted. Ask again to retry; the partial answer is kept.',
|
||||
} : message),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The chats shown before the host has answered. Once the browser's chats were handed to the host (the flag) the host's files are the only
|
||||
* truth and the page starts empty until it has read them (the boot screen is up until then); before that, and when the host cannot be
|
||||
* asked, the browser's old copy is shown. It is only ever read.
|
||||
*/
|
||||
function loadSessions(): Session[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as Session[];
|
||||
return Array.isArray(parsed) ? parsed.map(session => ({
|
||||
...session,
|
||||
...(session.showcase && !session.showcaseAgentStates ? {agents:SHOWCASE_AGENTS,showcaseAgentStates:true}:{}),
|
||||
...(!session.showcase && session.agents ? { agents: settleAgents(session.agents) } : {}),
|
||||
messages: session.messages.map(message => message.streaming ? {
|
||||
...message,
|
||||
streaming: false,
|
||||
error: message.error ?? 'Response interrupted. Ask again to retry; the partial answer is kept.',
|
||||
} : message),
|
||||
})) : [];
|
||||
if (browserStorage.getItem(HOST_FLAG_KEY) === '1') return [];
|
||||
return readBrowserSessions(browserStorage).sessions.map(fixSession);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -308,6 +332,7 @@ let state: AppState = {
|
||||
activeWorkspace: null,
|
||||
setupOpen: false,
|
||||
sessions: loadSessions(),
|
||||
sessionSave: null,
|
||||
activeSession: null,
|
||||
streaming: false,
|
||||
liveChats: 0,
|
||||
@@ -417,11 +442,11 @@ export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
const toastTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const TOAST_MS = 5200;
|
||||
|
||||
export function pushToast(tone: Toast['tone'], title: string, body?: string) {
|
||||
export function pushToast(tone: Toast['tone'], title: string, body?: string, action?: Toast['action']): string {
|
||||
const id = uid('toast');
|
||||
// Cap the stack: past three, the oldest collapses rather than pushing the
|
||||
// rest off-screen. A toast nobody can read is not a notification.
|
||||
const next = [...state.toasts, { id, tone, title, body }];
|
||||
const next = [...state.toasts, { id, tone, title, body, ...(action ? { action } : {}) }];
|
||||
const overflow = next.length > 3 ? next.slice(0, next.length - 3) : [];
|
||||
for (const gone of overflow) {
|
||||
const t = toastTimers.get(gone.id);
|
||||
@@ -430,7 +455,8 @@ export function pushToast(tone: Toast['tone'], title: string, body?: string) {
|
||||
}
|
||||
const kept = overflow.length ? next.slice(-3) : next;
|
||||
set({ toasts: kept });
|
||||
toastTimers.set(id, setTimeout(() => dismissToast(id), TOAST_MS));
|
||||
if (!action) toastTimers.set(id, setTimeout(() => dismissToast(id), TOAST_MS));
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -838,6 +864,10 @@ function applyEvent(e: StreamEvent) {
|
||||
else if (t.status === 'done') { pushToast('success', 'Task finished', String(t.title ?? '')); workFinished(state.doneSound, Infinity); }
|
||||
break;
|
||||
}
|
||||
case 'chats':
|
||||
// Another window saved or removed a chat (this window's own saves carry its name and are ignored).
|
||||
if (typeof data.id === 'string' && data.from !== WINDOW_ID) void applyChatChange(data.id, typeof data.rev === 'number' ? data.rev : null, data.deleted === true);
|
||||
break;
|
||||
case 'agent':
|
||||
if (typeof data.sessionId === 'string') patchSession(data.sessionId, s => ({ ...s, agents: updateAgent(s.agents ?? [], data.agent) }));
|
||||
break;
|
||||
@@ -846,40 +876,73 @@ function applyEvent(e: StreamEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** QAGUI 0213: how long one start-up GET may take, how often it is tried, and the pause between tries (a test may shorten them). */
|
||||
export const bootstrapTiming = { timeoutMs: 8000, tries: 3, backoffMs: 400 };
|
||||
|
||||
/** One GET with a time limit, tried again after a timeout, a lost connection or a host error. A refusal (4xx) is final. */
|
||||
export async function getWithRetry<T>(path: string): Promise<T> {
|
||||
const { timeoutMs, tries, backoffMs } = bootstrapTiming;
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < tries; attempt++) {
|
||||
if (attempt) await new Promise(r => setTimeout(r, backoffMs * 2 ** (attempt - 1)));
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
api.get<T>(path, abort.signal),
|
||||
new Promise<never>((_, reject) => { timer = setTimeout(() => { abort.abort(); reject(new Error(`No answer from ${path}`)); }, timeoutMs); }),
|
||||
]);
|
||||
} catch (err) {
|
||||
last = err;
|
||||
if (err instanceof ApiError && err.status >= 400 && err.status < 500) throw err;
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
let closeEvents: (() => void) | null = null;
|
||||
export async function bootstrap() {
|
||||
applyPrefs();
|
||||
try {
|
||||
const [setup, hardware, library, engine, serving, monitor, skills, providers, imageModels, runtime] = await Promise.all([
|
||||
api.get<SetupState>('/api/setup'),
|
||||
api.get<Hardware>('/api/hardware'),
|
||||
api.get<ModelLibrary>('/api/models'),
|
||||
api.get<EngineConfig>('/api/engine'),
|
||||
api.get<ServingState>('/api/serving'),
|
||||
api.get<MonitorState>('/api/monitor'),
|
||||
api.get<Skill[]>('/api/skills'),
|
||||
api.get<ModelProviders>('/api/model-providers'),
|
||||
api.get<ImageModelLibrary>('/api/image-models'),
|
||||
api.get<RuntimeModels>('/api/runtime'),
|
||||
let chatsTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
await Promise.race([
|
||||
loadChatsFromHost(),
|
||||
new Promise<never>((_, reject) => { chatsTimer = setTimeout(() => reject(new Error('The chats did not load in time')), bootstrapTiming.timeoutMs); }),
|
||||
]).catch(() => { sessionWriter.enable(); }).finally(() => clearTimeout(chatsTimer));
|
||||
// Only what the first screen cannot do without holds up 'live'; the rest is read at the same time and fills in when it arrives.
|
||||
const soft = <T,>(path: string) => getWithRetry<T>(path).catch(() => null);
|
||||
const late = {
|
||||
hardware: soft<Hardware>('/api/hardware'),
|
||||
monitor: soft<MonitorState>('/api/monitor'),
|
||||
skills: soft<Skill[]>('/api/skills'),
|
||||
providers: soft<ModelProviders>('/api/model-providers'),
|
||||
imageModels: soft<ImageModelLibrary>('/api/image-models'),
|
||||
runtime: soft<RuntimeModels>('/api/runtime'),
|
||||
};
|
||||
const [setup, library, engine, serving, workspaceState] = await Promise.all([
|
||||
getWithRetry<SetupState>('/api/setup'),
|
||||
getWithRetry<ModelLibrary>('/api/models'),
|
||||
getWithRetry<EngineConfig>('/api/engine'),
|
||||
getWithRetry<ServingState>('/api/serving'),
|
||||
getWithRetry<{ workspaces: Workspace[] }>('/api/workspaces'),
|
||||
]);
|
||||
const workspaceState = await api.get<{ workspaces: Workspace[] }>('/api/workspaces');
|
||||
set({
|
||||
ready: true,
|
||||
setup,
|
||||
hardware,
|
||||
library: libraryResponse(library),
|
||||
imageModels,
|
||||
runtime,
|
||||
engine,
|
||||
serving: newestServingState(state.serving, serving),
|
||||
monitor,
|
||||
skills,
|
||||
workspacePath: workspaceState.workspaces[0]?.path ?? '',
|
||||
workspaces: workspaceState.workspaces,
|
||||
activeWorkspace: workspaceState.workspaces.some(w => w.id === state.activeWorkspace) ? state.activeWorkspace : workspaceIdForSession(workspaceState.workspaces, state.sessions.find(s => s.id === state.activeSession)) ?? workspaceState.workspaces[0]?.id ?? null,
|
||||
connection: 'live',
|
||||
});
|
||||
applyProviders(providers);
|
||||
void late.hardware.then(hardware => { if (hardware) set({ hardware }); });
|
||||
void late.monitor.then(monitor => { if (monitor) set({ monitor }); });
|
||||
void late.skills.then(skills => { if (skills) set({ skills }); });
|
||||
void late.providers.then(providers => { if (providers) applyProviders(providers); });
|
||||
void late.imageModels.then(imageModels => { if (imageModels) set({ imageModels }); });
|
||||
void late.runtime.then(runtime => { if (runtime) set({ runtime }); });
|
||||
if (!shellAnnounced) { shellAnnounced = true; void announcePortNotice(); rememberShellLanguage(state.language); }
|
||||
closeEvents?.();
|
||||
let reconnect = false;
|
||||
@@ -1165,33 +1228,125 @@ export async function scopeSkill(id: string, workspaceId: string | null) {
|
||||
|
||||
/* ---------------------------------------------------------------- sessions */
|
||||
|
||||
const chatTransport = createHttpTransport();
|
||||
let saveToast: string | null = null;
|
||||
|
||||
const sessionWriter = createSessionWriter({
|
||||
write: (json) => {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, json);
|
||||
} catch {
|
||||
/* quota — sessions are a cache, not the source of truth */
|
||||
transport: chatTransport,
|
||||
enabled: false, // on once the host's chats have been read (loadChatsFromHost), so an older copy here never replaces a newer file
|
||||
// Nothing is trimmed to fit (#5192). A chat the host cannot write keeps its last file and stays whole here; it is reported and
|
||||
// tried again. A host that does not answer is retried quietly: that is the app being down, not a full disk.
|
||||
onResult: (result: SaveResult) => {
|
||||
if (result.ok) {
|
||||
try { browserStorage.removeItem(PENDING_KEY); } catch { /* removed at the next start */ }
|
||||
if (state.sessionSave) set({ sessionSave: null });
|
||||
if (saveToast) { dismissToast(saveToast); saveToast = null; }
|
||||
return;
|
||||
}
|
||||
const real = result.failed.filter(f => f.reason !== 'offline');
|
||||
if (!real.length) return;
|
||||
const failedIds = sessionWriter.unsaved().map(s => s.id);
|
||||
const reason = real[0].reason === 'offline' ? 'other' : real[0].reason;
|
||||
const was = state.sessionSave;
|
||||
if (!was || was.reason !== reason || was.failedIds.join() !== failedIds.join()) set({ sessionSave: { failedIds, reason } });
|
||||
if (!was && !saveToast) {
|
||||
saveToast = pushToast('error', 'Some chats could not be saved', tr("Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.", [failedIds.length]), {
|
||||
label: 'Copy unsaved chats',
|
||||
run: () => { const text = unsavedSessionsJson(); if (text) void copyText(text, '', { toast: (tone, title, body) => { pushToast(tone, title, body || undefined); } }); },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** The chats the host could not write, as one JSON text (to keep a copy by hand), or null when all are saved. */
|
||||
export function unsavedSessionsJson(): string | null {
|
||||
const unsaved = sessionWriter.unsaved();
|
||||
return unsaved.length ? exportSessions(unsaved) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the chat list. A one-off change (new, rename, delete, pin) is written now; `soon` is for the streaming
|
||||
* Save the chat list. A one-off change (new, rename, delete, pin) is sent now; `soon` is for the streaming
|
||||
* path, where a write per token grew the renderer to gigabytes (#3604, #3603): those are coalesced to one a second.
|
||||
*/
|
||||
function persistSessions(sessions: Session[], soon = false) {
|
||||
sessionWriter.schedule(sessions);
|
||||
if (!soon) sessionWriter.flush();
|
||||
if (!soon) void sessionWriter.flush();
|
||||
}
|
||||
|
||||
/** Write the newest chat list now: a turn ended, or the page is going away. */
|
||||
export function flushSessions() {
|
||||
sessionWriter.flush();
|
||||
/** Send the newest chat list now: a turn ended, or the page is going away. */
|
||||
export function flushSessions(): Promise<SaveResult> {
|
||||
return sessionWriter.flush();
|
||||
}
|
||||
|
||||
/** The page is going away: send what can still be sent, and keep a last-moment copy of what the host has not confirmed (taken back at the next start). */
|
||||
function leavingPage() {
|
||||
void sessionWriter.flush();
|
||||
const unconfirmed = sessionWriter.notOnHost();
|
||||
if (unconfirmed.length) keepLastMomentCopy(browserStorage, unconfirmed);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined' && window.addEventListener) {
|
||||
window.addEventListener('pagehide', flushSessions);
|
||||
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') flushSessions(); });
|
||||
window.addEventListener('pagehide', leavingPage);
|
||||
document.addEventListener?.('visibilitychange', () => { if (document.visibilityState === 'hidden') leavingPage(); });
|
||||
}
|
||||
|
||||
let chatsRead = false;
|
||||
/**
|
||||
* The first read of the chats at start, and again after the host came back: the host's files become the chats shown (the browser's
|
||||
* old chats are handed over first, once), then saving starts. A host that does not answer leaves the page as it is.
|
||||
*/
|
||||
export async function loadChatsFromHost() {
|
||||
if (chatsRead) { await syncChatsFromHost(); return; }
|
||||
const result = await hydrateChats({
|
||||
transport: chatTransport, browser: browserStorage, current: state.sessions,
|
||||
isLive: id => liveTurns.has(id),
|
||||
});
|
||||
if (result.offline) {
|
||||
// Show the browser's old copy so the person can still read; saves are retried until the host answers.
|
||||
if (!state.sessions.length) { try { set({ sessions: readBrowserSessions(browserStorage).sessions.map(fixSession) }); } catch { /* none */ } }
|
||||
for (const s of state.sessions) sessionWriter.adopt(s, null);
|
||||
sessionWriter.enable();
|
||||
return;
|
||||
}
|
||||
chatsRead = true;
|
||||
const sessions = result.sessions.map(fixSession);
|
||||
for (const s of sessions) { const rev = result.revs.get(s.id); if (rev !== undefined && !result.recovered.includes(s.id)) sessionWriter.adopt(s, rev); }
|
||||
set({ sessions });
|
||||
// Whatever the host does not have yet (a chat it refused during the move, a last-moment copy) is sent now; a refusal raises the notice.
|
||||
sessionWriter.schedule(sessions);
|
||||
sessionWriter.enable();
|
||||
void resumeSessions();
|
||||
}
|
||||
|
||||
/** One chat that another window saved or removed: taken over unless this window is working in it. */
|
||||
export async function applyChatChange(id: string, rev: number | null, deleted: boolean) {
|
||||
const here = state.sessions.find(s => s.id === id);
|
||||
if (deleted) {
|
||||
if (here && !liveTurns.has(id) && !sessionWriter.dirty(here)) {
|
||||
sessionWriter.forget(id);
|
||||
set({ sessions: state.sessions.filter(s => s.id !== id), ...(state.activeSession === id ? { activeSession: null } : {}) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (rev !== null && sessionWriter.rev(id) >= rev) return;
|
||||
if (here && (liveTurns.has(id) || sessionWriter.dirty(here))) return; // this window's own work wins; its next save keeps the displaced version on the host
|
||||
const got = await chatTransport.get(id).catch(() => null);
|
||||
if (!got) return;
|
||||
const fresh = state.sessions.find(s => s.id === id);
|
||||
if (fresh && (liveTurns.has(id) || sessionWriter.dirty(fresh))) return;
|
||||
sessionWriter.adopt(got.session, got.rev);
|
||||
const next = fresh ? state.sessions.map(s => (s.id === id ? got.session : s)) : [...state.sessions, got.session].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
set({ sessions: next });
|
||||
}
|
||||
|
||||
/** After the host was away: take over what other windows saved or removed meanwhile. */
|
||||
async function syncChatsFromHost() {
|
||||
const list = await chatTransport.list().catch(() => null);
|
||||
if (!list) return;
|
||||
const onHost = new Set(list.chats.map(c => c.id));
|
||||
for (const entry of list.chats) await applyChatChange(entry.id, entry.rev, false);
|
||||
for (const s of state.sessions) if (!onHost.has(s.id) && sessionWriter.rev(s.id) > 0) await applyChatChange(s.id, null, true);
|
||||
sessionWriter.enable();
|
||||
}
|
||||
|
||||
export function newSession(): Session {
|
||||
@@ -1397,7 +1552,7 @@ async function startTimeSkipReal(prompt: string) {
|
||||
const at = new Date().toISOString();
|
||||
patchSession(sessionId, s => ({ ...s,
|
||||
defaultTitle: false,
|
||||
title: s.messages.length ? s.title : prompt.length > 48 ? prompt.slice(0, 47).trimEnd() + '…' : prompt, updatedAt: at,
|
||||
title: s.messages.length ? s.title : shortTitle(prompt), updatedAt: at,
|
||||
// The comparison sidebar opens only when there is an update to compare (when the job ends with changed files), never while it works.
|
||||
timeSkips: [...(s.timeSkips ?? []), run],
|
||||
messages: [...s.messages,
|
||||
@@ -1490,7 +1645,7 @@ function startTimeSkipDemo(prompt: string) {
|
||||
const at = new Date().toISOString();
|
||||
patchSession(sessionId, s => ({ ...s,
|
||||
defaultTitle: false,
|
||||
title: s.messages.length ? s.title : prompt.length>48?prompt.slice(0,47).trimEnd()+'…':prompt, updatedAt: at,
|
||||
title: s.messages.length ? s.title : shortTitle(prompt), updatedAt: at,
|
||||
timeSkips: [...(s.timeSkips ?? []), run], previewOpen: timeSkipTabId(run.id),
|
||||
messages: [...s.messages,
|
||||
{ id: uid('m'), role: 'user', at, content: `/time-skip ${prompt}`, timeSkipId: run.id },
|
||||
@@ -1695,7 +1850,7 @@ const lastAnswer = (sessionId: string) => [...(state.sessions.find(s => s.id ===
|
||||
const loopScheduler = new LoopScheduler({
|
||||
async send(sessionId, { text, shown }) {
|
||||
const before = lastAnswer(sessionId)?.id;
|
||||
await sendMessage(text, { fromLoop: true, shown, title: shown.length > 48 ? shown.slice(0, 47).trimEnd() + '…' : shown });
|
||||
await sendMessage(text, { fromLoop: true, shown, title: shortTitle(shown) });
|
||||
const answer = lastAnswer(sessionId);
|
||||
if (!answer || answer.id === before || answer.streaming) return { answered: false, text: '', failed: false };
|
||||
return { answered: true, text: answer.content ?? '', failed: !!answer.error };
|
||||
@@ -1779,8 +1934,10 @@ export async function approvePlan(sessionId: string, planId: string) {
|
||||
/** "Start as goal" (and Resume on a paused plan): the host streams the run like any chat turn. */
|
||||
export async function startPlanGoal(planId: string) { await sendMessage('', { planId }); }
|
||||
|
||||
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume'; planId?: string; shown?: string; title?: string; markdown?: boolean } = {}) {
|
||||
if (state.streaming) {
|
||||
export async function sendMessage(text: string, options: { fromLoop?: boolean; goalAction?: 'resume'; planId?: string; shown?: string; title?: string; markdown?: boolean; reattach?: string; requestId?: string } = {}) {
|
||||
// `reattach`: a turn the host is still running for this chat (or finished while the page was away) is taken over again after a reload, not started.
|
||||
const reattach = options.reattach;
|
||||
if (state.streaming && !reattach) {
|
||||
// Typed while the assistant works: behind a running goal it waits in the queue; otherwise there is nothing to do but wait.
|
||||
const waiting = state.activeSession, line = text.trim();
|
||||
if (waiting && line && !options.planId && !options.goalAction) {
|
||||
@@ -1791,9 +1948,9 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
}
|
||||
const resume = options.goalAction === 'resume';
|
||||
const planStart = options.planId;
|
||||
const loopCommand = options.fromLoop || resume || planStart ? null : parseLoop(text.trim());
|
||||
const loopCommand = options.fromLoop || resume || planStart || reattach ? null : parseLoop(text.trim());
|
||||
if (loopCommand) { await runLoopCommand(loopCommand); return; }
|
||||
const demoPrompt = resume || planStart ? null : timeSkipPrompt(text.trim());
|
||||
const demoPrompt = resume || planStart || reattach ? null : timeSkipPrompt(text.trim());
|
||||
if (demoPrompt !== null) {
|
||||
if (demoPrompt && (state.remoteModel || nativeState()?.instances.some(i => i.status === 'ready'))) void startTimeSkipReal(demoPrompt); else if (state.connection === 'live' && demoPrompt) pushToast('info', 'Load a model first', 'Time-skip works on a loaded model. Choose one on the Models tab, then try again.');
|
||||
else if (state.connection === 'connecting' && demoPrompt) pushToast('info', 'Still starting up', 'Time-skip needs the app to finish starting. Try again in a moment.');
|
||||
@@ -1801,23 +1958,26 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
return;
|
||||
}
|
||||
// Whatever sent this (the box, a loop, a plan): with no model to answer, say so in plain words and start nothing.
|
||||
if (state.connection === 'live') {
|
||||
if (state.connection === 'live' && !reattach) {
|
||||
// The first read of the engine's state has not come back yet: do not say "no model" about something not known yet, and do not send.
|
||||
if (!nativeState() && !state.remoteModel && !state.library?.loaded) { pushToast('info', 'Still starting up', 'Strata is still checking your models. Try again in a moment.'); return; }
|
||||
const why = noModelNotice(nativeState(), { remote: !!state.remoteModel, libraryLoaded: !!state.library?.loaded });
|
||||
if (why) { pushToast('info', why.title, why.message); return; }
|
||||
}
|
||||
const pictureCheck=pictureStatus(nativeState(),state.remoteModel,state.providers);
|
||||
if(pendingChatPhotos(state.activeSession).length&&!pictureCheck.ok){pushToast('error','This model cannot read pictures',tr(pictureCheck.message));return;}
|
||||
const pictures=takeChatPhotos(state.activeSession);
|
||||
if(!reattach&&pendingChatPhotos(state.activeSession).length&&!pictureCheck.ok){pushToast('error','This model cannot read pictures',tr(pictureCheck.message));return;}
|
||||
const pictures=reattach?[]:takeChatPhotos(state.activeSession);
|
||||
const body=text.trim()||(pictures.length?'Describe this picture.':'');
|
||||
if(!body&&!resume&&!planStart)return;
|
||||
if(!body&&!resume&&!planStart&&!reattach)return;
|
||||
|
||||
let sessionId = state.activeSession;
|
||||
let sessionId = reattach ?? state.activeSession;
|
||||
if (!sessionId) sessionId = newSession().id;
|
||||
|
||||
const userMsg: ChatMessage = { id: uid('m'), role: 'user', content: options.shown ?? body, at: new Date().toISOString(),...(pictures.length?{photos:pictures}:{}),...(options.markdown?{markdown:true}:{}) };
|
||||
let assistantId = uid('m');
|
||||
// A taken-over turn goes on in the reply the page already has (the last one, if it is the assistant's).
|
||||
const lastReply = reattach ? [...(state.sessions.find(s => s.id === sessionId)?.messages ?? [])].reverse()[0] : undefined;
|
||||
const resumedReply = lastReply?.role === 'assistant' ? lastReply : undefined;
|
||||
let assistantId = resumedReply?.id ?? uid('m');
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
@@ -1845,10 +2005,15 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
// Bounded so a long conversation cannot send an unbounded body.
|
||||
.slice(-40);
|
||||
|
||||
patchSession(sessionId, (s) => ({
|
||||
patchSession(sessionId, (s) => reattach ? ({
|
||||
...s,
|
||||
messages: resumedReply
|
||||
? s.messages.map(m => m.id === resumedReply.id ? { ...m, streaming: true } : m)
|
||||
: [...s.messages, assistantMsg],
|
||||
}) : ({
|
||||
...s,
|
||||
defaultTitle: false,
|
||||
title: s.messages.length === 0 && !resume && !planStart ? (options.title ?? (body.length>48?body.slice(0,47).trimEnd()+'…':body)) : s.title,
|
||||
title: s.messages.length === 0 && !resume && !planStart ? (options.title ?? (shortTitle(body))) : s.title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: [...s.messages, ...(resume || planStart ? [] : [userMsg]), assistantMsg],
|
||||
}));
|
||||
@@ -1887,8 +2052,8 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
};
|
||||
try {
|
||||
await streamPost(
|
||||
planStart ? '/api/plan/start-goal' : '/api/chat/stream',
|
||||
{
|
||||
reattach ? '/api/chat/attach' : planStart ? '/api/plan/start-goal' : '/api/chat/stream',
|
||||
reattach ? { sessionId, ...(options.requestId ? { requestId: options.requestId } : {}) } : {
|
||||
sessionId,
|
||||
...(planStart ? { planId: planStart } : {}),
|
||||
...(resume ? { goalAction: 'resume' } : planStart ? {} : { message: options.shown ? body : body }),
|
||||
@@ -1914,10 +2079,21 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
if (type === 'delta' || type === 'reasoning') { queueText(type, String(frame.text ?? '')); return; }
|
||||
flushWaiting();
|
||||
if (type === 'start' && frame.agent === true) { const turn = liveTurns.get(sessionId); if (turn) turn.agent = true; }
|
||||
// The host has a turn to take over: the failure the page wrote when it lost the stream no longer applies.
|
||||
if (type === 'start' && frame.resumed === true) patchAssistant(m => ({ ...m, error: undefined, errorCode: undefined }));
|
||||
if (type === 'queued') {
|
||||
patchAssistant(m => ({ ...m, requestId: String(frame.requestId) }));
|
||||
} else if (type === 'start') {
|
||||
patchAssistant((m) => ({ ...m, demo: frame.demo === true, remoteModel: frame.remoteModel === true, requestId: typeof frame.requestId === 'string' ? frame.requestId : m.requestId, model: String(frame.model), contextMax: Number(frame.contextMax), skillsUsed: Array.isArray(frame.skills) ? frame.skills.map(String) : [] }));
|
||||
} else if (type === 'resync') {
|
||||
// A taken-over turn: what was said while no page was here. The reply continues from what the page had; where the two disagree the host's text stands.
|
||||
const said = String(frame.content ?? ''), thought = String(frame.reasoning ?? '');
|
||||
patchAssistant(m => {
|
||||
let next = m;
|
||||
if (said) next = said.startsWith(next.content) ? { ...next, content: tidyAnswer(said), ...(next.blocks && said.length > next.content.length ? { blocks: appendText(next.blocks, said.slice(next.content.length)) } : {}) } : { ...next, content: tidyAnswer(said), blocks: undefined };
|
||||
if (thought && thought.length >= (next.reasoning ?? '').length) next = { ...next, reasoning: thought, reasoningStartedAt: next.reasoningStartedAt ?? Date.now() };
|
||||
return next;
|
||||
});
|
||||
} else if (type === 'status') {
|
||||
applyStatusFrame(sessionId, frame as Record<string, unknown>);
|
||||
// Manual mode waits for the person: a notification when they have opted in and Strata is in the background (#3477), once per question.
|
||||
@@ -2042,7 +2218,10 @@ export async function sendMessage(text: string, options: { fromLoop?: boolean; g
|
||||
);
|
||||
} catch (err) {
|
||||
flushWaiting();
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
if (reattach && (err as { status?: number } | null)?.status === 404) {
|
||||
// Nothing to take over (the turn ended and the host no longer holds it): the reply stays as the page had it.
|
||||
patchAssistant((m) => ({ ...m, streaming: false }));
|
||||
} else if ((err as Error).name !== 'AbortError') {
|
||||
patchAssistant((m) => ({
|
||||
...m,
|
||||
streaming: false,
|
||||
@@ -2136,6 +2315,18 @@ export function go(route: Route, param?: string) {
|
||||
export function bootOnce() {
|
||||
if ((globalThis as { __strataBooted?: boolean }).__strataBooted) return;
|
||||
(globalThis as { __strataBooted?: boolean }).__strataBooted = true;
|
||||
void bootstrap();
|
||||
}
|
||||
|
||||
let sessionsResumed = false;
|
||||
/**
|
||||
* Once, after the chats were first read from the host: what a chat can still have going on. Time-skip runs are followed again, a loop the last
|
||||
* window left "running" has ended, a turn the host is still running is taken over (or one that ended while the page was away is shown whole),
|
||||
* and tool cards the browser remembers as waiting for a turn that no longer exists are marked interrupted.
|
||||
*/
|
||||
async function resumeSessions() {
|
||||
if (sessionsResumed) return;
|
||||
sessionsResumed = true;
|
||||
for (const session of state.sessions) for (const run of session.timeSkips ?? []) { scheduleTimeSkip(session.id, run); if (run.job && !run.completedAt) { markTurn(session.id, { abort: new AbortController(), agent: false }); void followTimeSkip(session.id, run.id); } }
|
||||
// A loop lives in the window: one the last window left "running" has ended, and the board says so.
|
||||
if (state.sessions.some(s => s.loop?.state === 'running')) {
|
||||
@@ -2143,10 +2334,28 @@ export function bootOnce() {
|
||||
persistSessions(sessions);
|
||||
set({ sessions });
|
||||
}
|
||||
void bootstrap();
|
||||
await takeOverTurns();
|
||||
void reconcileAgentTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* A reload does not stop an agent turn: the host keeps it going. The page asks which chats have a turn running and takes each one over again
|
||||
* (the catch-up, then the rest as it is written, the Stop button and the approvals waiting). A chat whose last reply was cut off by the page
|
||||
* going away is asked about too: if its turn ended meanwhile, the host still holds the end of it and shows it whole; if not, nothing changes.
|
||||
*/
|
||||
async function takeOverTurns() {
|
||||
let running: { chatId: string }[] = [];
|
||||
try { running = (await api.get<{ turns: { chatId: string }[] }>('/api/chat/active')).turns; } catch { /* an older host, or none reachable: nothing to take over */ }
|
||||
const live = new Set(running.map(t => t.chatId));
|
||||
for (const id of live) if (state.sessions.some(s => s.id === id) && !liveTurns.has(id)) void sendMessage('', { reattach: id });
|
||||
// Cut-off replies of chats with no turn running: the most recent few, asked once each.
|
||||
const cut = state.sessions.filter(s => !live.has(s.id) && !liveTurns.has(s.id)).slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)).slice(0, 12).flatMap(s => {
|
||||
const last = s.messages[s.messages.length - 1];
|
||||
return last?.role === 'assistant' && last.requestId && last.usageSource === undefined && !last.local ? [{ id: s.id, requestId: last.requestId }] : [];
|
||||
});
|
||||
await Promise.allSettled(cut.map(c => sendMessage('', { reattach: c.id, requestId: c.requestId })));
|
||||
}
|
||||
|
||||
/**
|
||||
* After a reload, a tool card the browser remembers as waiting or running may belong to a turn that no
|
||||
* longer exists. Cards whose approval the host still holds stay answerable; the rest are marked as
|
||||
@@ -2155,6 +2364,7 @@ export function bootOnce() {
|
||||
async function reconcileAgentTools() {
|
||||
const open = (t: ToolCall) => t.live && (t.state === 'proposed' || t.state === 'running');
|
||||
for (const session of state.sessions) {
|
||||
if (liveTurns.has(session.id)) continue; // a turn taken over again reports its own cards
|
||||
if (!session.messages.some(m => m.tools?.some(open))) continue;
|
||||
let waiting = new Set<string>();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* A chat title cut from the first prompt without cutting a character in half (#4533).
|
||||
*
|
||||
* `prompt.slice(0, 47)` counts UTF-16 units, so an emoji (two units), a flag (four), a family or a skin-tone sequence (up to eleven) that
|
||||
* straddles the cut leaves half a character: a lone surrogate that draws as a replacement box, or a ZWJ with nothing after it. The title is
|
||||
* stored and restored as it is, so the broken tail comes back on every start and goes through the font fallback that WebKitGTK crashes in.
|
||||
* This cuts at a user-perceived character (grapheme) when the engine has Intl.Segmenter, and at a code point when it has not.
|
||||
*
|
||||
* The three `prompt.length > 48 ? prompt.slice(0, 47).trimEnd() + '…' : prompt` places in lib/store.ts should call `shortTitle(prompt)`.
|
||||
*/
|
||||
const WIDTH = 48;
|
||||
|
||||
export function shortTitle(text: string, width = WIDTH): string {
|
||||
if (text.length <= width)
|
||||
return text;
|
||||
const room = width - 1; // the ellipsis takes one unit
|
||||
let end = 0;
|
||||
const Segmenter = (Intl as unknown as { Segmenter?: new (locale?: string, options?: { granularity: string }) => { segment(input: string): Iterable<{ segment: string; index: number }> } }).Segmenter;
|
||||
if (Segmenter) {
|
||||
for (const part of new Segmenter(undefined, { granularity: 'grapheme' }).segment(text)) {
|
||||
if (part.index + part.segment.length > room)
|
||||
break;
|
||||
end = part.index + part.segment.length;
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const ch of text) { // iterates by code point
|
||||
if (end + ch.length > room)
|
||||
break;
|
||||
end += ch.length;
|
||||
}
|
||||
}
|
||||
return text.slice(0, end).trimEnd() + '…';
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata ist nicht mit seiner Engine verbunden.",
|
||||
"s1762": "Strata-optimierte Modelle",
|
||||
"s1763": "Strata bereitet alle Teildateien, die Entwurfsschicht und gemeinsame Dateien vor. Abgeschlossene Downloads werden wiederverwendet.",
|
||||
"s1764": "Strata sendet keine Nutzungstelemetrie. Gespräche bleiben im Browserspeicher. Wenn du ein API-Modell auswählst, werden das Gespräch und die Anweisungen aktivierter Skills an dessen Anbieter gesendet. Das UI-Anfrageprotokoll hält Client-, Zeit- und Token-Metadaten im Arbeitsspeicher. Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten. Verbundene Tools greifen auf die von dir eingerichteten Ressourcen zu.",
|
||||
"s1764": "Strata sendet keine Nutzungstelemetrie. Gespräche bleiben auf diesem Computer, im Datenordner von Strata. Wenn du ein API-Modell auswählst, werden das Gespräch und die Anweisungen aktivierter Skills an dessen Anbieter gesendet. Das UI-Anfrageprotokoll hält Client-, Zeit- und Token-Metadaten im Arbeitsspeicher. Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten. Verbundene Tools greifen auf die von dir eingerichteten Ressourcen zu.",
|
||||
"s1765": "Strata verwendet diese Dateien an ihrem Speicherort. Nach dem Hinzufügen kannst du das Modell laden.",
|
||||
"s1766": "Strata — Chat öffnen",
|
||||
"s1767": "Stratas Budget",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "Protokolle anzeigen",
|
||||
"s3043": "Engine-Protokolle",
|
||||
"s3044": "Strata sendet keine Nutzungstelemetrie.",
|
||||
"s3045": "Unterhaltungen bleiben im Browser-Speicher.",
|
||||
"s3045": "Unterhaltungen bleiben auf diesem Computer, im Datenordner von Strata.",
|
||||
"s3046": "Wenn Sie ein API-Modell wählen, werden diese Unterhaltung und die aktivierten Skill-Anweisungen an den Anbieter gesendet.",
|
||||
"s3047": "Das UI-Anfrageprotokoll behält Client-, Zeit- und Token-Metadaten nur im Arbeitsspeicher.",
|
||||
"s3048": "Die native API-Überwachung kann bei Aktivierung Anfrageinhalte im Arbeitsspeicher behalten.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Das Modell wird geladen …",
|
||||
"s9035": "Diese Modelldateien fehlen: {0}.",
|
||||
"s9036": "Drücken Sie noch einmal auf „Herunterladen und laden“, um sie zu holen. Vorhandene Dateien bleiben erhalten. Werden dieselben Dateien wieder aufgelistet, öffnen Sie die Details und teilen Sie sie mit, wenn Sie um Hilfe bitten.",
|
||||
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun."
|
||||
"s9037": "Dieses Update wurde beim Neustart der App unterbrochen. Ein späteres Update ist abgeschlossen, Sie müssen nichts tun.",
|
||||
"s9038": "Dieses System erlaubt Strata nicht, Port {0} zu verwenden. Unter Linux und macOS brauchen Ports unter 1024 Administratorrechte. Starten Sie Strata mit einem anderen Port (STRATA_PORT), zum Beispiel 8801."
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "Der DFlash2-Entwurfsbaustein konnte nicht heruntergeladen werden, deshalb startet das Modell ohne Entwurfsbaustein. Das Modell wird geladen …",
|
||||
"df15": "MTP-Entwurfsbaustein wird heruntergeladen …",
|
||||
"df16": "Der MTP-Entwurfsbaustein konnte nicht heruntergeladen werden.",
|
||||
"df17": "Vorausraten: MTP (DFlash2 ist nicht gestartet)"
|
||||
"df17": "Vorausraten: MTP (DFlash2 ist nicht gestartet)",
|
||||
"df18": "Automatisch nutzt die Alle-Karten-Split-Engine mit der MTP-Entwurfsschicht: etwa {0} GB, einmalig herunterzuladen.",
|
||||
"df19": "Die Alle-Karten-Split-Engine konnte nicht starten, deshalb läuft die normale Schichtaufteilung.",
|
||||
"df20": "Vorausraten: aus (den Karten fehlte Speicher)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "The DFlash2 drafter could not be downloaded, so the model starts without a drafter. Loading the model…",
|
||||
"df15": "Downloading the MTP drafter…",
|
||||
"df16": "The MTP drafter could not be downloaded.",
|
||||
"df17": "Drafting: MTP (DFlash2 didn't start)"
|
||||
"df17": "Drafting: MTP (DFlash2 didn't start)",
|
||||
"df18": "Auto uses the all-cards split engine with the MTP draft layer: about {0} GB to download, once.",
|
||||
"df19": "The all-cards split engine could not start, so the normal layer split runs.",
|
||||
"df20": "Drafting: off (the cards were short of memory)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "No se pudo descargar el predictor de DFlash2, así que el modelo arranca sin predictor. Cargando el modelo…",
|
||||
"df15": "Descargando el predictor de MTP…",
|
||||
"df16": "No se pudo descargar el predictor de MTP.",
|
||||
"df17": "Predicción: MTP (DFlash2 no arrancó)"
|
||||
"df17": "Predicción: MTP (DFlash2 no arrancó)",
|
||||
"df18": "Automático usa el motor de reparto entre todas las tarjetas con la capa de borrador MTP: unos {0} GB, una sola vez.",
|
||||
"df19": "El motor de reparto entre todas las tarjetas no pudo arrancar, así que se usa el reparto normal por capas.",
|
||||
"df20": "Predicción: desactivada (a las tarjetas les faltaba memoria)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "Le module d’anticipation DFlash2 n’a pas pu être téléchargé, le modèle démarre donc sans module d’anticipation. Chargement du modèle…",
|
||||
"df15": "Téléchargement du module d’anticipation MTP…",
|
||||
"df16": "Le module d’anticipation MTP n’a pas pu être téléchargé.",
|
||||
"df17": "Anticipation : MTP (DFlash2 n’a pas démarré)"
|
||||
"df17": "Anticipation : MTP (DFlash2 n’a pas démarré)",
|
||||
"df18": "Auto utilise le moteur réparti sur toutes les cartes avec la couche de brouillon MTP : environ {0} Go à télécharger, une seule fois.",
|
||||
"df19": "Le moteur réparti sur toutes les cartes n’a pas pu démarrer, la répartition normale par couches est donc utilisée.",
|
||||
"df20": "Anticipation : désactivée (les cartes manquaient de mémoire)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "DFlash2 のドラフトモデルをダウンロードできなかったため、ドラフトモデルなしで起動します。モデルを読み込んでいます…",
|
||||
"df15": "MTP のドラフトモデルをダウンロードしています…",
|
||||
"df16": "MTP のドラフトモデルをダウンロードできませんでした。",
|
||||
"df17": "先読み:MTP(DFlash2 は起動しませんでした)"
|
||||
"df17": "先読み:MTP(DFlash2 は起動しませんでした)",
|
||||
"df18": "自動では、全カードのスプリットエンジンと MTP ドラフト層を使います。約 {0} GB を一度だけダウンロードします。",
|
||||
"df19": "全カードのスプリットエンジンを起動できなかったため、通常のレイヤー分割で動作しています。",
|
||||
"df20": "先読み:オフ(グラフィックスカードのメモリが不足していました)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "DFlash2 초안 모델을 내려받지 못해 초안 모델 없이 시작합니다. 모델을 불러오는 중…",
|
||||
"df15": "MTP 초안 모델을 내려받는 중…",
|
||||
"df16": "MTP 초안 모델을 내려받지 못했습니다.",
|
||||
"df17": "초안 예측: MTP(DFlash2가 시작되지 않았습니다)"
|
||||
"df17": "초안 예측: MTP(DFlash2가 시작되지 않았습니다)",
|
||||
"df18": "자동은 모든 카드를 쓰는 분할 엔진과 MTP 초안 층을 사용합니다(약 {0}GB, 한 번만 내려받음).",
|
||||
"df19": "모든 카드를 쓰는 분할 엔진을 시작하지 못해 일반 레이어 분할로 실행합니다.",
|
||||
"df20": "초안 예측: 꺼짐(카드 메모리가 부족했습니다)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "Não foi possível baixar o preditor do DFlash2, então o modelo inicia sem preditor. Carregando o modelo…",
|
||||
"df15": "Baixando o preditor do MTP…",
|
||||
"df16": "Não foi possível baixar o preditor do MTP.",
|
||||
"df17": "Previsão: MTP (o DFlash2 não iniciou)"
|
||||
"df17": "Previsão: MTP (o DFlash2 não iniciou)",
|
||||
"df18": "O automático usa o motor de divisão entre todas as placas com a camada de rascunho MTP: cerca de {0} GB, uma vez.",
|
||||
"df19": "O motor de divisão entre todas as placas não conseguiu iniciar, então a divisão normal por camadas está em uso.",
|
||||
"df20": "Previsão: desligada (faltou memória nas placas)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "Не удалось скачать черновую модель DFlash2, поэтому модель запускается без черновой модели. Загружается модель…",
|
||||
"df15": "Скачивается черновая модель MTP…",
|
||||
"df16": "Не удалось скачать черновую модель MTP.",
|
||||
"df17": "Предсказание: MTP (DFlash2 не запустился)"
|
||||
"df17": "Предсказание: MTP (DFlash2 не запустился)",
|
||||
"df18": "Авто использует движок с разделением по всем картам и черновой слой MTP: около {0} ГБ, скачивается один раз.",
|
||||
"df19": "Движок с разделением по всем картам не запустился, поэтому работает обычное послойное разделение.",
|
||||
"df20": "Предсказание: выключено (на картах не хватило памяти)"
|
||||
}
|
||||
@@ -15,5 +15,8 @@
|
||||
"df14": "无法下载 DFlash2 草稿模型,因此模型将不使用草稿模型启动。正在加载模型…",
|
||||
"df15": "正在下载 MTP 草稿模型…",
|
||||
"df16": "无法下载 MTP 草稿模型。",
|
||||
"df17": "预测草稿:MTP(DFlash2 未能启动)"
|
||||
"df17": "预测草稿:MTP(DFlash2 未能启动)",
|
||||
"df18": "自动模式使用全部显卡的拆分引擎和 MTP 草稿层:约 {0} GB,只下载一次。",
|
||||
"df19": "全显卡拆分引擎未能启动,因此改用常规的分层拆分。",
|
||||
"df20": "预测草稿:关闭(显卡内存不足)"
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata isn’t connected to its engine.",
|
||||
"s1762": "Strata optimized models",
|
||||
"s1763": "Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.",
|
||||
"s1764": "Strata sends no usage telemetry. Conversations stay in browser storage. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.",
|
||||
"s1764": "Strata sends no usage telemetry. Conversations stay on this computer, in Strata's data folder. When you choose an API model, that conversation and enabled skill instructions are sent to its provider. The UI request log keeps client, timing and token metadata in memory. Native API monitoring can retain request content in memory when enabled. Connected tools access the resources you configure.",
|
||||
"s1765": "Strata uses these files in place. You can load the model after adding it.",
|
||||
"s1766": "Strata — open chat",
|
||||
"s1767": "Strata’s budget",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "View logs",
|
||||
"s3043": "Engine logs",
|
||||
"s3044": "Strata sends no usage telemetry.",
|
||||
"s3045": "Conversations stay in browser storage.",
|
||||
"s3045": "Conversations stay on this computer, in Strata's data folder.",
|
||||
"s3046": "When you choose an API model, that conversation and enabled skill instructions are sent to its provider.",
|
||||
"s3047": "The UI request log keeps client, timing and token metadata in memory.",
|
||||
"s3048": "Native API monitoring can retain request content in memory when enabled.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Loading the model…",
|
||||
"s9035": "These model files are missing: {0}.",
|
||||
"s9036": "Press Download & load again to fetch them. Files that are already there are kept. If the same files are listed again, open Details and share them when you ask for help.",
|
||||
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing."
|
||||
"s9037": "This update was cut short when the app restarted. A later update finished, so nothing needs doing.",
|
||||
"s9038": "This system does not let Strata use port {0}. On Linux and macOS, ports below 1024 need administrator rights. Start Strata with another port (STRATA_PORT), for example 8801."
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata no está conectado a su motor.",
|
||||
"s1762": "Modelos optimizados de Strata",
|
||||
"s1763": "Strata prepara todos los fragmentos, la capa de borrador y los archivos compartidos. Se reutilizan las descargas completadas.",
|
||||
"s1764": "Strata no envía telemetría de uso. Las conversaciones permanecen en el almacenamiento del navegador. Cuando eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor. El registro de solicitudes de la interfaz mantiene en memoria metadatos de clientes, tiempos y tokens. La supervisión de la API nativa puede conservar el contenido de las solicitudes en memoria cuando está activada. Las herramientas conectadas acceden a los recursos que configures.",
|
||||
"s1764": "Strata no envía telemetría de uso. Las conversaciones permanecen en este equipo, en la carpeta de datos de Strata. Cuando eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor. El registro de solicitudes de la interfaz mantiene en memoria metadatos de clientes, tiempos y tokens. La supervisión de la API nativa puede conservar el contenido de las solicitudes en memoria cuando está activada. Las herramientas conectadas acceden a los recursos que configures.",
|
||||
"s1765": "Strata usa estos archivos en su ubicación actual. Puedes cargar el modelo después de añadirlo.",
|
||||
"s1766": "Strata — abrir chat",
|
||||
"s1767": "Recursos disponibles para Strata",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "Ver registros",
|
||||
"s3043": "Registros del motor",
|
||||
"s3044": "Strata no envía telemetría de uso.",
|
||||
"s3045": "Las conversaciones se quedan en el almacenamiento del navegador.",
|
||||
"s3045": "Las conversaciones se quedan en este equipo, en la carpeta de datos de Strata.",
|
||||
"s3046": "Si eliges un modelo de API, esa conversación y las instrucciones de las habilidades activadas se envían a su proveedor.",
|
||||
"s3047": "El registro de solicitudes de la interfaz guarda en memoria metadatos de cliente, tiempo y tokens.",
|
||||
"s3048": "Si se activa, la supervisión de la API nativa puede conservar en memoria el contenido de las solicitudes.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Cargando el modelo…",
|
||||
"s9035": "Faltan estos archivos del modelo: {0}.",
|
||||
"s9036": "Pulse de nuevo «Descargar y cargar» para obtenerlos. Los archivos que ya están se conservan. Si vuelven a aparecer los mismos archivos, abra los detalles y compártalos cuando pida ayuda.",
|
||||
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer."
|
||||
"s9037": "Esta actualización se interrumpió cuando la aplicación se reinició. Una actualización posterior terminó, así que no hay nada que hacer.",
|
||||
"s9038": "Este sistema no deja que Strata use el puerto {0}. En Linux y macOS, los puertos por debajo de 1024 necesitan derechos de administrador. Inicia Strata con otro puerto (STRATA_PORT), por ejemplo 8801."
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata n’est pas connecté à son moteur.",
|
||||
"s1762": "Modèles optimisés par Strata",
|
||||
"s1763": "Strata prépare tous les fragments, la couche de brouillon et les fichiers partagés. Les téléchargements terminés sont réutilisés.",
|
||||
"s1764": "Strata n’envoie aucune télémétrie d’utilisation. Les conversations restent dans le stockage du navigateur. Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur. Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens. La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée. Les outils connectés accèdent aux ressources que vous configurez.",
|
||||
"s1764": "Strata n’envoie aucune télémétrie d’utilisation. Les conversations restent sur cet ordinateur, dans le dossier de données de Strata. Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur. Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens. La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée. Les outils connectés accèdent aux ressources que vous configurez.",
|
||||
"s1765": "Strata utilise ces fichiers sur place. Vous pourrez charger le modèle après l’avoir ajouté.",
|
||||
"s1766": "Strata — ouvrir la conversation",
|
||||
"s1767": "Budget de Strata",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "Voir les journaux",
|
||||
"s3043": "Journaux du moteur",
|
||||
"s3044": "Strata n’envoie aucune télémétrie d’utilisation.",
|
||||
"s3045": "Les conversations restent dans le stockage du navigateur.",
|
||||
"s3045": "Les conversations restent sur cet ordinateur, dans le dossier de données de Strata.",
|
||||
"s3046": "Quand vous choisissez un modèle API, cette conversation et les instructions des compétences activées sont envoyées à son fournisseur.",
|
||||
"s3047": "Le journal des requêtes de l’interface garde en mémoire le client, les durées et les métadonnées de tokens.",
|
||||
"s3048": "La surveillance de l’API native peut conserver le contenu des requêtes en mémoire lorsqu’elle est activée.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Chargement du modèle…",
|
||||
"s9035": "Ces fichiers du modèle sont manquants : {0}.",
|
||||
"s9036": "Appuyez à nouveau sur Télécharger et charger pour les récupérer. Les fichiers déjà présents sont conservés. Si les mêmes fichiers sont listés à nouveau, ouvrez Détails et partagez-les quand vous demandez de l’aide.",
|
||||
"s9037": "Cette mise à jour a été interrompue au redémarrage de l’application. Une mise à jour plus récente s’est terminée, il n’y a donc rien à faire."
|
||||
"s9037": "Cette mise à jour a été interrompue au redémarrage de l’application. Une mise à jour plus récente s’est terminée, il n’y a donc rien à faire.",
|
||||
"s9038": "Ce système ne laisse pas Strata utiliser le port {0}. Sous Linux et macOS, les ports inférieurs à 1024 nécessitent des droits d’administrateur. Lancez Strata avec un autre port (STRATA_PORT), par exemple 8801."
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strataがエンジンに接続されていません。",
|
||||
"s1762": "Strataの最適化モデル",
|
||||
"s1763": "Strataがすべてのシャード、ドラフトレイヤー、共有ファイルを準備します。ダウンロード済みのものは再利用します。",
|
||||
"s1764": "Strataは使用状況のテレメトリーを送信しません。会話はブラウザーのストレージに保存します。APIモデルを選ぶと、会話と有効なスキルの指示をプロバイダーに送信します。UIのリクエストログは、クライアント、時間、トークンのメタデータをメモリーに保持します。ネイティブAPIの監視を有効にすると、リクエストの内容をメモリーに保持する場合があります。接続ツールは設定したリソースにアクセスします。",
|
||||
"s1764": "Strataは使用状況のテレメトリーを送信しません。会話はこのコンピューター上のStrataのデータフォルダーに保存します。APIモデルを選ぶと、会話と有効なスキルの指示をプロバイダーに送信します。UIのリクエストログは、クライアント、時間、トークンのメタデータをメモリーに保持します。ネイティブAPIの監視を有効にすると、リクエストの内容をメモリーに保持する場合があります。接続ツールは設定したリソースにアクセスします。",
|
||||
"s1765": "Strataは現在の場所のファイルを使います。追加した後、モデルを読み込めます。",
|
||||
"s1766": "Strata — チャットを開く",
|
||||
"s1767": "Strataの容量枠",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "ログを見る",
|
||||
"s3043": "エンジンのログ",
|
||||
"s3044": "Strata は利用状況のテレメトリーを送信しません。",
|
||||
"s3045": "会話はブラウザーのストレージに保存されます。",
|
||||
"s3045": "会話はこのコンピューター上のStrataのデータフォルダーに保存されます。",
|
||||
"s3046": "API モデルを選ぶと、その会話と有効なスキルの指示がプロバイダーに送信されます。",
|
||||
"s3047": "UI のリクエストログは、クライアント・時間・トークンのメタデータをメモリ上にのみ保持します。",
|
||||
"s3048": "有効にすると、ネイティブ API の監視はリクエスト内容をメモリ上に保持することがあります。",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "モデルを読み込んでいます…",
|
||||
"s9035": "次のモデルファイルが見つかりません: {0}。",
|
||||
"s9036": "もう一度「ダウンロードして読み込む」を押すと取得します。すでにあるファイルはそのまま残ります。同じファイルがまた表示される場合は、詳細を開き、助けを求めるときに共有してください。",
|
||||
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。"
|
||||
"s9037": "このアップデートは、アプリの再起動で中断されました。その後のアップデートが完了しているため、対応は不要です。",
|
||||
"s9038": "このシステムでは、Strataによるポート{0}の使用が許可されていません。LinuxとmacOSでは、1024未満のポートには管理者権限が必要です。別のポート(STRATA_PORT)、たとえば8801でStrataを起動してください。"
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata가 엔진에 연결되어 있지 않습니다.",
|
||||
"s1762": "Strata 최적화 모델",
|
||||
"s1763": "Strata가 모든 분할 파일, 초안 레이어, 공유 파일을 준비합니다. 완료된 다운로드는 재사용합니다.",
|
||||
"s1764": "Strata는 사용 원격 측정 데이터를 전송하지 않습니다. 대화는 브라우저 저장 공간에 유지됩니다. API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체에 전송됩니다. UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에 보관합니다. 네이티브 API 모니터링을 활성화하면 요청 내용을 메모리에 보관할 수 있습니다. 연결된 도구는 구성한 리소스에 접근합니다.",
|
||||
"s1764": "Strata는 사용 원격 측정 데이터를 전송하지 않습니다. 대화는 이 컴퓨터의 Strata 데이터 폴더에 유지됩니다. API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체에 전송됩니다. UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에 보관합니다. 네이티브 API 모니터링을 활성화하면 요청 내용을 메모리에 보관할 수 있습니다. 연결된 도구는 구성한 리소스에 접근합니다.",
|
||||
"s1765": "Strata는 이 파일을 현재 위치에서 사용합니다. 추가한 뒤 모델을 불러올 수 있습니다.",
|
||||
"s1766": "Strata — 채팅 열기",
|
||||
"s1767": "Strata 할당량",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "로그 보기",
|
||||
"s3043": "엔진 로그",
|
||||
"s3044": "Strata는 사용 현황 텔레메트리를 전송하지 않습니다.",
|
||||
"s3045": "대화는 브라우저 저장소에 남습니다.",
|
||||
"s3045": "대화는 이 컴퓨터의 Strata 데이터 폴더에 남습니다.",
|
||||
"s3046": "API 모델을 선택하면 해당 대화와 활성화된 스킬 지침이 제공업체로 전송됩니다.",
|
||||
"s3047": "UI 요청 로그는 클라이언트, 시간, 토큰 메타데이터를 메모리에만 보관합니다.",
|
||||
"s3048": "활성화하면 네이티브 API 모니터링이 요청 내용을 메모리에 보관할 수 있습니다.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "모델을 불러오는 중…",
|
||||
"s9035": "다음 모델 파일이 없습니다: {0}.",
|
||||
"s9036": "'다운로드 및 불러오기'를 다시 누르면 가져옵니다. 이미 있는 파일은 그대로 둡니다. 같은 파일이 다시 표시되면 자세히 보기를 열어 도움을 요청할 때 함께 알려 주세요.",
|
||||
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다."
|
||||
"s9037": "이 업데이트는 앱이 다시 시작되면서 중단되었습니다. 이후 업데이트가 완료되었으므로 할 일은 없습니다.",
|
||||
"s9038": "이 시스템은 Strata의 포트 {0} 사용을 허용하지 않습니다. Linux와 macOS에서는 1024 미만의 포트에 관리자 권한이 필요합니다. 다른 포트(STRATA_PORT), 예를 들어 8801로 Strata를 시작하세요."
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "O Strata não está conectado ao mecanismo.",
|
||||
"s1762": "Modelos otimizados do Strata",
|
||||
"s1763": "O Strata prepara todas as partes, a camada de rascunho e os arquivos compartilhados. Downloads concluídos são reutilizados.",
|
||||
"s1764": "O Strata não envia telemetria de uso. As conversas ficam no armazenamento do navegador. Quando você escolhe um modelo de API, a conversa e as instruções das habilidades ativadas são enviadas ao provedor. O log de solicitações da interface mantém metadados de cliente, tempo e tokens na memória. O monitoramento da API nativa pode reter o conteúdo das solicitações na memória quando ativado. Ferramentas conectadas acessam os recursos que você configura.",
|
||||
"s1764": "O Strata não envia telemetria de uso. As conversas ficam neste computador, na pasta de dados do Strata. Quando você escolhe um modelo de API, a conversa e as instruções das habilidades ativadas são enviadas ao provedor. O log de solicitações da interface mantém metadados de cliente, tempo e tokens na memória. O monitoramento da API nativa pode reter o conteúdo das solicitações na memória quando ativado. Ferramentas conectadas acessam os recursos que você configura.",
|
||||
"s1765": "O Strata usa estes arquivos onde estão. Você pode carregar o modelo depois de adicioná-lo.",
|
||||
"s1766": "Strata — abrir chat",
|
||||
"s1767": "Limite de recursos do Strata",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "Ver registros",
|
||||
"s3043": "Registros do mecanismo",
|
||||
"s3044": "O Strata não envia telemetria de uso.",
|
||||
"s3045": "As conversas ficam no armazenamento do navegador.",
|
||||
"s3045": "As conversas ficam neste computador, na pasta de dados do Strata.",
|
||||
"s3046": "Ao escolher um modelo de API, essa conversa e as instruções das habilidades ativadas são enviadas ao provedor.",
|
||||
"s3047": "O registro de solicitações da interface guarda na memória metadados de cliente, tempo e tokens.",
|
||||
"s3048": "Quando ativado, o monitoramento da API nativa pode manter o conteúdo das solicitações na memória.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Carregando o modelo…",
|
||||
"s9035": "Estes arquivos do modelo estão faltando: {0}.",
|
||||
"s9036": "Pressione Baixar e carregar de novo para buscá-los. Os arquivos que já estão aí são mantidos. Se os mesmos arquivos aparecerem de novo, abra os Detalhes e compartilhe-os ao pedir ajuda.",
|
||||
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer."
|
||||
"s9037": "Esta atualização foi interrompida quando o aplicativo reiniciou. Uma atualização posterior terminou, então não há nada a fazer.",
|
||||
"s9038": "Este sistema não deixa o Strata usar a porta {0}. No Linux e no macOS, as portas abaixo de 1024 precisam de direitos de administrador. Inicie o Strata com outra porta (STRATA_PORT), por exemplo 8801."
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata не подключена к движку.",
|
||||
"s1762": "Оптимизированные модели Strata",
|
||||
"s1763": "Strata готовит все части, черновой слой и общие файлы. Уже скачанные файлы используются повторно.",
|
||||
"s1764": "Strata не отправляет телеметрию использования. Диалоги хранятся в браузере. При выборе модели API диалог и инструкции включённых навыков отправляются её провайдеру. Журнал запросов интерфейса хранит в памяти метаданные клиентов, времени и токенов. Мониторинг нативного API при включении может хранить содержимое запросов в памяти. Подключённые инструменты обращаются к настроенным вами ресурсам.",
|
||||
"s1764": "Strata не отправляет телеметрию использования. Диалоги хранятся на этом компьютере, в папке данных Strata. При выборе модели API диалог и инструкции включённых навыков отправляются её провайдеру. Журнал запросов интерфейса хранит в памяти метаданные клиентов, времени и токенов. Мониторинг нативного API при включении может хранить содержимое запросов в памяти. Подключённые инструменты обращаются к настроенным вами ресурсам.",
|
||||
"s1765": "Strata использует эти файлы на месте. После добавления можно загрузить модель.",
|
||||
"s1766": "Strata — открыть чат",
|
||||
"s1767": "Бюджет Strata",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "Показать журналы",
|
||||
"s3043": "Журналы движка",
|
||||
"s3044": "Strata не отправляет телеметрию использования.",
|
||||
"s3045": "Беседы хранятся в хранилище браузера.",
|
||||
"s3045": "Беседы хранятся на этом компьютере, в папке данных Strata.",
|
||||
"s3046": "Если вы выбираете API-модель, эта беседа и инструкции включённых навыков отправляются её провайдеру.",
|
||||
"s3047": "Журнал запросов интерфейса хранит в памяти данные о клиенте, времени и токенах.",
|
||||
"s3048": "Если включён мониторинг нативного API, содержимое запросов может храниться в памяти.",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "Загружается модель…",
|
||||
"s9035": "Не хватает этих файлов модели: {0}.",
|
||||
"s9036": "Нажмите «Скачать и загрузить» ещё раз, чтобы получить их. Уже имеющиеся файлы сохраняются. Если те же файлы снова появятся в списке, откройте «Подробности» и приложите их, когда будете просить о помощи.",
|
||||
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно."
|
||||
"s9037": "Это обновление прервалось при перезапуске приложения. Более позднее обновление завершилось, так что делать ничего не нужно.",
|
||||
"s9038": "Эта система не разрешает Strata использовать порт {0}. В Linux и macOS порты ниже 1024 требуют прав администратора. Запустите Strata с другим портом (STRATA_PORT), например 8801."
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "Einige Chats konnten nicht gespeichert werden",
|
||||
"sav02": "Der Chat-Speicher von Strata ist voll, daher gibt es die neuesten Änderungen an {0} Chat(s) nur in diesem Fenster. Löschen Sie alte Chats, um Platz zu schaffen; dann werden sie automatisch gespeichert.",
|
||||
"sav03": "Nicht gespeicherte Chats kopieren"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "Some chats could not be saved",
|
||||
"sav02": "Strata's chat storage is full, so the newest changes to {0} chat(s) are only in this window. Delete old chats to make room; they then save by themselves.",
|
||||
"sav03": "Copy unsaved chats"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "No se pudieron guardar algunos chats",
|
||||
"sav02": "El almacenamiento de chats de Strata está lleno, así que los cambios más recientes de {0} chat(s) solo están en esta ventana. Elimina chats antiguos para liberar espacio; después se guardarán solos.",
|
||||
"sav03": "Copiar chats sin guardar"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "Certaines conversations n’ont pas pu être enregistrées",
|
||||
"sav02": "L’espace de stockage des conversations de Strata est plein : les dernières modifications de {0} conversation(s) ne sont que dans cette fenêtre. Supprimez d’anciennes conversations pour faire de la place ; elles s’enregistreront alors d’elles-mêmes.",
|
||||
"sav03": "Copier les conversations non enregistrées"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "一部のチャットを保存できませんでした",
|
||||
"sav02": "Strata のチャット保存領域がいっぱいのため、{0} 件のチャットの最新の変更はこのウィンドウにしかありません。古いチャットを削除して空きを作ると、自動的に保存されます。",
|
||||
"sav03": "未保存のチャットをコピー"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "일부 채팅을 저장하지 못했습니다",
|
||||
"sav02": "Strata의 채팅 저장 공간이 가득 차서 {0}개 채팅의 최신 변경 내용이 이 창에만 있습니다. 오래된 채팅을 삭제해 공간을 확보하세요. 그러면 자동으로 저장됩니다.",
|
||||
"sav03": "저장되지 않은 채팅 복사"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "Alguns chats não puderam ser guardados",
|
||||
"sav02": "O armazenamento de chats do Strata está cheio, por isso as alterações mais recentes de {0} chat(s) só existem nesta janela. Apague chats antigos para libertar espaço; depois eles guardam-se sozinhos.",
|
||||
"sav03": "Copiar chats não guardados"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "Не удалось сохранить некоторые чаты",
|
||||
"sav02": "Хранилище чатов Strata заполнено, поэтому последние изменения в чатах ({0}) есть только в этом окне. Удалите старые чаты, чтобы освободить место; после этого они сохранятся сами.",
|
||||
"sav03": "Скопировать несохранённые чаты"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"sav01": "部分聊天无法保存",
|
||||
"sav02": "Strata 的聊天存储空间已满,因此 {0} 个聊天的最新更改目前只保存在此窗口中。请删除旧聊天以腾出空间,之后它们会自动保存。",
|
||||
"sav03": "复制未保存的聊天"
|
||||
}
|
||||
@@ -1779,7 +1779,7 @@
|
||||
"s1761": "Strata 未连接到引擎。",
|
||||
"s1762": "Strata 优化模型",
|
||||
"s1763": "Strata 会准备全部分片、草稿层和共享文件。已完成的下载会被复用。",
|
||||
"s1764": "Strata 不发送使用情况遥测。对话保存在浏览器存储中。选择 API 模型时,该对话及已启用技能的指令会发送给提供商。界面请求日志在内存中保留客户端、时间和词元元数据。启用原生 API 监控后,请求内容可能保留在内存中。已连接的工具可访问你配置的资源。",
|
||||
"s1764": "Strata 不发送使用情况遥测。对话保存在这台电脑上 Strata 的数据文件夹中。选择 API 模型时,该对话及已启用技能的指令会发送给提供商。界面请求日志在内存中保留客户端、时间和词元元数据。启用原生 API 监控后,请求内容可能保留在内存中。已连接的工具可访问你配置的资源。",
|
||||
"s1765": "Strata 直接使用这些文件。添加后即可加载模型。",
|
||||
"s1766": "Strata — 打开聊天",
|
||||
"s1767": "Strata 的预算",
|
||||
@@ -3056,7 +3056,7 @@
|
||||
"s3042": "查看日志",
|
||||
"s3043": "引擎日志",
|
||||
"s3044": "Strata 不发送任何使用遥测数据。",
|
||||
"s3045": "对话保存在浏览器存储中。",
|
||||
"s3045": "对话保存在这台电脑上 Strata 的数据文件夹中。",
|
||||
"s3046": "当你选择 API 模型时,该对话和已启用的技能说明会发送给其提供商。",
|
||||
"s3047": "界面请求日志只在内存中保留客户端、时间和令牌元数据。",
|
||||
"s3048": "启用后,原生 API 监控可能会在内存中保留请求内容。",
|
||||
@@ -3526,5 +3526,6 @@
|
||||
"s9034": "正在加载模型…",
|
||||
"s9035": "缺少这些模型文件:{0}。",
|
||||
"s9036": "再按一次“下载并加载”即可获取。已有的文件会保留。如果再次列出同样的文件,请打开详情,并在求助时一并提供。",
|
||||
"s9037": "此更新在应用重启时被中断。之后的更新已完成,无需任何操作。"
|
||||
"s9037": "此更新在应用重启时被中断。之后的更新已完成,无需任何操作。",
|
||||
"s9038": "此系统不允许 Strata 使用端口 {0}。在 Linux 和 macOS 上,1024 以下的端口需要管理员权限。请用其他端口(STRATA_PORT)启动 Strata,例如 8801。"
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import './styles/kit/components.css';
|
||||
import './styles/app.css';
|
||||
import { App } from './app/App';
|
||||
import { installDropGuard } from './components/DropZone';
|
||||
import { startRecovery } from './lib/recovery';
|
||||
|
||||
const el = document.getElementById('root');
|
||||
if (!el) throw new Error('#root missing');
|
||||
|
||||
installDropGuard();
|
||||
startRecovery();
|
||||
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -10,6 +10,9 @@ import { ChatTelemetry } from '../components/TelemetryPopover';
|
||||
import { ArtifactCard } from '../components/ArtifactCard';
|
||||
import { ChatMedia } from '../components/ChatMedia';
|
||||
import { closePreviewTab } from '../lib/preview-tabs';
|
||||
import { endPreviewDeferral, previewsDeferred } from '../lib/recovery';
|
||||
import { messageArtifacts, openItem, syncArtifacts } from '../lib/preview-merge';
|
||||
import { createImeGuard } from '../lib/ime-enter';
|
||||
import { mainCodePreview, setSelectedPreviewId } from '../lib/code-preview';
|
||||
import { ArtifactPanel } from '../components/ArtifactPanel';
|
||||
import { PreviewResizeHandle } from '../components/PreviewResizeHandle';
|
||||
@@ -167,24 +170,27 @@ export function ChatScreen() {
|
||||
const resizeHandle = !setupVisible && inlinePreview && selectedPreview && !expanded ? <PreviewResizeHandle container={splitRef} total={splitWidth} fraction={previewFraction} onChange={setPreviewFraction} controls={selectedRun ? 'time-skip-content' : planShown ? 'plan-content' : 'artifact-content'}/> : null;
|
||||
const runCount = timeSkipRuns.length;
|
||||
const previewCount = session?.messages.reduce((n, m) => n + (m.artifacts?.length ?? 0), 0) ?? 0;
|
||||
useEffect(() => { const items = [...new Map((session?.messages.flatMap(m => m.artifacts ?? []) ?? []).map(a => [a.id, a])).values()]; setPreviews(items.filter(a => !session?.closedPreviews?.includes(a.id))); setSelectedPreview(session?.closedPreviews?.includes(session.previewOpen ?? '') ? null : session?.previewOpen?.startsWith('time-skip:') && !timeSkipRuns.some(r => timeSkipTabId(r.id) === session.previewOpen) ? null : session?.previewOpen ?? null); }, [session?.id]);
|
||||
useEffect(() => { const items = [...new Map((session?.messages.flatMap(m => m.artifacts ?? []) ?? []).map(a => [a.id, a])).values()]; setPreviews(items.filter(a => !session?.closedPreviews?.includes(a.id))); setSelectedPreview(session && previewsDeferred(session.id) || session?.closedPreviews?.includes(session.previewOpen ?? '') ? null : session?.previewOpen?.startsWith('time-skip:') && !timeSkipRuns.some(r => timeSkipTabId(r.id) === session.previewOpen) ? null : session?.previewOpen ?? null); }, [session?.id]);
|
||||
useEffect(() => {
|
||||
const items = [...new Map((session?.messages.flatMap(m => m.artifacts ?? []) ?? []).map(a => [a.id, a])).values()];
|
||||
// A message's copy of a file never replaces one that was read later (lib/preview-merge.ts); nothing changed = the same array = no update.
|
||||
const items = messageArtifacts(session?.messages);
|
||||
if (items.length)
|
||||
setPreviews(old => [...old.filter(a => !items.some(b => a.id === b.id)), ...items.filter(a => !session?.closedPreviews?.includes(a.id))]);
|
||||
setPreviews(old => syncArtifacts(old, items, session?.closedPreviews));
|
||||
}, [previewCount]);
|
||||
const localUrls = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
const id = session?.previewOpen;
|
||||
if (id?.startsWith('time-skip:') && timeSkipRuns.some(r => timeSkipTabId(r.id) === id) && !session?.closedPreviews?.includes(id))
|
||||
if (id?.startsWith('time-skip:') && timeSkipRuns.some(r => timeSkipTabId(r.id) === id) && !session?.closedPreviews?.includes(id) && !(session && previewsDeferred(session.id)))
|
||||
setSelectedPreview(id);
|
||||
}, [session?.id, runCount]);
|
||||
useEffect(() => () => localUrls.current.forEach(url => URL.revokeObjectURL(url)), []);
|
||||
function openPreview(item: PreviewArtifact) {
|
||||
endPreviewDeferral();
|
||||
if (item.url?.startsWith('blob:'))
|
||||
localUrls.current.push(item.url);
|
||||
setPreviewTabClosed(session?.id, item.id, false, item.id);
|
||||
setPreviews(old => old.some(a => a.id === item.id) ? old : [...old, item]);
|
||||
// The file was read just now: it replaces the tab of the same id when its content differs (it used to be dropped, keeping the first copy).
|
||||
setPreviews(old => openItem(old, item));
|
||||
setSelectedPreview(item.id);
|
||||
}
|
||||
/* A run that finishes with a file the sidebar can show opens its main result there by itself (one file, never several). Only a run seen
|
||||
@@ -214,6 +220,7 @@ export function ChatScreen() {
|
||||
}
|
||||
}
|
||||
function selectPreview(id: string) {
|
||||
endPreviewDeferral();
|
||||
setSelectedPreview(id);
|
||||
setPreviewTabClosed(session?.id, id, false, id);
|
||||
}
|
||||
@@ -611,6 +618,7 @@ function Composer({ draft, setDraft, loaded, streaming, onSubmit, onKey, session
|
||||
const [slashOpen, setSlashOpen] = useState(false);
|
||||
const [slashDismissed, setSlashDismissed] = useState(false);
|
||||
const slashHostRef = useRef<HTMLDivElement>(null);
|
||||
const ime = useRef(createImeGuard());
|
||||
const slashQuery = slashDismissed ? null : slashOpen ? '' : draft.startsWith('/') && !/\s/.test(draft) ? draft.slice(1) : null;
|
||||
const matches = slashQuery === null ? [] : commands.filter((c) => c.label.slice(1).startsWith(slashQuery.toLowerCase()));
|
||||
useEffect(() => { setSlashDismissed(false); }, [draft, session?.id]);
|
||||
@@ -640,7 +648,7 @@ function Composer({ draft, setDraft, loaded, streaming, onSubmit, onKey, session
|
||||
{tr(parkedList.length ? <div className="parked" role="status">{tr(parkedList.map((text, i) => <div className="parked__item" key={`${i}-${text}`}><span className="xs muted">{tr("Waiting to send")}</span><span className="parked__text" title={text}>{text}</span><IconBtn name="x" size={14} title={tr("Take back")} onClick={() => { const back = takeParked(session!.id, i); if (back !== undefined && !draft.trim()) setDraft(back); }}/></div>))}</div> : null)}
|
||||
<div className="st-composer" style={{ boxShadow: 'var(--st-sh-bar)' }}>
|
||||
{tr(photos.length ? <ChatPhotos photos={photos} onRemove={id => removeChatPhoto(id, session?.id)}/> : null)}
|
||||
<textarea ref={textareaRef} rows={1} value={draft} aria-label={tr("Message Strata")} placeholder={tr(canChat ? 'Message Strata…' : 'Try /time-skip, or load a model to chat…')} onFocus={() => setSlashDismissed(false)} onPaste={(e) => {
|
||||
<textarea ref={textareaRef} rows={1} value={draft} aria-label={tr("Message Strata")} placeholder={tr(canChat ? 'Message Strata…' : 'Try /time-skip, or load a model to chat…')} onFocus={() => setSlashDismissed(false)} onBlur={() => ime.current.reset()} onCompositionStart={() => ime.current.compositionStart()} onCompositionEnd={(e) => ime.current.compositionEnd(e.timeStamp)} onPaste={(e) => {
|
||||
// A picture on the clipboard (a screenshot, a copied image) is attached like a dropped file; text pastes as usual.
|
||||
const files = pastedFiles(e.clipboardData);
|
||||
if (!files.length)
|
||||
@@ -648,6 +656,9 @@ function Composer({ draft, setDraft, loaded, streaming, onSubmit, onKey, session
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent(DROP_EVENT, { detail: files }));
|
||||
}} onChange={(e) => { setSlashOpen(false); setSlashDismissed(false); setDraft(e.target.value); e.currentTarget.style.height = "auto"; e.currentTarget.style.height = `${Math.min(180, e.currentTarget.scrollHeight)}px`; }} onKeyDown={(e) => {
|
||||
// A key that belongs to the IME (the Enter that commits a conversion, in the order each engine delivers it) is not ours (#5071).
|
||||
if (ime.current.keyDown({ key: e.key, isComposing: e.nativeEvent.isComposing, keyCode: e.nativeEvent.keyCode }, e.timeStamp))
|
||||
return;
|
||||
// The command menu owns the keyboard while it is open.
|
||||
if (slashQuery !== null && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
@@ -676,7 +687,7 @@ function Composer({ draft, setDraft, loaded, streaming, onSubmit, onKey, session
|
||||
}
|
||||
}
|
||||
onKey(e);
|
||||
if (!e.nativeEvent.isComposing && composerSends(e)) {
|
||||
if (composerSends(e)) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function MonitorScreen() {
|
||||
// While this screen is open and visible the host reads the graphics card every 3 s; otherwise it asks only every 30 s (#4066).
|
||||
useEffect(() => {
|
||||
const ask = () => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden')
|
||||
void api.get('/api/monitor').catch(() => undefined); };
|
||||
void api.get('/api/monitor?drives=1').catch(() => undefined); };
|
||||
ask();
|
||||
const timer = setInterval(ask, 10000);
|
||||
return () => clearInterval(timer);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* #4397: one request with an empty or malformed Host header (or a request target that is not a path) used to end the
|
||||
* whole host: the URL was built before the routing try/catch, the async handler rejected, and Node ends the process on
|
||||
* an unhandled rejection. A bad request now gets a bounded 400 and the host keeps serving.
|
||||
*/
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
async function freePort() { const s = net.createServer(); await new Promise(r => s.listen(0, '127.0.0.1', r)); const p = s.address().port; await new Promise(r => s.close(r)); return p; }
|
||||
|
||||
async function startHost() {
|
||||
const home = await mkdtemp(join(tmpdir(), 'strata-host-badhost-'));
|
||||
const port = await freePort(), sharePort = await freePort();
|
||||
const env = {
|
||||
...process.env, STRATA_HOME: home, STRATA_MODELS_DIR: join(home, 'models'), STRATA_PORT: String(port),
|
||||
STRATA_SHARE_PORT: String(sharePort), STRATA_TUNNEL_PORT: String(await freePort()), STRATA_REAL_ENGINE: '1',
|
||||
STRATA_SETTINGS_FILE: join(home, 'app-settings.json'), STRATA_COMPONENTS_DIR: join(home, 'components'),
|
||||
LOCALAPPDATA: join(home, 'Local'), APPDATA: join(home, 'Roaming'), XDG_DATA_HOME: join(home, 'xdg-data'),
|
||||
XDG_STATE_HOME: join(home, 'xdg-state'), XDG_CONFIG_HOME: join(home, 'xdg-config'),
|
||||
};
|
||||
const host = spawn(process.execPath, ['server/index.mjs'], { cwd: root, stdio: 'ignore', env });
|
||||
const exit = new Promise(r => host.once('exit', (code, signal) => r({ code, signal })));
|
||||
let up = false;
|
||||
for (let i = 0; i < 150 && !up; i++) { try { up = (await fetch(`http://127.0.0.1:${port}/api/health`)).ok; } catch { await delay(100); } }
|
||||
assert.ok(up, 'host started');
|
||||
return { host, port, sharePort, exit, done: async () => { host.kill('SIGKILL'); await exit; await rm(home, { recursive: true, force: true }).catch(() => {}); } };
|
||||
}
|
||||
|
||||
/** One raw HTTP request on its own socket: the bytes are sent as written and the first response is returned. */
|
||||
function raw(port, bytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1');
|
||||
let data = '';
|
||||
const finish = () => { socket.destroy(); resolve(data); };
|
||||
const timer = setTimeout(finish, 4000);
|
||||
socket.on('connect', () => socket.write(bytes));
|
||||
socket.on('data', chunk => { data += chunk; if (/\r\n\r\n/.test(data) && /content-length: *\d+/i.test(data)) { const body = data.split('\r\n\r\n')[1] ?? ''; const len = Number(/content-length: *(\d+)/i.exec(data)[1]); if (Buffer.byteLength(body) >= len) { clearTimeout(timer); finish(); } } });
|
||||
socket.on('close', () => { clearTimeout(timer); resolve(data); });
|
||||
socket.on('error', e => { clearTimeout(timer); data ? resolve(data) : reject(e); });
|
||||
});
|
||||
}
|
||||
const status = response => Number(/^HTTP\/1\.[01] (\d{3})/.exec(response)?.[1] ?? 0);
|
||||
|
||||
test('requestUrl: a good Host (name, port, IPv6) or none gives the URL; an empty or malformed Host or target gives null', async () => {
|
||||
const { requestUrl } = await import('../server/util.mjs');
|
||||
const req = (url, host) => ({ url, headers: host === undefined ? {} : { host } });
|
||||
assert.equal(requestUrl(req('/api/health?x=1', 'localhost:8791')).pathname, '/api/health');
|
||||
assert.equal(requestUrl(req('/api/health?x=1', 'localhost:8791')).searchParams.get('x'), '1');
|
||||
assert.equal(requestUrl(req('/', '[::1]:8791')).pathname, '/');
|
||||
assert.equal(requestUrl(req('/', '127.0.0.1')).pathname, '/');
|
||||
assert.equal(requestUrl(req('/')).pathname, '/', 'no Host (HTTP/1.0) is read as localhost');
|
||||
assert.equal(requestUrl({ headers: {} }).pathname, '/', 'no url at all is the root');
|
||||
for (const bad of ['', ' ', 'a b', 'exa mple.com', 'a/b', 'a?b', 'a#b', 'u@h', 'h\\x', '[::1', 'http://x', '%zz', 'a:99999']) {
|
||||
assert.equal(requestUrl(req('/', bad)), null, `Host ${JSON.stringify(bad)}`);
|
||||
}
|
||||
assert.equal(requestUrl(req('//', 'localhost')), null, 'a target of // has no host to read');
|
||||
assert.equal(requestUrl(req('http://', 'localhost')), null);
|
||||
});
|
||||
|
||||
test('App Host: an empty, missing, malformed or IPv6 Host never ends the process; a healthy request on another socket still works', async t => {
|
||||
const h = await startHost();
|
||||
t.after(h.done);
|
||||
let ended = null;
|
||||
h.exit.then(r => { ended = r; });
|
||||
|
||||
const empty = await raw(h.port, 'GET /api/health HTTP/1.1\r\nHost: \r\nConnection: close\r\n\r\n');
|
||||
assert.equal(status(empty), 400, `empty Host: ${empty.slice(0, 80)}`);
|
||||
assert.match(empty, /bad_host/);
|
||||
assert.equal(ended, null, 'the host survived an empty Host');
|
||||
const alive = await fetch(`http://127.0.0.1:${h.port}/api/health`);
|
||||
assert.equal(alive.status, 200, 'a healthy request on a separate socket after the empty Host');
|
||||
|
||||
const spaced = await raw(h.port, 'GET /api/health HTTP/1.1\r\nHost: a b\r\nConnection: close\r\n\r\n');
|
||||
assert.equal(status(spaced), 400, 'malformed Host');
|
||||
assert.ok(spaced.length < 1000, 'the 400 is bounded');
|
||||
for (const odd of ['a/b', 'u@h', '[::1', 'x y z', '%zz']) {
|
||||
const r = await raw(h.port, `GET /api/health HTTP/1.1\r\nHost: ${odd}\r\nConnection: close\r\n\r\n`);
|
||||
assert.equal(status(r), 400, `Host ${odd}`);
|
||||
}
|
||||
assert.equal(ended, null, 'the host survived the malformed Hosts');
|
||||
|
||||
const none = await raw(h.port, 'GET /api/health HTTP/1.0\r\n\r\n');
|
||||
assert.equal(status(none), 200, 'HTTP/1.0 without a Host is served');
|
||||
|
||||
const ipv6 = await raw(h.port, `GET /api/health HTTP/1.1\r\nHost: [::1]:${h.port}\r\nConnection: close\r\n\r\n`);
|
||||
assert.equal(status(ipv6), 200, 'a valid IPv6 Host is served');
|
||||
|
||||
const target = await raw(h.port, 'GET // HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
||||
assert.equal(status(target), 400, 'a request target that is not a path');
|
||||
|
||||
assert.equal(ended, null, 'the host is still running');
|
||||
assert.equal((await fetch(`http://127.0.0.1:${h.port}/api/health`)).status, 200, 'and still answers');
|
||||
});
|
||||
|
||||
test('The share listener also survives a request target that is not a path', async t => {
|
||||
const h = await startHost();
|
||||
t.after(h.done);
|
||||
// the share listener is off until sharing is started: start it the way the app does
|
||||
const started = await fetch(`http://127.0.0.1:${h.port}/api/serving/share`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: true }) }).catch(() => null);
|
||||
if (!started || !started.ok) return t.skip('could not start the share listener in this environment');
|
||||
const bad = await raw(h.sharePort, 'GET // HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n');
|
||||
assert.equal(status(bad), 400);
|
||||
assert.equal((await fetch(`http://127.0.0.1:${h.port}/api/health`)).status, 200);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* QAGUI 0213 #1: one start-up GET that never answers must not leave the app on "Connecting to the local engine" for good.
|
||||
* Optional reads (hardware, monitor, skills, ...) fill in later; a hung essential read ends in the offline path within a bounded time.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { build } from 'esbuild';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
|
||||
globalThis.window ??= globalThis;
|
||||
globalThis.document ??= { addEventListener() {}, querySelector: () => null, hasFocus: () => true, visibilityState: 'visible', documentElement: { dataset: {}, style: { setProperty() {}, removeProperty() {} }, classList: { toggle() {}, add() {}, remove() {} } } };
|
||||
const memory = new Map();
|
||||
globalThis.localStorage = {
|
||||
get length() { return memory.size; }, key: (i) => [...memory.keys()][i] ?? null,
|
||||
getItem: (k) => memory.get(k) ?? null, setItem: (k, v) => { memory.set(k, v); }, removeItem: (k) => memory.delete(k),
|
||||
};
|
||||
globalThis.navigator ??= { language: 'en' };
|
||||
globalThis.matchMedia ??= () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} });
|
||||
globalThis.fetch = async () => { throw new Error('offline'); }; // the chat read falls to the browser copy
|
||||
|
||||
const answers = {
|
||||
'/api/setup': { complete: true, dismissed: false },
|
||||
'/api/models': { mode: 'real', variants: [] },
|
||||
'/api/engine': { beginner: { contextLength: 8192 }, expert: [] },
|
||||
'/api/serving': { state: 'idle' },
|
||||
'/api/workspaces': { workspaces: [] },
|
||||
'/api/hardware': { gpus: [] },
|
||||
'/api/monitor': { cpu: 1 },
|
||||
'/api/skills': [],
|
||||
'/api/model-providers': { providers: [] },
|
||||
'/api/image-models': { models: [] },
|
||||
'/api/runtime': { models: [] },
|
||||
};
|
||||
globalThis.__hang = new Set();
|
||||
globalThis.__calls = {};
|
||||
globalThis.__flaky = {};
|
||||
const mockClient = {
|
||||
name: 'mock-client',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /\/client$/ }, () => ({ path: 'mock-client', namespace: 'mock' }));
|
||||
b.onLoad({ filter: /.*/, namespace: 'mock' }, () => ({
|
||||
loader: 'js',
|
||||
contents: `
|
||||
export class ApiError extends Error { constructor(m, status) { super(m); this.status = status; } }
|
||||
const none = async () => ({});
|
||||
export const api = {
|
||||
get: (path) => {
|
||||
globalThis.__calls[path] = (globalThis.__calls[path] ?? 0) + 1;
|
||||
if (globalThis.__hang.has(path)) return new Promise(() => {});
|
||||
if ((globalThis.__flaky[path] ?? 0) > 0) { globalThis.__flaky[path]--; return Promise.reject(new Error('socket hang up')); }
|
||||
return Promise.resolve(structuredClone(globalThis.__answers[path]));
|
||||
},
|
||||
post: none, put: none, patch: none, del: none, delete: none,
|
||||
};
|
||||
export function onEvent() { return () => {}; }
|
||||
export async function streamPost() {}`,
|
||||
}));
|
||||
},
|
||||
};
|
||||
globalThis.__answers = answers;
|
||||
const result = await build({
|
||||
entryPoints: [here('../src/lib/store.ts')], bundle: true, format: 'esm', platform: 'node', write: false, logLevel: 'silent',
|
||||
alias: { react: here('./helpers/react-stub.mjs') }, loader: { '.json': 'json' }, plugins: [mockClient],
|
||||
});
|
||||
const store = await import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`);
|
||||
Object.assign(store.bootstrapTiming, { timeoutMs: 150, tries: 2, backoffMs: 20 });
|
||||
|
||||
const reset = () => { globalThis.__hang.clear(); globalThis.__calls = {}; globalThis.__flaky = {}; };
|
||||
const snapshot = () => store.getState();
|
||||
|
||||
test('an optional GET that never answers does not hold back live; the others still fill in', async () => {
|
||||
reset();
|
||||
globalThis.__hang.add('/api/skills');
|
||||
const t0 = Date.now();
|
||||
await store.bootstrap();
|
||||
assert.ok(Date.now() - t0 < 140, 'live before the time limit of the hung read');
|
||||
assert.equal(snapshot().connection, 'live');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
assert.deepEqual(snapshot().hardware, answers['/api/hardware']);
|
||||
assert.equal(globalThis.__calls['/api/skills'], 1, 'the hung read is still waiting for its first answer');
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
assert.equal(globalThis.__calls['/api/skills'], 2, 'and is tried again, then given up');
|
||||
});
|
||||
|
||||
test('an essential GET that never answers ends offline within a bounded time', async () => {
|
||||
reset();
|
||||
globalThis.__hang.add('/api/engine');
|
||||
const t0 = Date.now();
|
||||
await store.bootstrap();
|
||||
const took = Date.now() - t0;
|
||||
assert.equal(snapshot().connection, 'offline');
|
||||
assert.equal(snapshot().ready, true);
|
||||
assert.ok(took < 1500, `bounded (took ${took} ms)`);
|
||||
assert.equal(globalThis.__calls['/api/engine'], 2, 'tried the bounded number of times');
|
||||
});
|
||||
|
||||
test('a read that fails once is tried again and the app still goes live', async () => {
|
||||
reset();
|
||||
globalThis.__flaky['/api/setup'] = 1;
|
||||
await store.bootstrap();
|
||||
assert.equal(snapshot().connection, 'live');
|
||||
assert.equal(globalThis.__calls['/api/setup'], 2);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* #4878: a quiet desktop chat stream stays open. The chat answer is NDJSON; during a long silence (prompt processing, a tool,
|
||||
* an approval wait) a `{"type":"beat"}` frame goes out every 15 seconds so the web view does not drop the request at 60 s.
|
||||
* - the helper, with fake timers: a 60-second tool wait gives the beats, and the timer ends on finish, on close, on error and
|
||||
* on stop(), with no interval left behind;
|
||||
* - the real route, against a real host and a scripted API model that goes silent: beats arrive, a client that goes away
|
||||
* cancels the model request before the next beat, the next message works, and two chats stay independent.
|
||||
* The frontend consumer (src/lib/store.ts) has no branch for an unknown frame type, so `beat` changes nothing there; the last
|
||||
* test pins that.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { startScriptedWorker } from './helpers/scripted-worker.mjs';
|
||||
import { BEAT_MS, beatInterval, keepStreamAlive } from '../server/stream-beat.mjs';
|
||||
|
||||
/** A response stand-in: records writes, emits finish/close like http.ServerResponse. */
|
||||
function fakeResponse() {
|
||||
const res = new EventEmitter();
|
||||
res.writes = [];
|
||||
res.writableEnded = false;
|
||||
res.destroyed = false;
|
||||
res.write = chunk => { res.writes.push(String(chunk)); return true; };
|
||||
res.end = () => { res.writableEnded = true; res.emit('finish'); res.emit('close'); };
|
||||
return res;
|
||||
}
|
||||
const beats = res => res.writes.filter(w => w === '{"type":"beat"}\n').length;
|
||||
const listeners = res => ['finish', 'close', 'error'].reduce((n, e) => n + res.listenerCount(e), 0);
|
||||
|
||||
test('the interval is 15 s; a tester can shorten it; nonsense is the default', () => {
|
||||
assert.equal(BEAT_MS, 15_000);
|
||||
assert.equal(beatInterval({}), 15_000);
|
||||
assert.equal(beatInterval({ STRATA_STREAM_BEAT_MS: '200' }), 200);
|
||||
for (const bad of ['0', '-5', 'abc', '', '5', '999999', 'NaN']) assert.equal(beatInterval({ STRATA_STREAM_BEAT_MS: bad }), 15_000, bad);
|
||||
});
|
||||
|
||||
test('a 60-second silent tool wait carries a beat every 15 s, each a whole NDJSON line', t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const res = fakeResponse();
|
||||
const stop = keepStreamAlive(res);
|
||||
t.mock.timers.tick(14_999);
|
||||
assert.equal(beats(res), 0, 'nothing before 15 s');
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(beats(res), 1);
|
||||
t.mock.timers.tick(45_000);
|
||||
assert.equal(beats(res), 4, 'four beats in 60 s');
|
||||
for (const w of res.writes) assert.deepEqual(JSON.parse(w), { type: 'beat' });
|
||||
assert.ok(res.writes.every(w => w.endsWith('\n') && w.indexOf('\n') === w.length - 1));
|
||||
stop();
|
||||
});
|
||||
|
||||
test('the timer is cleared on finish, on close, on error and by stop(): nothing beats afterwards, no listener is left', t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
for (const how of ['finish', 'close', 'error', 'stop', 'end']) {
|
||||
const res = fakeResponse();
|
||||
const stop = keepStreamAlive(res);
|
||||
t.mock.timers.tick(15_000);
|
||||
assert.equal(beats(res), 1, how);
|
||||
if (how === 'stop') stop(); else if (how === 'end') res.end(); else res.emit(how, new Error('x'));
|
||||
const after = res.writes.length;
|
||||
t.mock.timers.tick(120_000);
|
||||
assert.equal(res.writes.length, after, `${how}: no beat after the end`);
|
||||
assert.equal(listeners(res), 0, `${how}: no listener left on the response`);
|
||||
stop(); // calling it again is harmless
|
||||
}
|
||||
});
|
||||
|
||||
test('a response that was destroyed or ended without an event is not written to, and its timer goes at the next tick', t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const a = fakeResponse(); keepStreamAlive(a); a.destroyed = true;
|
||||
const b = fakeResponse(); keepStreamAlive(b); b.writableEnded = true;
|
||||
const c = fakeResponse(); keepStreamAlive(c); c.write = () => { throw new Error('socket closed'); };
|
||||
t.mock.timers.tick(15_000);
|
||||
assert.equal(a.writes.length + b.writes.length, 0);
|
||||
t.mock.timers.tick(120_000);
|
||||
assert.equal(a.writes.length + b.writes.length, 0);
|
||||
assert.equal(listeners(a) + listeners(b) + listeners(c), 0);
|
||||
});
|
||||
|
||||
test('two responses beat independently: ending one leaves the other beating', t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
const one = fakeResponse(), two = fakeResponse();
|
||||
keepStreamAlive(one); keepStreamAlive(two);
|
||||
t.mock.timers.tick(30_000);
|
||||
assert.equal(beats(one), 2); assert.equal(beats(two), 2);
|
||||
one.emit('close');
|
||||
t.mock.timers.tick(30_000);
|
||||
assert.equal(beats(one), 2, 'the closed chat stops');
|
||||
assert.equal(beats(two), 4, 'the other keeps going');
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- the real route --- */
|
||||
|
||||
const HOST = resolve(import.meta.dirname, '..', 'server', 'index.mjs');
|
||||
const freePort = () => new Promise(r => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => r(p)); }); });
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const cleanup = dir => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ } };
|
||||
|
||||
async function setup(t, beatMs) {
|
||||
const worker = await startScriptedWorker({ model: 'cloud-model' });
|
||||
const home = mkdtempSync(join(tmpdir(), 'strata-beat-'));
|
||||
const ws = join(home, 'workspace'); mkdirSync(ws, { recursive: true });
|
||||
const port = await freePort();
|
||||
const env = {
|
||||
...process.env, STRATA_HOME: home, STRATA_PORT: String(port), STRATA_WORKSPACE: ws, STRATA_REAL_ENGINE: '0',
|
||||
STRATA_COMPONENTS_DIR: join(home, 'components'), STRATA_STREAM_BEAT_MS: String(beatMs),
|
||||
STRATA_SETTINGS_FILE: join(home, 'app-settings.json'), LOCALAPPDATA: join(home, 'Local'), APPDATA: join(home, 'Roaming'),
|
||||
XDG_DATA_HOME: join(home, 'xdg-data'), XDG_STATE_HOME: join(home, 'xdg-state'), XDG_CONFIG_HOME: join(home, 'xdg-config'),
|
||||
STRATA_SHARE_PORT: String(await freePort()), STRATA_TUNNEL_PORT: String(await freePort()),
|
||||
};
|
||||
delete env.STRATA_HARNESS_DIR;
|
||||
const child = spawn(process.execPath, [HOST], { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
||||
let log = '';
|
||||
child.stdout.on('data', d => { log += d; }); child.stderr.on('data', d => { log += d; });
|
||||
t.after(async () => {
|
||||
if (child.exitCode === null) {
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
|
||||
else child.kill('SIGTERM');
|
||||
await new Promise(r => child.once('exit', r));
|
||||
}
|
||||
await worker.close(); cleanup(home);
|
||||
});
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* not up yet */ }
|
||||
if (child.exitCode !== null) throw new Error(`the host exited: ${log.slice(-500)}`);
|
||||
await sleep(100);
|
||||
}
|
||||
const saved = await fetch(`${base}/api/model-providers`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'custom', name: 'Scripted API', baseUrl: worker.url, protocol: 'openai-chat', apiKey: 'sk-host-test-0123456789', models: [{ id: 'cloud-model', name: 'Cloud model', contextMax: 64000 }] }) });
|
||||
assert.equal(saved.status, 200);
|
||||
const connectionId = (await saved.json()).connections[0].id;
|
||||
const remoteModel = { connectionId, modelId: 'cloud-model' };
|
||||
/** Open a chat stream; frames are handed to onFrame as they arrive; abort() drops the connection like a client that left. */
|
||||
const chat = (sessionId, message, onFrame = () => {}) => {
|
||||
const controller = new AbortController();
|
||||
const frames = [];
|
||||
const done = (async () => {
|
||||
const res = await fetch(`${base}/api/chat/stream`, { method: 'POST', headers: { 'content-type': 'application/json' }, signal: controller.signal, body: JSON.stringify({ sessionId, message, remoteModel, history: [], agent: false }) });
|
||||
assert.equal(res.status, 200);
|
||||
let buffer = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of res.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
let i;
|
||||
while ((i = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, i).trim(); buffer = buffer.slice(i + 1); if (line) { const f = JSON.parse(line); frames.push(f); onFrame(f); } }
|
||||
}
|
||||
return frames;
|
||||
})();
|
||||
return { frames, done, abort: () => controller.abort() };
|
||||
};
|
||||
return { worker, chat, alive: () => child.exitCode === null, log: () => log };
|
||||
}
|
||||
const until = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { if (fn()) return true; await sleep(25); } return false; };
|
||||
|
||||
test('route: a silent API model gets beats; a client that leaves cancels the model request before the next beat', { timeout: 40_000 }, async t => {
|
||||
const s = await setup(t, 300);
|
||||
let first = null;
|
||||
const quiet = s.chat('chat-beat-a', 'SCRIPT:hang', f => { if (f.type === 'beat' && first === null) first = Date.now(); });
|
||||
assert.ok(await until(() => quiet.frames.filter(f => f.type === 'beat').length >= 2), 'two beats while the model says nothing');
|
||||
const framesBefore = quiet.frames.length;
|
||||
quiet.abort();
|
||||
await assert.rejects(quiet.done, { name: 'AbortError' });
|
||||
// the next beat is 300 ms away: the model request must already be cancelled well before it
|
||||
assert.ok(await until(() => s.worker.aborted >= 1, 1500), 'disconnect cancelled the direct model request');
|
||||
assert.ok(framesBefore >= 2);
|
||||
const next = s.chat('chat-beat-a', 'SCRIPT:echo still works');
|
||||
const frames = await next.done;
|
||||
assert.equal(frames.filter(f => f.type === 'delta').map(f => f.text).join(''), 'still works');
|
||||
assert.equal(frames.at(-1).type, 'done');
|
||||
assert.ok(s.alive(), 'the host is alive');
|
||||
});
|
||||
|
||||
test('route: two chats at once are independent; leaving one does not touch the other', { timeout: 40_000 }, async t => {
|
||||
const s = await setup(t, 250);
|
||||
const a = s.chat('chat-beat-1', 'SCRIPT:hang');
|
||||
const b = s.chat('chat-beat-2', 'SCRIPT:hang');
|
||||
const count = c => c.frames.filter(f => f.type === 'beat').length;
|
||||
assert.ok(await until(() => count(a) >= 2 && count(b) >= 2), 'both beat');
|
||||
a.abort();
|
||||
await assert.rejects(a.done, { name: 'AbortError' });
|
||||
assert.ok(await until(() => s.worker.aborted === 1, 1500), 'exactly the one request was cancelled');
|
||||
const frozen = count(a);
|
||||
const bBefore = count(b);
|
||||
assert.ok(await until(() => count(b) >= bBefore + 2), 'the other chat keeps getting beats');
|
||||
assert.equal(count(a), frozen, 'the chat that left gets nothing more');
|
||||
assert.equal(s.worker.aborted, 1, 'the other model request was not cancelled');
|
||||
b.abort();
|
||||
await assert.rejects(b.done, { name: 'AbortError' });
|
||||
assert.ok(await until(() => s.worker.aborted === 2, 1500));
|
||||
assert.ok(s.alive());
|
||||
});
|
||||
|
||||
test('route: a normal answer is unchanged (beats only fill silence) and the stream still ends', { timeout: 40_000 }, async t => {
|
||||
const s = await setup(t, 20_000); // longer than the test: no beat should appear in a fast answer
|
||||
const frames = await s.chat('chat-beat-fast', 'SCRIPT:echo hello there').done;
|
||||
assert.equal(frames.filter(f => f.type === 'delta').map(f => f.text).join(''), 'hello there');
|
||||
assert.equal(frames.filter(f => f.type === 'beat').length, 0);
|
||||
assert.equal(frames.at(-1).type, 'done');
|
||||
});
|
||||
|
||||
test('the desktop consumer ignores a frame type it does not know (so beat changes nothing there)', () => {
|
||||
const store = readFileSync(resolve(import.meta.dirname, '..', 'src', 'lib', 'store.ts'), 'utf8');
|
||||
const start = store.indexOf("if (type === 'delta' || type === 'reasoning') { queueText");
|
||||
const doneAt = store.indexOf("} else if (type === 'done') {", start);
|
||||
assert.ok(start > 0 && doneAt > start, 'found the frame handler');
|
||||
// the chain is if / else if only and its last branch is `done`: no final `else` follows it, so an unknown type falls through silently
|
||||
assert.match(store.slice(doneAt, doneAt + 2500), /flushSessions\(\);\s*\}\s*\},/);
|
||||
assert.ok(!store.slice(start, doneAt).includes("type === 'beat'"), 'no special case is needed');
|
||||
const client = readFileSync(resolve(import.meta.dirname, '..', 'src', 'lib', 'client.ts'), 'utf8');
|
||||
assert.match(client, /onFrame\(JSON\.parse\(line\)/);
|
||||
});
|
||||
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user