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:
Concedo
2026-09-08 11:59:02 +08:00
116 changed files with 3679 additions and 1158 deletions
+5 -57
View File
@@ -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})
-308
View File
@@ -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;
}
-1
View File
@@ -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>
@@ -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));
@@ -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}
@@ -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}&nbsp;of&nbsp;{meta.edits.length}
Edit {ei + 1}&nbsp;of&nbsp;{editFileBody.edits.length}
</div>
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
@@ -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}
@@ -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:
@@ -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 };
}
@@ -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') {
@@ -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
};
}
@@ -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;
}
+1
View File
@@ -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;
}
/**
+10 -1
View File
@@ -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';
+47
View File
@@ -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;
}
+86 -3
View File
@@ -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 : [],
+25 -5
View File
@@ -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);
+2 -1
View File
@@ -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;
+15 -1
View File
@@ -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);
+9 -1
View File
@@ -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>;
+1 -2
View File
@@ -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]);
});
});
+95
View File
@@ -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();
});
});
+26 -1
View File
@@ -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();
});
});
+113 -3
View File
@@ -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(
+36
View File
@@ -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@;
}
+21
View File
@@ -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();