mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-19 17:25:07 +02:00
Merge branch 'upstream' into concedo_experimental
# Conflicts: # .devops/nix/package.nix # .github/ISSUE_TEMPLATE/config.yml # .github/workflows/make-release.yml # docs/autoparser.md # flake.nix # ggml/src/ggml-opencl/ggml-opencl.cpp # ggml/src/ggml-openvino/ggml-openvino.cpp # ggml/src/ggml-sycl/ggml-sycl.cpp # ggml/src/ggml-sycl/norm.cpp # ggml/src/ggml-sycl/norm.hpp # ggml/src/ggml-webgpu/ggml-webgpu.cpp # models/templates/README.md # scripts/make-release-checks.sh # scripts/ui-assets.cmake # tests/test-backend-ops.cpp # tests/test-chat.cpp # tests/test-llama-archs.cpp # tools/cli/README.md # tools/completion/README.md # tools/server/CMakeLists.txt # tools/server/README.md
This commit is contained in:
+5
-57
@@ -36,60 +36,11 @@ endif()
|
||||
set(UI_CPP "${CMAKE_CURRENT_BINARY_DIR}/ui.cpp")
|
||||
set(UI_H "${CMAKE_CURRENT_BINARY_DIR}/ui.h")
|
||||
|
||||
if(CMAKE_CROSSCOMPILING)
|
||||
find_program(HOST_CXX_COMPILER NAMES g++ clang++ NO_CMAKE_FIND_ROOT_PATH)
|
||||
if(NOT HOST_CXX_COMPILER)
|
||||
message(FATAL_ERROR "UI: no host C++ compiler (g++/clang++) found to build llama-ui-embed; set -DHOST_CXX_COMPILER=<path>")
|
||||
endif()
|
||||
message(STATUS "UI: building llama-ui-embed with host compiler ${HOST_CXX_COMPILER}")
|
||||
|
||||
if(CMAKE_HOST_WIN32)
|
||||
set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host.exe")
|
||||
else()
|
||||
set(LLAMA_UI_EMBED_EXE "${CMAKE_CURRENT_BINARY_DIR}/llama-ui-embed-host")
|
||||
endif()
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${LLAMA_UI_EMBED_EXE}"
|
||||
COMMAND "${HOST_CXX_COMPILER}" -O2 -std=c++17
|
||||
-o "${LLAMA_UI_EMBED_EXE}" "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
|
||||
DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/embed.cpp"
|
||||
COMMENT "Building llama-ui-embed (host)"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
# phony target to tie it into the dependency graph
|
||||
add_custom_target(llama-ui-embed DEPENDS "${LLAMA_UI_EMBED_EXE}")
|
||||
else()
|
||||
# exclude llama-ui-embed from sanitizer flags,
|
||||
# it's a build-time-only tool, no need to instrument it
|
||||
# this is to fix TSan "memory layout is incompatible" error on CI
|
||||
get_directory_property(_llama_ui_dir_co COMPILE_OPTIONS)
|
||||
get_directory_property(_llama_ui_dir_ll LINK_LIBRARIES)
|
||||
set(_llama_ui_embed_co ${_llama_ui_dir_co})
|
||||
set(_llama_ui_embed_ll ${_llama_ui_dir_ll})
|
||||
list(FILTER _llama_ui_embed_co EXCLUDE REGEX ".*-fsanitize=.*")
|
||||
list(FILTER _llama_ui_embed_ll EXCLUDE REGEX ".*-fsanitize=.*")
|
||||
set_directory_properties(PROPERTIES
|
||||
COMPILE_OPTIONS "${_llama_ui_embed_co}"
|
||||
LINK_LIBRARIES "${_llama_ui_embed_ll}")
|
||||
|
||||
add_executable(llama-ui-embed embed.cpp)
|
||||
target_compile_features(llama-ui-embed PRIVATE cxx_std_17)
|
||||
set_target_properties(llama-ui-embed PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
|
||||
)
|
||||
set(LLAMA_UI_EMBED_EXE "$<TARGET_FILE:llama-ui-embed>")
|
||||
|
||||
# restore so the llama-ui library below keeps sanitizer instrumentation
|
||||
set_directory_properties(PROPERTIES
|
||||
COMPILE_OPTIONS "${_llama_ui_dir_co}"
|
||||
LINK_LIBRARIES "${_llama_ui_dir_ll}")
|
||||
endif()
|
||||
|
||||
# Run the provisioning script every build so source changes in tools/ui/ are
|
||||
# always picked up. The script uses copy_if_different for ui.cpp/ui.h, so the
|
||||
# library only recompiles when contents actually change.
|
||||
# Provision assets and generate ui.cpp/ui.h natively in CMake at build time.
|
||||
# The generated sources are compiled by the regular target toolchain; no
|
||||
# build-time host executable is needed (works in any cross-compile setup).
|
||||
# The script uses copy_if_different semantics, so the library below only
|
||||
# recompiles when the generated contents actually change.
|
||||
add_custom_target(llama-ui-assets ALL
|
||||
BYPRODUCTS ${UI_CPP} ${UI_H}
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
@@ -101,15 +52,12 @@ add_custom_target(llama-ui-assets ALL
|
||||
"-DHF_VERSION=${HF_UI_VERSION}"
|
||||
"-DHF_ENABLED=${LLAMA_USE_PREBUILT_UI}"
|
||||
"-DBUILD_UI=${LLAMA_BUILD_UI}"
|
||||
"-DLLAMA_UI_EMBED=${LLAMA_UI_EMBED_EXE}"
|
||||
"-DLLAMA_UI_GZIP=${LLAMA_UI_GZIP}"
|
||||
-P "${PROJECT_SOURCE_DIR}/scripts/ui-assets.cmake"
|
||||
COMMENT "Provisioning UI assets"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_dependencies(llama-ui-assets llama-ui-embed)
|
||||
|
||||
set_source_files_properties(${UI_CPP} ${UI_H} PROPERTIES GENERATED TRUE)
|
||||
|
||||
add_library(${TARGET} STATIC ${UI_CPP} ${UI_H})
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
// llama-ui-embed: generate ui.cpp / ui.h that embed UI assets as C arrays.
|
||||
//
|
||||
// Usage:
|
||||
// llama-ui-embed <out_cpp> <out_h> [<asset_dir>]
|
||||
//
|
||||
// Recursively embeds every regular file under <asset_dir>.
|
||||
// Asset names are relative paths from <asset_dir> (e.g. "_app/immutable/bundle.HASH.js").
|
||||
// Without <asset_dir>, emits an empty asset table.
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
||||
static const char * mime_from_ext(const std::string & name) {
|
||||
auto ext = name.rfind('.');
|
||||
if (ext == std::string::npos) return "application/octet-stream";
|
||||
std::string e = name.substr(ext + 1);
|
||||
if (e == "html") return "text/html; charset=utf-8";
|
||||
if (e == "css") return "text/css";
|
||||
if (e == "js") return "application/javascript";
|
||||
if (e == "json") return "application/json";
|
||||
if (e == "webmanifest") return "application/manifest+json";
|
||||
if (e == "svg") return "image/svg+xml";
|
||||
if (e == "png") return "image/png";
|
||||
if (e == "jpg" ||
|
||||
e == "jpeg") return "image/jpeg";
|
||||
if (e == "ico") return "image/x-icon";
|
||||
if (e == "woff") return "font/woff";
|
||||
if (e == "woff2") return "font/woff2";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
// Computes FNV-1a hash of the data
|
||||
static uint64_t fnv_hash(const uint8_t * data, size_t len) {
|
||||
const uint64_t fnv_prime = 0x100000001b3ULL;
|
||||
uint64_t hash = 0xcbf29ce484222325ULL;
|
||||
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
hash ^= data[i];
|
||||
hash *= fnv_prime;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
static bool read_file(const std::filesystem::path & path, std::vector<unsigned char> & out) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) {
|
||||
fprintf(stderr, "embed: cannot open %s\n", path.string().c_str());
|
||||
return false;
|
||||
}
|
||||
const auto sz = f.tellg();
|
||||
if (sz < 0) {
|
||||
return false;
|
||||
}
|
||||
f.seekg(0);
|
||||
out.resize(static_cast<size_t>(sz));
|
||||
if (sz > 0 && !f.read(reinterpret_cast<char *>(out.data()), sz)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void append_bytes_hex(std::string & out, const std::vector<unsigned char> & bytes) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
out.reserve(out.size() + bytes.size() * 5);
|
||||
for (unsigned char b : bytes) {
|
||||
out += '0';
|
||||
out += 'x';
|
||||
out += hex[b >> 4];
|
||||
out += hex[b & 0xf];
|
||||
out += ',';
|
||||
}
|
||||
}
|
||||
|
||||
static bool write_if_different(const std::string & path, const std::string & content) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (f) {
|
||||
const auto sz = f.tellg();
|
||||
if (sz >= 0 && static_cast<size_t>(sz) == content.size()) {
|
||||
std::string existing(static_cast<size_t>(sz), '\0');
|
||||
f.seekg(0);
|
||||
if (sz == 0 || f.read(existing.data(), sz)) {
|
||||
if (existing == content) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
fprintf(stderr, "embed: cannot write %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
if (!content.empty()) {
|
||||
out.write(content.data(), static_cast<std::streamsize>(content.size()));
|
||||
}
|
||||
bool ok = out.good();
|
||||
if (ok) {
|
||||
printf("embed: write output file %s\n", path.c_str());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
static std::string path_basename(const std::string & name) {
|
||||
const size_t p = name.rfind('/');
|
||||
return p == std::string::npos ? name : name.substr(p + 1);
|
||||
}
|
||||
static bool str_starts_with(const std::string & s, const char * prefix) {
|
||||
const size_t n = strlen(prefix);
|
||||
return s.size() >= n && s.compare(0, n, prefix) == 0;
|
||||
}
|
||||
static bool str_ends_with(const std::string & s, const char * suffix) {
|
||||
const size_t n = strlen(suffix);
|
||||
return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0;
|
||||
}
|
||||
|
||||
static std::string fmt(const char * pattern, ...) {
|
||||
char tmp[512];
|
||||
va_list ap;
|
||||
va_start(ap, pattern);
|
||||
const int n = vsnprintf(tmp, sizeof(tmp), pattern, ap);
|
||||
va_end(ap);
|
||||
return (n > 0) ? std::string(tmp, static_cast<size_t>(n)) : std::string();
|
||||
}
|
||||
|
||||
struct asset_entry {
|
||||
std::string name;
|
||||
std::filesystem::path path;
|
||||
};
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3 || argc > 4) {
|
||||
fprintf(stderr, "usage: %s <out_cpp> <out_h> [<asset_dir>]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string out_cpp = argv[1];
|
||||
const std::string out_h = argv[2];
|
||||
const std::string asset_dir = (argc >= 4) ? argv[3] : std::string();
|
||||
|
||||
const bool use_gzip = !asset_dir.empty() && std::filesystem::exists(asset_dir + "/_gzip");
|
||||
const std::string in_dir = use_gzip ? (asset_dir + "/_gzip") : asset_dir;
|
||||
|
||||
std::vector<asset_entry> assets;
|
||||
if (!in_dir.empty()) {
|
||||
const std::filesystem::path dir = in_dir;
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::recursive_directory_iterator it(dir, ec);
|
||||
if (ec) {
|
||||
fprintf(stderr, "embed: cannot iterate %s: %s\n", argv[3], ec.message().c_str());
|
||||
return 1;
|
||||
}
|
||||
for (const auto & entry : it) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
// name is the relative path from dir, with forward slashes
|
||||
const std::string name = entry.path().lexically_relative(dir).generic_string();
|
||||
assets.push_back({ name, entry.path() });
|
||||
}
|
||||
|
||||
// directory iteration order is unspecified; sort for reproducible output
|
||||
std::sort(assets.begin(), assets.end(),
|
||||
[](const asset_entry & a, const asset_entry & b) { return a.name < b.name; });
|
||||
}
|
||||
|
||||
const int n_assets = static_cast<int>(assets.size());
|
||||
|
||||
if (n_assets > 0) {
|
||||
using match_fn = std::function<bool(const std::string &)>;
|
||||
auto exact = [](const char * name) -> match_fn {
|
||||
return [name](const std::string & base) { return base == name; };
|
||||
};
|
||||
|
||||
struct required_check { const char * label; match_fn match; bool found; };
|
||||
required_check checks[] = {
|
||||
{ "index.html", exact("index.html"), false },
|
||||
{ "manifest.webmanifest", exact("manifest.webmanifest"), false },
|
||||
{ "sw.js", exact("sw.js"), false },
|
||||
{ "build.json", exact("build.json"), false },
|
||||
{ "version.json", exact("version.json"), false },
|
||||
{ "bundle[hash].js", [](const std::string & b) {
|
||||
return str_starts_with(b, "bundle") && str_ends_with(b, ".js");
|
||||
}, false },
|
||||
{ "bundle[hash].css", [](const std::string & b) {
|
||||
return str_starts_with(b, "bundle") && str_ends_with(b, ".css");
|
||||
}, false },
|
||||
{ "workbox[hash].js", [](const std::string & b) {
|
||||
return str_starts_with(b, "workbox") && str_ends_with(b, ".js");
|
||||
}, false },
|
||||
};
|
||||
|
||||
for (const auto & a : assets) {
|
||||
const std::string base = path_basename(a.name);
|
||||
for (auto & c : checks) {
|
||||
if (!c.found) { c.found = c.match(base); }
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<const char *> missing;
|
||||
for (const auto & c : checks) {
|
||||
if (!c.found) { missing.push_back(c.label); }
|
||||
}
|
||||
if (!missing.empty()) {
|
||||
fprintf(stderr, "\ncurrent asset files:\n");
|
||||
for (const auto & a : assets) {
|
||||
fprintf(stderr, " %s\n", a.name.c_str());
|
||||
}
|
||||
fprintf(stderr, "missing required asset(s):\n");
|
||||
for (const char * m : missing) {
|
||||
fprintf(stderr, " %s\n", m);
|
||||
}
|
||||
fprintf(stderr, "hint: try cleaning your build directory: %s\n", in_dir.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::string h;
|
||||
h += "#pragma once\n\n#include <array>\n#include <string>\n\n";
|
||||
if (n_assets > 0) {
|
||||
h += "#define LLAMA_UI_HAS_ASSETS 1\n\n";
|
||||
}
|
||||
h +=
|
||||
"struct llama_ui_asset {\n"
|
||||
" std::string name;\n"
|
||||
" const unsigned char * data;\n"
|
||||
" std::size_t size;\n"
|
||||
" std::string etag;\n"
|
||||
" std::string type;\n"
|
||||
"};\n\n"
|
||||
"const llama_ui_asset * llama_ui_find_asset(const std::string & name);\n"
|
||||
"bool llama_ui_use_gzip();\n";
|
||||
h += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets();\n", n_assets);
|
||||
|
||||
std::string cpp;
|
||||
cpp += "#include \"ui.h\"\n\n";
|
||||
|
||||
if (n_assets > 0) {
|
||||
for (int i = 0; i < n_assets; i++) {
|
||||
std::vector<unsigned char> bytes;
|
||||
if (!read_file(assets[i].path, bytes)) {
|
||||
return 1;
|
||||
}
|
||||
if (bytes.empty()) {
|
||||
fprintf(stderr, "embed: empty file: %s\n", assets[i].path.generic_string().c_str());
|
||||
return 1;
|
||||
}
|
||||
cpp += fmt("static const unsigned char asset_%d_data[] = {", i);
|
||||
append_bytes_hex(cpp, bytes);
|
||||
|
||||
// note: this is a simple hash for cache busting, not a cryptographic hash; fnv is enough here
|
||||
const auto hash = fnv_hash(bytes.data(), bytes.size());
|
||||
|
||||
cpp += fmt("};\nstatic const std::size_t asset_%d_size = %zu;\n",
|
||||
i, bytes.size());
|
||||
cpp += fmt("static const char asset_%d_etag[] = \"\\\"0x%016" PRIx64 "\\\"\";\n\n",
|
||||
i, hash);
|
||||
}
|
||||
|
||||
cpp += fmt("static const std::array<llama_ui_asset, %d> g_assets = {{\n", n_assets);
|
||||
for (int i = 0; i < n_assets; i++) {
|
||||
const std::string & name = assets[i].name;
|
||||
cpp += fmt(" { \"%s\", asset_%d_data, asset_%d_size, asset_%d_etag, \"%s\" },\n",
|
||||
name.c_str(), i, i, i, mime_from_ext(name));
|
||||
}
|
||||
cpp += "}};\n\n";
|
||||
|
||||
cpp +=
|
||||
"const llama_ui_asset * llama_ui_find_asset(const std::string & name) {\n"
|
||||
" for (const auto & a : g_assets) {\n"
|
||||
" if (a.name == name) {\n"
|
||||
" return &a;\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" return nullptr;\n"
|
||||
"}\n";
|
||||
cpp += fmt("const std::array<llama_ui_asset, %d> & llama_ui_get_assets() {\n", n_assets);
|
||||
cpp += " return g_assets;\n"
|
||||
"}\n";
|
||||
} else {
|
||||
cpp +=
|
||||
"const llama_ui_asset * llama_ui_find_asset(const std::string &) {\n"
|
||||
" return nullptr;\n"
|
||||
"}\n"
|
||||
"const std::array<llama_ui_asset, 0> & llama_ui_get_assets() {\n"
|
||||
" static const std::array<llama_ui_asset, 0> empty{};\n"
|
||||
" return empty;\n"
|
||||
"}\n";
|
||||
}
|
||||
cpp += fmt("bool llama_ui_use_gzip() { return %s; }\n", use_gzip ? "true" : "false");
|
||||
|
||||
bool ok = true;
|
||||
ok = write_if_different(out_h, h) && ok;
|
||||
ok = write_if_different(out_cpp, cpp) && ok;
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
Vendored
-1
@@ -137,7 +137,6 @@ declare global {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
idxThemeStyle?: number;
|
||||
idxCodeBlock?: number;
|
||||
|
||||
// File System Access API - not in the DOM lib and unavailable in some browsers
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
|
||||
<div>
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem bind:textareaElement class={className} {message} />
|
||||
{:else if mcpPromptExtra}
|
||||
@@ -425,25 +425,3 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The browser skips layout and paint for messages outside the
|
||||
* viewport. contain-intrinsic-size reuses the last rendered size
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-1
@@ -82,8 +82,11 @@
|
||||
let lastUserMessageHeight = $state(0);
|
||||
let assistantMarginTop = $state(0);
|
||||
|
||||
// The measured CSS vars feed the :last-child min-height rule only, so only
|
||||
// the last assistant message needs them. Reading isLastAssistantMessage
|
||||
// here also re-runs the effect when this message stops being the last.
|
||||
$effect(() => {
|
||||
if (!assistantEl) return;
|
||||
if (!assistantEl || !isLastAssistantMessage) return;
|
||||
|
||||
assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
|
||||
|
||||
|
||||
+16
-6
@@ -13,7 +13,12 @@
|
||||
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection, DatabaseMessageExtra } from '$lib/types';
|
||||
import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils';
|
||||
import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
@@ -26,11 +31,16 @@
|
||||
|
||||
let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const searchResults = $derived(extractSearchResults(section.toolResult));
|
||||
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
|
||||
const isSearchCall = $derived(
|
||||
searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName))
|
||||
);
|
||||
// Runs for every tool block on mount, before the body renders: the cheap
|
||||
// content prefilter and the tool-name allow-list come first so blobs from
|
||||
// exec/file tools are never line-split or JSON-parsed here
|
||||
const isSearchCall = $derived.by(() => {
|
||||
if (looksLikeSearchResult(section.toolResult)) {
|
||||
return extractSearchResults(section.toolResult).length > 0;
|
||||
}
|
||||
|
||||
return isWebSearchToolName(section.toolName) && extractSearchQuery(section.toolArgs).length > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isSearchCall}
|
||||
|
||||
+9
-5
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { parseEditFileMeta } from './parsers/edit-file';
|
||||
import { parseEditFileMeta, parseEditFileTitleMeta } from './parsers/edit-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
@@ -16,10 +16,14 @@
|
||||
|
||||
let { isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||
const editFileMeta = $derived(parseEditFileTitleMeta(section));
|
||||
// body-only: the full meta parses the embedded edit strings, and these
|
||||
// deriveds are read solely from the children snippet, which renders only
|
||||
// while the block is expanded
|
||||
const editFileBody = $derived(parseEditFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const editDiffs = $derived(
|
||||
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
(editFileBody?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -45,11 +49,11 @@
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.edits.length > 0}
|
||||
{:else if meta && editFileBody && editFileBody.edits.length > 0}
|
||||
{#each editDiffs as diffLines, ei (ei)}
|
||||
<div class={ei === 0 ? '' : 'mt-3'}>
|
||||
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Edit {ei + 1} of {meta.edits.length}
|
||||
Edit {ei + 1} of {editFileBody.edits.length}
|
||||
</div>
|
||||
|
||||
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { parseWriteFileMeta } from './parsers/write-file';
|
||||
import { parseWriteFileMeta, parseWriteFileTitleMeta } from './parsers/write-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
@@ -17,7 +17,11 @@
|
||||
|
||||
let { isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||
const writeFileMeta = $derived(parseWriteFileTitleMeta(section));
|
||||
// body-only: the full meta parses the embedded file content, and this
|
||||
// derived is read solely from the children snippet, which renders only
|
||||
// while the block is expanded
|
||||
const writeFileBody = $derived(parseWriteFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
@@ -45,7 +49,7 @@
|
||||
</div>
|
||||
{:else if meta}
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.content}
|
||||
code={writeFileBody?.content ?? ''}
|
||||
language={meta.language}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
|
||||
+40
@@ -4,6 +4,7 @@
|
||||
// args-present check, JSON parse) - keeping them here lets each parser
|
||||
// stay focused on its own format quirks.
|
||||
|
||||
import { TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types/agentic';
|
||||
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||
@@ -28,6 +29,45 @@ function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Compiled per key on first use; the key set is tiny and fixed.
|
||||
const toolArgStringRegexes = new Map<string, RegExp>();
|
||||
|
||||
/**
|
||||
* Extract a string field from a JSON tool-args blob without parsing the
|
||||
* whole document. write_file and edit_file args embed full file contents,
|
||||
* yet the block title needs only the path; a targeted key match plus a
|
||||
* JSON.parse of the captured string literal alone keeps title rendering
|
||||
* O(path) instead of O(blob). Returns undefined when the key is missing
|
||||
* or its value is not a string; callers fall back to the full parse.
|
||||
*/
|
||||
export function extractToolArgString(
|
||||
toolArgs: string,
|
||||
keys: readonly string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
let pattern = toolArgStringRegexes.get(key);
|
||||
|
||||
if (!pattern) {
|
||||
pattern = new RegExp(TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE.replace('{key}', key));
|
||||
toolArgStringRegexes.set(key, pattern);
|
||||
}
|
||||
|
||||
const match = pattern.exec(toolArgs);
|
||||
|
||||
if (!match) continue;
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(`"${match[1]}"`);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
} catch {
|
||||
// fall through to the next key; the full parse is the fallback
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a section's toolArgs against an expected tool name. Returns
|
||||
* `null` when:
|
||||
|
||||
+45
-17
@@ -3,26 +3,12 @@
|
||||
// rendering), plus the result blob for `result` / `edits_applied` /
|
||||
// `error` fields.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, EditFileEdit, EditFileMeta, EditFileTitleMeta } from '$lib/types';
|
||||
import { tryParseToolResultObject } from '$lib/utils';
|
||||
|
||||
export type EditFileEdit = {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
};
|
||||
|
||||
export type EditFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
edits: EditFileEdit[];
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
|
||||
|
||||
@@ -79,3 +65,45 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-tier meta for edit_file blocks: everything the header and status
|
||||
* pill render, obtained without parsing the embedded edit strings. The path
|
||||
* comes from a targeted key extraction; the full parse runs only as a
|
||||
* fallback for arg shapes the extraction can't see.
|
||||
*/
|
||||
export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null {
|
||||
if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null;
|
||||
|
||||
let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
|
||||
|
||||
if (!rawPath) {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
|
||||
const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
|
||||
|
||||
if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
|
||||
}
|
||||
|
||||
if (!rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
|
||||
let resultMessage: string | undefined;
|
||||
let editsApplied: number | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
if (typeof resultObj?.error === 'string') {
|
||||
errorMessage = resultObj.error;
|
||||
} else if (resultObj) {
|
||||
if (typeof resultObj.result === 'string') {
|
||||
resultMessage = resultObj.result;
|
||||
}
|
||||
|
||||
if (Number.isFinite(Number(resultObj.edits_applied))) {
|
||||
editsApplied = Number(resultObj.edits_applied);
|
||||
}
|
||||
}
|
||||
|
||||
return { editsApplied, errorMessage, fileName, filePath: rawPath, resultMessage };
|
||||
}
|
||||
|
||||
+14
-6
@@ -6,6 +6,7 @@
|
||||
// are handled.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { JSON_ARRAY_OPEN, JSON_OBJECT_OPEN } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
|
||||
@@ -38,14 +39,21 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe
|
||||
// do we scan raw lines for the `Error:` prefix.
|
||||
let parsedObject: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
// Successful sandbox output is a JSON array, errors are objects; plain
|
||||
// text (huge console logs) fails the parse below anyway, so only try
|
||||
// when the blob starts with a JSON container
|
||||
const trimmedResult = toolResultString.trimStart();
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedObject = parsed as Record<string, unknown>;
|
||||
if (trimmedResult[0] === JSON_OBJECT_OPEN || trimmedResult[0] === JSON_ARRAY_OPEN) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmedResult);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedObject = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsedObject = null;
|
||||
}
|
||||
} catch {
|
||||
parsedObject = null;
|
||||
}
|
||||
|
||||
if (typeof parsedObject?.error === 'string') {
|
||||
|
||||
+43
-13
@@ -3,22 +3,12 @@
|
||||
// finishes) and surfaces `bytes`, `result`, and `error` from the
|
||||
// result blob.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, WriteFileMeta, WriteFileTitleMeta } from '$lib/types';
|
||||
import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
|
||||
|
||||
export type WriteFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
content: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
|
||||
|
||||
@@ -51,3 +41,43 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-tier meta for write_file blocks: everything the header and status
|
||||
* pill render, obtained without parsing the embedded file content. The path
|
||||
* comes from a targeted key extraction; the full parse runs only as a
|
||||
* fallback for arg shapes the extraction can't see.
|
||||
*/
|
||||
export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null {
|
||||
if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null;
|
||||
|
||||
let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
|
||||
|
||||
if (!rawPath) {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
|
||||
const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
|
||||
|
||||
if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
|
||||
}
|
||||
|
||||
if (!rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
const language =
|
||||
getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
|
||||
CODE_BLOCK.DEFAULT_LANGUAGE;
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
const bytesWritten =
|
||||
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
|
||||
const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined;
|
||||
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
|
||||
|
||||
return {
|
||||
bytesWritten,
|
||||
errorMessage,
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
language,
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
+20
-25
@@ -46,49 +46,44 @@
|
||||
isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false
|
||||
);
|
||||
|
||||
let permissionDismissed = $state(false);
|
||||
|
||||
const pendingPermission = $derived(
|
||||
isStreaming && isLastAssistantMessage
|
||||
? agenticStore.getPendingPermissionRequest(message.convId)
|
||||
: null
|
||||
);
|
||||
|
||||
let prevPendingRef: typeof pendingPermission = null;
|
||||
$effect(() => {
|
||||
if (pendingPermission !== prevPendingRef) {
|
||||
prevPendingRef = pendingPermission;
|
||||
// dismissal applies to the request object, so the next request ( new
|
||||
// identity ) shows the card again without any reset bookkeeping
|
||||
let dismissedPermission: typeof pendingPermission = $state(null);
|
||||
|
||||
if (pendingPermission) {
|
||||
permissionDismissed = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
const visiblePermission = $derived(
|
||||
pendingPermission && dismissedPermission !== pendingPermission ? pendingPermission : null
|
||||
);
|
||||
|
||||
function handlePermission(decision: ToolPermissionDecision) {
|
||||
permissionDismissed = true;
|
||||
dismissedPermission = pendingPermission;
|
||||
agenticStore.resolvePermission(message.convId, decision);
|
||||
}
|
||||
|
||||
let continueDismissed = $state(false);
|
||||
|
||||
const pendingContinue = $derived(
|
||||
isStreaming && isLastAssistantMessage
|
||||
? agenticStore.getPendingContinueRequest(message.convId)
|
||||
: false
|
||||
);
|
||||
|
||||
let prevContinueRef = false;
|
||||
$effect(() => {
|
||||
if (pendingContinue !== prevContinueRef) {
|
||||
prevContinueRef = pendingContinue;
|
||||
let continueDismissed = $state(false);
|
||||
|
||||
if (pendingContinue) {
|
||||
continueDismissed = false;
|
||||
}
|
||||
// the continue request is a plain boolean, so there is no identity to
|
||||
// compare against; clear the dismissal whenever no request is pending so
|
||||
// the next one starts from a clean state
|
||||
$effect(() => {
|
||||
if (!pendingContinue) {
|
||||
continueDismissed = false;
|
||||
}
|
||||
});
|
||||
|
||||
const showContinue = $derived(Boolean(pendingContinue) && !continueDismissed);
|
||||
|
||||
function handleContinue(shouldContinue: boolean) {
|
||||
continueDismissed = true;
|
||||
agenticStore.resolveContinue(message.convId, shouldContinue);
|
||||
@@ -238,15 +233,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pendingPermission && !permissionDismissed}
|
||||
{#if visiblePermission}
|
||||
<ChatMessageActionCardPermissionRequest
|
||||
onDecision={handlePermission}
|
||||
serverLabel={pendingPermission.serverLabel}
|
||||
toolName={pendingPermission.toolName}
|
||||
serverLabel={visiblePermission.serverLabel}
|
||||
toolName={visiblePermission.toolName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingContinue && !continueDismissed}
|
||||
{#if showContinue}
|
||||
<ChatMessageActionCardContinueRequest onDecision={handleContinue} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
|
||||
import LazyChatMessage from './LazyChatMessage.svelte';
|
||||
import { ChatMessageUserPending } from '$lib/components/app';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
@@ -51,8 +52,9 @@
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
// in-place edit: the store already updated activeMessages and no
|
||||
// branch is created, so sibling info stays valid without a refetch
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
},
|
||||
|
||||
editWithBranching: async (
|
||||
@@ -72,7 +74,10 @@
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
refreshAllMessages();
|
||||
|
||||
// only a branch changes sibling info; an in-place edit already
|
||||
// landed in activeMessages
|
||||
if (shouldBranch) refreshAllMessages();
|
||||
},
|
||||
|
||||
forkConversation: async (
|
||||
@@ -97,9 +102,17 @@
|
||||
const conversation = conversationsStore.activeConversation;
|
||||
|
||||
if (conversation) {
|
||||
conversationsStore.getConversationMessages(conversation.id).then((messages) => {
|
||||
allConversationMessages = messages;
|
||||
});
|
||||
// reuse the array loadConversation just read, when present; branch
|
||||
// actions fall through to a fresh fetch
|
||||
const preloaded = conversationsStore.consumeLastLoadedMessages(conversation.id);
|
||||
|
||||
if (preloaded) {
|
||||
allConversationMessages = preloaded;
|
||||
} else {
|
||||
conversationsStore.getConversationMessages(conversation.id).then((messages) => {
|
||||
allConversationMessages = messages;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allConversationMessages = [];
|
||||
}
|
||||
@@ -224,48 +237,76 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
|
||||
<ChatMessage
|
||||
{chatActions}
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
agenticStore.injectSteeringMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
<!-- Re-created per conversation, so the CSS fade-in below plays on every
|
||||
navigation into a chat route. -->
|
||||
{#key conversationsStore.activeConversation?.id ?? 'new'}
|
||||
<div class="chat-messages">
|
||||
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
|
||||
<LazyChatMessage
|
||||
{chatActions}
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
|
||||
{/each}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={chatStore.getPendingMessageExtras(convId)}
|
||||
onDelete={() => chatStore.clearPendingMessage(convId)}
|
||||
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
agenticStore.injectSteeringMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={chatStore.getPendingMessageExtras(convId)}
|
||||
onDelete={() => chatStore.clearPendingMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<style>
|
||||
/* Compositor-friendly opacity fade; the keyed block re-creates the list per
|
||||
* conversation, so the animation plays on every navigation into a chat. */
|
||||
.chat-messages {
|
||||
animation: chat-messages-fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes chat-messages-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-messages {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import ChatMessage from './ChatMessage/ChatMessage.svelte';
|
||||
import { chatStore } from '$lib/stores';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
chatActions: ChatMessageActions;
|
||||
class?: string;
|
||||
isLastAssistantMessage?: boolean;
|
||||
isLastUserMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
nextAssistantMessage?: DatabaseMessage | null;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
}
|
||||
|
||||
let {
|
||||
chatActions,
|
||||
class: className = '',
|
||||
isLastAssistantMessage = false,
|
||||
isLastUserMessage = false,
|
||||
message,
|
||||
nextAssistantMessage = null,
|
||||
siblingInfo = null,
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
// A mounted message row is a whole component tree (contexts, effects,
|
||||
// collapsibles, markdown blocks), and the cycle collector, GC and layout
|
||||
// invalidation keep walking every live object and DOM node, even for
|
||||
// rows the user never scrolls to. Mount the real tree only when the row
|
||||
// approaches the viewport; until then the row is an empty placeholder
|
||||
// that reserves its size through content-visibility.
|
||||
let mounted = $state(false);
|
||||
let wrapperEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (mounted || !wrapperEl) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
mounted = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
// pre-mount a couple of viewport heights ahead of the scroll
|
||||
// position so a fast scroll never meets an empty row
|
||||
{ rootMargin: '200% 0px' }
|
||||
);
|
||||
|
||||
observer.observe(wrapperEl);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Flows that target a row by id (pending edit) expect the message
|
||||
// component and its effects to exist; mount the target row first
|
||||
$effect(() => {
|
||||
if (chatStore.pendingEditMessageId === message.id) {
|
||||
mounted = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={wrapperEl}
|
||||
class:chat-message--synthetic={Boolean(message.isSynthetic)}
|
||||
class="chat-message"
|
||||
>
|
||||
{#if mounted}
|
||||
<ChatMessage
|
||||
{chatActions}
|
||||
class={className}
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The browser skips layout and paint for messages outside the
|
||||
* viewport. contain-intrinsic-size reuses the last rendered size
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -315,13 +315,18 @@
|
||||
<div
|
||||
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
|
||||
class={[
|
||||
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
||||
// animate the centered->bottomed move with transform, not bottom:
|
||||
// layout-property transitions need the main thread every frame and
|
||||
// stutter while a long conversation loads; transform transitions
|
||||
// run on the compositor and stay smooth
|
||||
'pointer-events-none md:sticky fixed mt-auto transition-transform duration-200',
|
||||
deviceStore.isStandalone
|
||||
? 'bottom-6 right-4 left-4'
|
||||
: deviceStore.isIOSSafari
|
||||
? 'bottom-1 left-2 right-2'
|
||||
: 'bottom-2 right-2 left-2',
|
||||
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
||||
'md:bottom-4',
|
||||
isEmpty ? 'md:translate-y-[calc(-50dvh+8rem)] 2xl:translate-y-[calc(-50dvh+5rem)]' : ''
|
||||
]}
|
||||
>
|
||||
<ChatScreenGreeting {isEmpty} />
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/katex-custom.scss';
|
||||
import { getMarkdownProcessor, type MarkdownProcessor } from './markdown-processor';
|
||||
import {
|
||||
getCodeInfoFromTarget,
|
||||
getHastNodeId,
|
||||
getMdastNodeHash,
|
||||
isAppendMode
|
||||
} from './markdown-utils';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
ActionIconCopyToClipboard,
|
||||
CodeBlockActions,
|
||||
@@ -38,10 +27,10 @@
|
||||
MERMAID_WRAPPER_CLASS,
|
||||
SETTINGS_KEYS,
|
||||
SVG,
|
||||
TOGGLE_SOURCE_BTN_CLASS
|
||||
TOGGLE_SOURCE_BTN_CLASS,
|
||||
UI_DATA_ATTRS
|
||||
} from '$lib/constants';
|
||||
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
@@ -58,17 +47,8 @@
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import { mode } from 'mode-watcher';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -144,44 +124,6 @@
|
||||
const transformCache = new SvelteMap<string, string>();
|
||||
let previousContent = '';
|
||||
|
||||
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
|
||||
|
||||
let processor = $derived(() => {
|
||||
void attachments;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
return proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes click event listeners from copy and preview buttons.
|
||||
* Called on component destroy.
|
||||
@@ -201,33 +143,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes this component's highlight.js theme style from the document head.
|
||||
* Called on component destroy to clean up injected styles.
|
||||
*/
|
||||
function cleanupHighlightTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the appropriate highlight.js theme based on dark/light mode.
|
||||
* Injects a scoped style element into the document head.
|
||||
* One shared style element for every markdown block, mirroring
|
||||
* SyntaxHighlightedCode.svelte. The old per-instance copies duplicated the
|
||||
* full theme CSS once per rendered message, which grows without bound in
|
||||
* long conversations.
|
||||
* @param isDark - Whether to load the dark theme (true) or light theme (false)
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
document
|
||||
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
|
||||
.forEach((style) => style.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.id = themeStyleId;
|
||||
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
@@ -247,7 +178,7 @@
|
||||
* @returns Object containing the HTML string and cache hash
|
||||
*/
|
||||
async function transformMdastNode(
|
||||
processorInstance: ReturnType<typeof processor>,
|
||||
processorInstance: MarkdownProcessor,
|
||||
node: unknown,
|
||||
index: number
|
||||
): Promise<{ html: string; hash: string }> {
|
||||
@@ -369,7 +300,7 @@
|
||||
|
||||
if (prefixMarkdown.trim()) {
|
||||
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
@@ -419,7 +350,7 @@
|
||||
incompleteCodeBlock = null;
|
||||
|
||||
const normalized = preprocessLaTeX(markdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalized) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const stableCount = Math.max(mdastChildren.length - 1, 0);
|
||||
@@ -858,7 +789,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupEventListeners();
|
||||
cleanupHighlightTheme();
|
||||
streamingAutoScroll.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Shared remark/rehype pipeline factory for MarkdownContent.
|
||||
//
|
||||
// The frozen plugin chain is expensive to build ( ~15 plugin instances ),
|
||||
// and MarkdownContent used to rebuild it on every processMarkdown call:
|
||||
// once per block at mount, and again on every coalesced chunk while
|
||||
// streaming. Pipelines without attachments are shared process-wide per
|
||||
// math flag; attachment-bearing pipelines are cached by the attachments
|
||||
// array identity, which changes whenever extras are updated.
|
||||
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import type { Root as HastRoot } from 'hast';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
|
||||
export interface MarkdownProcessor {
|
||||
parse(markdown: string): MdastRoot;
|
||||
run(tree: MdastRoot): Promise<HastRoot>;
|
||||
stringify(tree: HastRoot): string;
|
||||
}
|
||||
|
||||
export interface MarkdownProcessorOptions {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
disableMath?: boolean;
|
||||
}
|
||||
|
||||
const sharedPipelines = new Map<string, MarkdownProcessor>();
|
||||
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
|
||||
|
||||
function buildPipeline({
|
||||
attachments,
|
||||
disableMath = false
|
||||
}: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkLiteralHtml)
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
const pipeline = proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g. <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
|
||||
return pipeline as MarkdownProcessor;
|
||||
}
|
||||
|
||||
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
if (options.attachments && options.attachments.length > 0) {
|
||||
let cached = attachmentPipelines.get(options.attachments);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
attachmentPipelines.set(options.attachments, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
const key = String(Boolean(options.disableMath));
|
||||
|
||||
let cached = sharedPipelines.get(key);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
sharedPipelines.set(key, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * from './context-gauge-popup.constants';
|
||||
export * from './conversation-import.constants';
|
||||
export * from './binary-detection.constants';
|
||||
export * from './content-detection.constants';
|
||||
export * from './tool-call-args.constants';
|
||||
export * from './tool-ui.constants';
|
||||
export * from './cache.constants';
|
||||
export * from './chat-form.constants';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Tool-args and tool-result parsing helpers: the file tools' path field
|
||||
// aliases, the JSON container gates for result blobs, and the targeted
|
||||
// string-field pattern used for cheap title-tier extraction.
|
||||
|
||||
/**
|
||||
* Field aliases the file tools accept for the path argument. Tool contracts
|
||||
* drifted over time: some models emit `file_path` / `filePath`.
|
||||
*/
|
||||
export const TOOL_ARG_PATH_KEYS: readonly string[] = ['path', 'file_path', 'filePath'];
|
||||
|
||||
/** Opening character of a JSON object; only an object root can carry fields. */
|
||||
export const JSON_OBJECT_OPEN = '{';
|
||||
|
||||
/** Opening character of a JSON array; successful sandbox output is one. */
|
||||
export const JSON_ARRAY_OPEN = '[';
|
||||
|
||||
/**
|
||||
* Matches `"<key>": "<value>"` in a JSON args blob ( whitespace between
|
||||
* tokens allowed ), capturing the raw string literal so only that literal
|
||||
* gets decoded; escaped quotes stay inside the value group. `{key}` is
|
||||
* replaced with the field name before use.
|
||||
*/
|
||||
export const TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE = '"{key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"';
|
||||
@@ -55,7 +55,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
string,
|
||||
{ response: string; messageId: string; model?: string | null }
|
||||
>();
|
||||
currentResponse = $state('');
|
||||
errorDialogState = $state<ErrorDialogState | null>(null);
|
||||
// true while the active conversation has a local pipe (send, attach or resume-wait)
|
||||
isLoading = $derived(this.activity.isLocal(conversationsStore.activeConversation?.id ?? ''));
|
||||
@@ -256,8 +255,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
}
|
||||
|
||||
this.chatStreamingStates.delete(convId);
|
||||
|
||||
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
|
||||
}
|
||||
clearEditMode(): void {
|
||||
this.isEditModeActive = false;
|
||||
@@ -272,11 +269,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
this.pendingMessages.delete(convId);
|
||||
}
|
||||
|
||||
/** Reset per-view state when (re)mounting the empty chat screen. */
|
||||
clearUIState(): void {
|
||||
this.currentResponse = '';
|
||||
}
|
||||
|
||||
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
|
||||
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
|
||||
|
||||
@@ -766,8 +758,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
model: model ?? this.chatStreamingStates.get(convId)?.model,
|
||||
response
|
||||
});
|
||||
|
||||
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
|
||||
}
|
||||
|
||||
setEditModeActive(handler: (files: File[]) => void): void {
|
||||
@@ -1244,7 +1234,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
syncLoadingStateForChat(convId: string): void {
|
||||
const s = this.chatStreamingStates.get(convId);
|
||||
|
||||
this.currentResponse = s?.response || '';
|
||||
this.processing.setActiveConversation(convId);
|
||||
|
||||
// Sync streaming content to activeMessages so UI displays current content
|
||||
|
||||
@@ -52,6 +52,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Messages loadConversation just read, handed off once so the chat
|
||||
* screen can reuse them for sibling info instead of re-fetching the
|
||||
* whole conversation a second time.
|
||||
*/
|
||||
private lastLoadedMessages: { convId: string; messages: DatabaseMessage[] } | null = null;
|
||||
|
||||
/**
|
||||
* Memo of the last findMessageIndex() lookup. Streaming calls it once per
|
||||
* chunk for the same message, so a validated cache hit keeps that O(1)
|
||||
@@ -88,7 +95,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === id) {
|
||||
this.activeConversation = { ...this.activeConversation, ...updates };
|
||||
// field-wise, not object replacement: effects that track the active
|
||||
// conversation identity would otherwise refire on every rename or pin
|
||||
const target = this.activeConversation as unknown as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (target[key] !== value) target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,11 +215,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
|
||||
const activeId = this.activeConversation?.id;
|
||||
|
||||
if (activeId && updates.has(activeId)) {
|
||||
this.activeConversation = {
|
||||
...this.activeConversation!,
|
||||
pinned: updates.get(activeId)!
|
||||
};
|
||||
if (this.activeConversation && activeId && updates.has(activeId)) {
|
||||
this.activeConversation.pinned = updates.get(activeId)!;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.conversations.length; i++) {
|
||||
@@ -236,6 +246,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
this.preferences.resetPending();
|
||||
}
|
||||
|
||||
/** One-shot handoff of the messages the last loadConversation read. */
|
||||
consumeLastLoadedMessages(convId: string): DatabaseMessage[] | null {
|
||||
if (this.lastLoadedMessages?.convId !== convId) return null;
|
||||
|
||||
const messages = this.lastLoadedMessages.messages;
|
||||
|
||||
this.lastLoadedMessages = null;
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
@@ -509,22 +530,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
// it doesn't belong to this conversation.
|
||||
this.preferences.pendingCwd = null;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
|
||||
// set conversation and messages in one sync block so effects never see
|
||||
// the new conversation with the previous conversation's messages
|
||||
this.lastLoadedMessages = { convId, messages: allMessages };
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages = filteredMessages;
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
|
||||
this.activeMessages = messages;
|
||||
}
|
||||
this.activeMessages = conversation.currNode
|
||||
? (filterByLeafNodeId(allMessages, conversation.currNode, false) as DatabaseMessage[])
|
||||
: allMessages;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -558,7 +572,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
||||
this.activeConversation.currNode = currentLeafNodeId;
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
@@ -694,7 +708,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === targetId) {
|
||||
this.activeConversation = { ...this.activeConversation, lastModified: now };
|
||||
this.activeConversation.lastModified = now;
|
||||
}
|
||||
|
||||
DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
|
||||
@@ -710,7 +724,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
||||
this.activeConversation.currNode = nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -209,7 +209,16 @@ export type {
|
||||
export type { DesktopIconStripItem } from './navigation';
|
||||
|
||||
// Tools types
|
||||
export type { ToolEntry, ToolGroup, ToolUiEntry } from './tools';
|
||||
export type {
|
||||
EditFileEdit,
|
||||
EditFileMeta,
|
||||
EditFileTitleMeta,
|
||||
ToolEntry,
|
||||
ToolGroup,
|
||||
ToolUiEntry,
|
||||
WriteFileMeta,
|
||||
WriteFileTitleMeta
|
||||
} from './tools';
|
||||
|
||||
// Reasoning
|
||||
export type { ReasoningEffortLevel } from './reasoning';
|
||||
|
||||
Vendored
+47
@@ -31,3 +31,50 @@ export interface ToolGroup {
|
||||
serverId?: string;
|
||||
tools: ToolEntry[];
|
||||
}
|
||||
|
||||
export interface WriteFileMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
content: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Everything the write_file block title and status pill show; the full meta
|
||||
* ( with the embedded file content ) stays body-only so collapsed blocks
|
||||
* never parse the content blob. */
|
||||
export interface WriteFileTitleMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface EditFileEdit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface EditFileMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
edits: EditFileEdit[];
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Everything the edit_file block title and status pill show; the full meta
|
||||
* ( with the embedded edit strings ) stays body-only so collapsed blocks
|
||||
* never parse the args blob. */
|
||||
export interface EditFileTitleMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,89 @@ function deriveSingleTurnSections(
|
||||
return sections;
|
||||
}
|
||||
|
||||
interface TurnSectionsCacheEntry {
|
||||
content: string | undefined;
|
||||
extra: DatabaseMessageExtra[] | undefined;
|
||||
reasoningContent: string | undefined;
|
||||
toolCalls: string | undefined;
|
||||
toolMessageContents: (string | undefined)[];
|
||||
toolMessageExtras: (DatabaseMessageExtra[] | undefined)[];
|
||||
toolMessages: DatabaseMessage[];
|
||||
sections: AgenticSection[];
|
||||
}
|
||||
|
||||
const turnSectionsCache = new WeakMap<DatabaseMessage, TurnSectionsCacheEntry>();
|
||||
|
||||
function isTurnCacheValid(
|
||||
entry: TurnSectionsCacheEntry,
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[]
|
||||
): boolean {
|
||||
if (
|
||||
entry.content !== message.content ||
|
||||
entry.reasoningContent !== message.reasoningContent ||
|
||||
entry.toolCalls !== message.toolCalls ||
|
||||
entry.extra !== message.extra
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.toolMessages.length !== toolMessages.length) return false;
|
||||
|
||||
for (let i = 0; i < toolMessages.length; i++) {
|
||||
if (entry.toolMessages[i] !== toolMessages[i]) return false;
|
||||
|
||||
if (entry.toolMessageContents[i] !== toolMessages[i].content) return false;
|
||||
|
||||
if (entry.toolMessageExtras[i] !== toolMessages[i].extra) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* deriveSingleTurnSections with structural reuse for completed turns.
|
||||
*
|
||||
* deriveAgenticSections runs in a $derived invalidated per streamed chunk, but
|
||||
* only the last turn actually changes. Messages mutate in place and are never
|
||||
* replaced, so a WeakMap keyed by the turn's assistant message plus reference
|
||||
* checks on every field deriveSingleTurnSections reads detects any change. A
|
||||
* cache hit also returns the same section objects, keeping downstream props
|
||||
* stable so tool blocks skip their per-chunk re-derive. The streaming turn
|
||||
* recomputes uncached on every chunk.
|
||||
*/
|
||||
function deriveTurnSections(
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[],
|
||||
streamingToolCalls: ApiChatCompletionToolCall[],
|
||||
isStreaming: boolean
|
||||
): AgenticSection[] {
|
||||
if (isStreaming || streamingToolCalls.length > 0) {
|
||||
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
}
|
||||
|
||||
const cached = turnSectionsCache.get(message);
|
||||
|
||||
if (cached && isTurnCacheValid(cached, message, toolMessages)) {
|
||||
return cached.sections;
|
||||
}
|
||||
|
||||
const sections = deriveSingleTurnSections(message, toolMessages, [], false);
|
||||
|
||||
turnSectionsCache.set(message, {
|
||||
content: message.content,
|
||||
extra: message.extra,
|
||||
reasoningContent: message.reasoningContent,
|
||||
sections,
|
||||
toolCalls: message.toolCalls,
|
||||
toolMessageContents: toolMessages.map((tm) => tm.content),
|
||||
toolMessageExtras: toolMessages.map((tm) => tm.extra),
|
||||
toolMessages
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives display sections from structured message data.
|
||||
*
|
||||
@@ -132,13 +215,13 @@ export function deriveAgenticSections(
|
||||
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
|
||||
|
||||
if (!hasAssistantContinuations) {
|
||||
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
return deriveTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
}
|
||||
|
||||
const sections: AgenticSection[] = [];
|
||||
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
|
||||
|
||||
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
|
||||
sections.push(...deriveTurnSections(message, firstTurnToolMsgs, [], false));
|
||||
|
||||
let i = firstTurnToolMsgs.length;
|
||||
|
||||
@@ -150,7 +233,7 @@ export function deriveAgenticSections(
|
||||
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
|
||||
|
||||
sections.push(
|
||||
...deriveSingleTurnSections(
|
||||
...deriveTurnSections(
|
||||
msg,
|
||||
turnToolMsgs,
|
||||
isLastTurn ? streamingToolCalls : [],
|
||||
|
||||
@@ -105,18 +105,34 @@ export function filterByLeafNodeId(
|
||||
*/
|
||||
function findLeafNodeInMap(
|
||||
nodeMap: ReadonlyMap<string, DatabaseMessage>,
|
||||
messageId: string
|
||||
messageId: string,
|
||||
leafCache?: Map<string, string>
|
||||
): string {
|
||||
const path: string[] = [];
|
||||
|
||||
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
|
||||
|
||||
while (currentNode && currentNode.children.length > 0) {
|
||||
// Follow the last child (most recent branch)
|
||||
const cached = leafCache?.get(currentNode.id);
|
||||
|
||||
if (cached !== undefined) {
|
||||
for (const id of path) leafCache?.set(id, cached);
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
path.push(currentNode.id);
|
||||
const lastChildId = currentNode.children[currentNode.children.length - 1];
|
||||
|
||||
currentNode = nodeMap.get(lastChildId);
|
||||
}
|
||||
|
||||
return currentNode?.id ?? messageId;
|
||||
const leafId = currentNode?.id ?? messageId;
|
||||
|
||||
for (const id of path) leafCache?.set(id, leafId);
|
||||
|
||||
return leafId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +192,8 @@ export function findDescendantMessages(
|
||||
*/
|
||||
export function getMessageSiblings(
|
||||
nodeMap: ReadonlyMap<string, DatabaseMessage>,
|
||||
messageId: string
|
||||
messageId: string,
|
||||
leafCache?: Map<string, string>
|
||||
): ChatMessageSiblingInfo | null {
|
||||
const message = nodeMap.get(messageId);
|
||||
|
||||
@@ -212,7 +229,7 @@ export function getMessageSiblings(
|
||||
// Convert sibling message IDs to their corresponding leaf node IDs
|
||||
// This allows navigation between different conversation branches
|
||||
const siblingLeafIds = siblingIds.map((siblingId: string) =>
|
||||
findLeafNodeInMap(nodeMap, siblingId)
|
||||
findLeafNodeInMap(nodeMap, siblingId, leafCache)
|
||||
);
|
||||
// Find current message's position among siblings
|
||||
const currentIndex = siblingIds.indexOf(messageId);
|
||||
@@ -236,9 +253,12 @@ export function buildSiblingInfoMap(
|
||||
): Map<string, ChatMessageSiblingInfo> {
|
||||
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
|
||||
const siblingMap = new Map<string, ChatMessageSiblingInfo>();
|
||||
// Leaf walks repeat along the same child chains for every message; memoize
|
||||
// them per build so each edge is walked once instead of O(messages^2)
|
||||
const leafCache = new Map<string, string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
const info = getMessageSiblings(nodeMap, msg.id);
|
||||
const info = getMessageSiblings(nodeMap, msg.id, leafCache);
|
||||
|
||||
if (info) {
|
||||
siblingMap.set(msg.id, info);
|
||||
|
||||
@@ -285,7 +285,8 @@ export {
|
||||
extractSearchResults,
|
||||
extractSearchQuery,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from './search-results';
|
||||
|
||||
// Cache utilities
|
||||
|
||||
@@ -3,8 +3,14 @@ export function parseExecShellCommandError(
|
||||
): string | undefined {
|
||||
if (!toolResultString) return undefined;
|
||||
|
||||
// Exec results are usually large plain-text stdout; only a JSON object
|
||||
// root can carry an error field, so skip the parse otherwise
|
||||
const trimmed = toolResultString.trimStart();
|
||||
|
||||
if (trimmed[0] !== '{') return undefined;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
|
||||
@@ -15,15 +15,18 @@ export interface ExecShellExitStatus {
|
||||
}
|
||||
|
||||
// Anchor to the absolute end so intermediate "[exit code: N]" string content
|
||||
// (e.g. a shell echo) doesn't false-positive.
|
||||
// (e.g. a shell echo) doesn't false-positive. The marker is at most ~50 chars
|
||||
// with the timed-out suffix, so matching a tail slice keeps the cost constant
|
||||
// for megabyte exec outputs instead of scanning the whole blob.
|
||||
const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
|
||||
const EXIT_CODE_TAIL_SCAN = 128;
|
||||
|
||||
export function parseExecShellCommandExitStatus(
|
||||
toolResultString: string | undefined
|
||||
): ExecShellExitStatus | undefined {
|
||||
if (!toolResultString) return undefined;
|
||||
|
||||
const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
|
||||
const match = toolResultString.slice(-EXIT_CODE_TAIL_SCAN).match(EXIT_CODE_TAIL_REGEX);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
|
||||
@@ -156,6 +156,20 @@ function parseChunk(chunk: string): SearchResult | null {
|
||||
return result;
|
||||
}
|
||||
|
||||
const EMPTY_SEARCH_RESULTS: SearchResult[] = [];
|
||||
|
||||
/**
|
||||
* Cheap prefilter for the wire format: a parseable result needs both a
|
||||
* `Title:` and a `URL:` field line, so a blob missing either substring can
|
||||
* never yield a result. Two substring scans cost far less than the
|
||||
* line-split parse for the megabyte tool results exec and file tools emit.
|
||||
*/
|
||||
export function looksLikeSearchResult(text: string | undefined | null): boolean {
|
||||
if (!text) return false;
|
||||
|
||||
return text.includes('Title:') && text.includes('URL:');
|
||||
}
|
||||
|
||||
/** Bounded cache for extractSearchResults results. */
|
||||
const SEARCH_RESULTS_CACHE_MAX_SIZE = 32;
|
||||
const searchResultsCache = new Map<string, SearchResult[]>();
|
||||
@@ -168,7 +182,7 @@ const searchResultsCache = new Map<string, SearchResult[]>();
|
||||
* tool result strings.
|
||||
*/
|
||||
export function extractSearchResults(text: string | undefined | null): SearchResult[] {
|
||||
if (!text) return [];
|
||||
if (!text || !looksLikeSearchResult(text)) return EMPTY_SEARCH_RESULTS;
|
||||
|
||||
const cached = searchResultsCache.get(text);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Each tool needs to surface fields like `error`, `result`, `bytes`,
|
||||
// `edits_applied` without repeating the try/JSON.parse/object guard inline.
|
||||
|
||||
import { JSON_OBJECT_OPEN } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Parse a tool-result blob into a JSON object, or `null` if it isn't
|
||||
* one. Returns null for:
|
||||
@@ -16,8 +18,14 @@ export function tryParseToolResultObject(
|
||||
): Record<string, unknown> | null {
|
||||
if (!toolResultString) return null;
|
||||
|
||||
// Tool results are usually large plain text (file contents, stdout); only
|
||||
// a JSON object root can carry fields, so skip the parse otherwise
|
||||
const trimmed = toolResultString.trimStart();
|
||||
|
||||
if (trimmed[0] !== JSON_OBJECT_OPEN) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { APP_NAME, URL_PARAMS } from '$lib/constants';
|
||||
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
@@ -77,7 +77,6 @@
|
||||
}
|
||||
|
||||
conversationsStore.clearActiveConversation();
|
||||
chatStore.clearUIState();
|
||||
|
||||
await modelsStore.fetch();
|
||||
|
||||
|
||||
@@ -290,3 +290,114 @@ describe('hasAgenticContent', () => {
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-section cache: completed turns are immutable, so repeated
|
||||
// derivations return the same section objects - which is what keeps tool
|
||||
// block props stable while another turn streams. Every field the cache
|
||||
// compares must invalidate it; a miss here renders stale content.
|
||||
|
||||
describe('completed turn section reuse', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
anchor: makeAssistant({
|
||||
content: 'answer',
|
||||
reasoningContent: 'thinking',
|
||||
toolCalls: toolCallsJson
|
||||
}),
|
||||
tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the same section objects for unchanged inputs', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
});
|
||||
|
||||
it('recomputes when the assistant content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.content = 'edited';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('recomputes when reasoning content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.reasoningContent = 'new thinking';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when toolCalls change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.toolCalls = '[]';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when a tool result or its extras change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].content = 'new tool result';
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
|
||||
|
||||
const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].extra = [{ type: 'image' } as never];
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
|
||||
});
|
||||
|
||||
it('never reuses the streaming turn', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps completed turns stable while the last turn streams', () => {
|
||||
const anchor = makeAssistant({
|
||||
content: 'turn one',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
|
||||
const tools = [
|
||||
makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
|
||||
continuation,
|
||||
makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
|
||||
];
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
// turn one is complete: identical section objects across derivations
|
||||
expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
|
||||
// the streaming last turn recomputed: fresh section objects
|
||||
expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Sibling-info correctness for buildSiblingInfoMap, including the memoized
|
||||
// leaf resolution. A wrong leaf id here breaks branch navigation, so the
|
||||
// deep-chain and multi-branch cases below pin the resolution down.
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
|
||||
return {
|
||||
children,
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> m1 -> ... -> m depth, each node with a single child. */
|
||||
function linearChain(depth: number): DatabaseMessage[] {
|
||||
const messages = [msg('m0', null, ['m1'])];
|
||||
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe('buildSiblingInfoMap', () => {
|
||||
it('resolves the deepest leaf for every node of a long single chain', () => {
|
||||
const messages = linearChain(50);
|
||||
const map = buildSiblingInfoMap(messages);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every non-root message of the chain is an only child, and its
|
||||
// navigation target is the chain's deepest leaf
|
||||
for (const m of messages.slice(1)) {
|
||||
const info = map.get(m.id);
|
||||
|
||||
expect(info?.totalSiblings).toBe(1);
|
||||
expect(info?.siblingIds).toEqual([leafId]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling position and leaf targets on a branched tree', () => {
|
||||
// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
|
||||
const root = msg('m0', null, ['m1', 'm4']);
|
||||
const m1 = msg('m1', 'm0', ['m2']);
|
||||
const m2 = msg('m2', 'm1', ['m3', 'm6']);
|
||||
const m3 = msg('m3', 'm2');
|
||||
const m4 = msg('m4', 'm0', ['m5']);
|
||||
const m5 = msg('m5', 'm4');
|
||||
const m6 = msg('m6', 'm2');
|
||||
const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
|
||||
|
||||
// m1 and m4 share the root as parent; their nav targets are the
|
||||
// leaves of their subtrees ( m6 for the first branch, m5 for the second )
|
||||
expect(map.get(m1.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
expect(map.get(m4.id)).toMatchObject({
|
||||
currentIndex: 1,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
|
||||
// m3 and m6 are siblings under m2; both are leaves
|
||||
expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
|
||||
expect(map.get(m6.id)?.currentIndex).toBe(1);
|
||||
|
||||
// the root has no parent and reports itself
|
||||
expect(map.get(root.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [root.id],
|
||||
totalSiblings: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with findLeafNode for arbitrary nodes', () => {
|
||||
const messages = linearChain(20);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every node of the chain resolves to the deepest leaf
|
||||
for (const m of messages) {
|
||||
expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Field updates to the active conversation must keep the object identity
|
||||
// stable: effects that track the identity ( the chat screen's sibling-info
|
||||
// refresh ) refire on every identity change, which used to trigger a full
|
||||
// message refetch on every send and tool result.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: {
|
||||
getConversation: vi.fn(),
|
||||
getConversationMessages: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
updateCurrentNode: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const getConversationMock = vi.mocked(DatabaseService.getConversation);
|
||||
const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
|
||||
const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
|
||||
|
||||
function makeConversation(overrides: Partial<DatabaseConversation> = {}): DatabaseConversation {
|
||||
return {
|
||||
currNode: 'node-1',
|
||||
id: 'conv-1',
|
||||
lastModified: 1000,
|
||||
name: 'conversation',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
|
||||
getConversationMock.mockResolvedValue(conversation);
|
||||
getMessagesMock.mockResolvedValue(messages);
|
||||
|
||||
expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getConversationMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
updateCurrentNodeMock.mockReset();
|
||||
updateCurrentNodeMock.mockResolvedValue(undefined);
|
||||
vi.mocked(DatabaseService.updateConversation).mockReset();
|
||||
vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('active conversation identity', () => {
|
||||
it('hands the load read off exactly once', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
|
||||
// a second consume is a miss: branch actions must fall back to a refetch
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes currNode in place on updateCurrentNode', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
await conversationsStore.updateCurrentNode('node-2');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
|
||||
});
|
||||
|
||||
it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.name).toBe('renamed');
|
||||
expect(conversationsStore.activeConversation?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('writes lastModified in place on updateConversationTimestamp', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.updateConversationTimestamp('conv-1');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,21 @@ describe('isExitCodeSummaryLine', () => {
|
||||
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandExitStatus tail scan', () => {
|
||||
it('finds the marker at the end of a blob larger than the tail window', () => {
|
||||
// the parser matches only the last ~128 chars; a marker past that
|
||||
// window must still parse, and an earlier fake must not match
|
||||
const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
|
||||
const status = parseExecShellCommandExitStatus(blob);
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps rejecting markers that are not at the absolute end', () => {
|
||||
const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
|
||||
|
||||
expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -119,3 +120,27 @@ describe('isWebSearchToolName', () => {
|
||||
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchResults prefilter', () => {
|
||||
it('returns the shared empty array for blobs without the wire format', () => {
|
||||
// exec/file tool results never carry Title:/URL: field lines; the
|
||||
// cheap prefilter must skip the line-split parse for them
|
||||
const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
|
||||
|
||||
expect(extractSearchResults(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty result when only one required field is present', () => {
|
||||
expect(extractSearchResults('URL: https://example.com')).toEqual([]);
|
||||
expect(extractSearchResults('Title: only a title')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeSearchResult', () => {
|
||||
it('requires both Title and URL field markers', () => {
|
||||
expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
|
||||
expect(looksLikeSearchResult('URL: https://b')).toBe(false);
|
||||
expect(looksLikeSearchResult('plain stdout')).toBe(false);
|
||||
expect(looksLikeSearchResult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,15 @@ describe('tryParseToolResultObject', () => {
|
||||
expect(tryParseToolResultObject('{bad')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseToolResultObject gating', () => {
|
||||
it('parses JSON objects that start after leading whitespace', () => {
|
||||
expect(tryParseToolResultObject('\n {"result":"ok"}')).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('skips the parse for large plain-text results', () => {
|
||||
// most tool results are file contents or stdout; the gate avoids a
|
||||
// doomed JSON.parse over the whole blob
|
||||
expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import {
|
||||
parseEditFileMeta,
|
||||
parseEditFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
||||
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
||||
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
||||
@@ -7,10 +10,10 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
parseWriteFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, WriteFileMeta } from '$lib/types';
|
||||
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -223,6 +226,113 @@ describe('parseWriteFileMeta', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWriteFileTitleMeta', () => {
|
||||
it('matches the full meta for path, language and result fields', () => {
|
||||
const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' });
|
||||
const toolResult = '{"result":"wrote","bytes":42}';
|
||||
const section = makeSection(
|
||||
{ toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
const full = parseWriteFileMeta(section);
|
||||
const title = parseWriteFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.language).toBe(full?.language);
|
||||
expect(title?.bytesWritten).toBe(full?.bytesWritten);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('extracts a path with escaped characters without parsing the content blob', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts');
|
||||
});
|
||||
|
||||
it('falls back to the full parse for args the extractor can not see', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
// key written with an escaped unicode escape sequence in the name
|
||||
toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts');
|
||||
});
|
||||
|
||||
it('accepts partial args like the full parser', () => {
|
||||
const section = makeSection(
|
||||
{ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
|
||||
});
|
||||
|
||||
it('returns null for sections with a different tool name', () => {
|
||||
expect(
|
||||
parseWriteFileTitleMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"path":"/x","content":"y"}',
|
||||
toolName: BuiltInTool.SERVER_READ_FILE
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileTitleMeta', () => {
|
||||
it('matches the full meta for path and result fields', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0),
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"result":"ok","edits_applied":1}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const full = parseEditFileMeta(section);
|
||||
const title = parseEditFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.editsApplied).toBe(full?.editsApplied);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('surfaces errorMessage from the result blob without parsing args', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"error":"permission denied"}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
|
||||
expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
|
||||
it('returns null when args have no path-like field', () => {
|
||||
expect(
|
||||
parseEditFileTitleMeta(
|
||||
makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileMeta', () => {
|
||||
it('parses edits array and applies editsApplied from the result', () => {
|
||||
const section = makeSection(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Generated by scripts/ui-assets.cmake - do not edit.
|
||||
|
||||
#include "ui.h"
|
||||
|
||||
@ASSET_ARRAYS@
|
||||
#if defined(LLAMA_UI_HAS_ASSETS)
|
||||
static const std::array<llama_ui_asset, @N_ASSETS@> g_assets = {{
|
||||
@ASSET_TABLE@
|
||||
}};
|
||||
#endif
|
||||
|
||||
const llama_ui_asset * llama_ui_find_asset(const std::string & name) {
|
||||
#if defined(LLAMA_UI_HAS_ASSETS)
|
||||
for (const auto & a : g_assets) {
|
||||
if (a.name == name) {
|
||||
return &a;
|
||||
}
|
||||
}
|
||||
#else
|
||||
(void) name;
|
||||
#endif
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::array<llama_ui_asset, @N_ASSETS@> & llama_ui_get_assets() {
|
||||
#if defined(LLAMA_UI_HAS_ASSETS)
|
||||
return g_assets;
|
||||
#else
|
||||
static const std::array<llama_ui_asset, 0> empty{};
|
||||
return empty;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool llama_ui_use_gzip() {
|
||||
return @USE_GZIP@;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Generated by scripts/ui-assets.cmake - do not edit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
// Defined as 1 only when assets were embedded (tools/server checks defined()).
|
||||
#cmakedefine LLAMA_UI_HAS_ASSETS 1
|
||||
|
||||
struct llama_ui_asset {
|
||||
std::string name;
|
||||
const unsigned char * data;
|
||||
std::size_t size;
|
||||
std::string etag;
|
||||
std::string type;
|
||||
};
|
||||
|
||||
const llama_ui_asset * llama_ui_find_asset(const std::string & name);
|
||||
bool llama_ui_use_gzip();
|
||||
const std::array<llama_ui_asset, @N_ASSETS@> & llama_ui_get_assets();
|
||||
Reference in New Issue
Block a user