Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.devops/cpu.Dockerfile
#	.devops/cuda.Dockerfile
#	.devops/intel.Dockerfile
#	.devops/llama-cli-cann.Dockerfile
#	.devops/musa.Dockerfile
#	.devops/openvino.Dockerfile
#	.devops/rocm.Dockerfile
#	.devops/s390x.Dockerfile
#	.devops/vulkan.Dockerfile
#	.devops/zendnn.Dockerfile
#	.github/labeler.yml
#	docs/backend/CUDA-FEDORA.md
#	docs/ops.md
#	docs/ops/SYCL.csv
#	examples/sycl/build.sh
#	examples/sycl/win-build-sycl.bat
#	ggml/src/ggml-sycl/common.cpp
#	ggml/src/ggml-sycl/common.hpp
#	ggml/src/ggml-sycl/cpy.hpp
#	ggml/src/ggml-sycl/dmmv.cpp
#	ggml/src/ggml-sycl/ggml-sycl.cpp
#	ggml/src/ggml-sycl/mmvq.cpp
#	ggml/src/ggml-sycl/presets.hpp
#	ggml/src/ggml-sycl/set_rows.cpp
#	ggml/src/ggml-sycl/softmax.cpp
#	ggml/src/ggml-webgpu/wgsl-shaders/mul_mat_decls.tmpl
#	grammars/README.md
#	tests/test-backend-ops.cpp
#	tests/test-chat-auto-parser.cpp
#	tests/test-chat.cpp
#	tests/test-jinja.cpp
#	tools/cli/cli.cpp
This commit is contained in:
Concedo 2026-06-15 20:00:57 +08:00
commit bdbcd049b1
40 changed files with 811 additions and 171 deletions

View file

@ -134,7 +134,7 @@ common_peg_arena autoparser::build_parser(const generation_params & inputs, cons
auto response_format = p.rule("response-format", p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)));
parser = ctx.reasoning_parser + p.space() + p.choice({
p.literal("```json") + p.space() + response_format + p.space() + p.literal("```"),
response_format
p.space() + response_format + p.space()
}) + p.end();
pure_content = false;
} else if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE && jinja_caps.supports_tool_calls) {
@ -393,8 +393,7 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte
(schema_info.resolves_to_string(param_schema) ?
p.tool_arg_string_value(until_suffix) :
p.tool_arg_json_value(p.schema(
p.json(), "tool-" + name + "-arg-" + param_name + "-schema", param_schema, false)) +
p.space()) +
p.json(), "tool-" + name + "-arg-" + param_name + "-schema", param_schema, false))) +
p.tool_arg_close(p.literal(arguments.value_suffix)));
auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);

View file

@ -1229,8 +1229,8 @@ void analyze_tools::extract_argument_name_markers() {
left_result.tags["pre"] == right_result.tags["pre"] &&
left_result.tags["suffix"] == right_result.tags["suffix"]) {
// Name is inside a structure (e.g., JSON key): prefix is the shared wrapper
arguments.name_prefix = trim_whitespace(left_result.tags["pre"]);
arguments.name_suffix = trim_leading_whitespace(left_result.tags["suffix"]);
arguments.name_prefix = left_result.tags["pre"];
arguments.name_suffix = left_result.tags["suffix"];
} else if (diff.left.substr(0, ARG_FIRST.length()) == ARG_FIRST && diff.right.substr(0, ARG_SECOND.length()) == ARG_SECOND) {
// Name is directly in the diff: prefix comes from last marker in diff.prefix
auto pre_parser = build_tagged_peg_parser([&](common_peg_parser_builder & p) {
@ -1315,8 +1315,7 @@ void analyze_tools::extract_argument_value_markers() {
value_suffix = value_suffix.substr(0, end_marker_pos);
}
}
value_suffix = trim_leading_whitespace(value_suffix);
if (!value_suffix.empty()) {
if (!trim_whitespace(value_suffix).empty()) {
arguments.value_suffix = value_suffix;
}
}

View file

@ -363,7 +363,7 @@ void common_chat_peg_mapper::map(const common_peg_ast_node & node) {
}
if ((is_arg_value || is_arg_string_value) && current_tool) {
std::string value_content = std::string(trim_trailing_space(trim_leading_space(node.text, 1), 1));
std::string value_content = std::string(node.text);
std::string value_to_add;
if (value_content.empty() && is_arg_string_value) {

View file

@ -1994,6 +1994,146 @@ static common_chat_params common_chat_params_init_deepseek_v3_2(const common_cha
return data;
}
// Cohere2 MoE (a.k.a. "North Code") parser.
//
// The assistant turn is fully marker-wrapped:
// <|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>
// <|START_THINKING|>{reasoning}<|END_THINKING|>
// then EITHER content: <|START_TEXT|>{content}<|END_TEXT|>
// OR tool calls: <|START_ACTION|>[
// {"tool_call_id": "0", "tool_name": "f", "parameters": {...}}, ...
// ]<|END_ACTION|>
// <|END_OF_TURN_TOKEN|>
//
// The generation prompt forces a leading <|START_THINKING|> (when reasoning is enabled, which is
// the template default), so the model's output continues from *inside* the thinking block. The
// parser literal therefore only covers the stable <|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|> prefix
// and the reasoning rule consumes the <|START_THINKING|> ... <|END_THINKING|> markers itself,
// regardless of whether they came from the generation prompt or the generated text.
static common_chat_params common_chat_params_init_cohere2moe(const common_chat_template & tmpl,
const autoparser::generation_params & inputs) {
common_chat_params data;
const std::string TURN_START = "<|START_OF_TURN_TOKEN|>";
const std::string TURN_END = "<|END_OF_TURN_TOKEN|>";
const std::string CHATBOT = "<|CHATBOT_TOKEN|>";
const std::string USER = "<|USER_TOKEN|>";
const std::string SYSTEM = "<|SYSTEM_TOKEN|>";
const std::string THINK_START = "<|START_THINKING|>";
const std::string THINK_END = "<|END_THINKING|>";
const std::string TEXT_START = "<|START_TEXT|>";
const std::string TEXT_END = "<|END_TEXT|>";
const std::string ACTION_START = "<|START_ACTION|>";
const std::string ACTION_END = "<|END_ACTION|>";
const std::string RESULT_START = "<|START_TOOL_RESULT|>";
const std::string RESULT_END = "<|END_TOOL_RESULT|>";
// Stable prefix of the generation prompt that precedes the (forced) <|START_THINKING|> marker.
const std::string GEN_PREFIX = TURN_START + CHATBOT;
data.prompt = common_chat_template_direct_apply_impl(tmpl, inputs);
data.generation_prompt = common_chat_template_generation_prompt_impl(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
data.supports_thinking = true;
data.thinking_start_tag = THINK_START;
data.thinking_end_tag = THINK_END;
data.preserved_tokens = {
TURN_START, TURN_END, CHATBOT, USER, SYSTEM,
THINK_START, THINK_END,
TEXT_START, TEXT_END,
ACTION_START, ACTION_END,
RESULT_START, RESULT_END,
};
// Split the rendered prompt into per-role message spans. Tool results are rendered with the
// system token followed by <|START_TOOL_RESULT|>, so the "tool" delimiter must be listed before
// the plain "system" one (it is a strict superset, and the role split tries delimiters in order).
data.message_spans = common_chat_split_by_role(data.prompt, {
{ "assistant", GEN_PREFIX },
{ "user", TURN_START + USER },
{ "tool", TURN_START + SYSTEM + RESULT_START },
{ "system", TURN_START + SYSTEM },
});
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
auto include_grammar = has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE;
if (inputs.has_continuation()) {
const auto & msg = inputs.continue_msg;
data.generation_prompt = GEN_PREFIX + THINK_START + msg.reasoning_content;
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_CONTENT) {
data.generation_prompt += THINK_END + TEXT_START + msg.render_content();
}
data.prompt += data.generation_prompt;
}
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
auto generation_prompt = p.literal(GEN_PREFIX);
auto end = p.end();
// The thinking block is always present (the generation prompt forces <|START_THINKING|>).
// When extracting reasoning, capture its body; otherwise keep the whole block (markers
// included) inline as content, matching reasoning_format=NONE conventions.
common_peg_parser reasoning = p.eps();
if (extract_reasoning) {
reasoning = p.optional(p.literal(THINK_START) +
p.reasoning(p.until_one_of({ THINK_END, TEXT_START, ACTION_START })) +
p.optional(p.literal(THINK_END)));
} else {
reasoning = p.optional(p.content(p.literal(THINK_START) +
p.until_one_of({ THINK_END, TEXT_START, ACTION_START }) +
p.optional(p.literal(THINK_END))));
}
auto text_content = p.literal(TEXT_START) + p.content(p.until(TEXT_END)) + p.optional(p.literal(TEXT_END));
if (!has_tools || inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_NONE) {
return generation_prompt + reasoning + text_content + p.optional(p.literal(TURN_END)) + end;
}
auto require_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
// <|START_ACTION|>[ {"tool_call_id": "0", "tool_name": "f", "parameters": {...}}, ... ]<|END_ACTION|>
auto tool_calls = p.standard_json_tools(ACTION_START, ACTION_END, inputs.tools, inputs.parallel_tool_calls,
/* force_tool_calls = */ true,
/* name_key = */ "tool_name",
/* args_key = */ "parameters",
/* array_wrapped = */ true,
/* function_is_key = */ false,
/* call_id_key = */ "",
/* gen_call_id_key = */ "tool_call_id",
/* parameters_order = */ { "tool_call_id", "tool_name", "parameters" });
// Content and tool calls are mutually exclusive in this format.
common_peg_parser body = require_tools ? tool_calls : p.choice({ tool_calls, text_content });
return generation_prompt + reasoning + body + p.optional(p.literal(TURN_END)) + end;
});
data.parser = parser.save();
if (include_grammar) {
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
data.grammar_triggers = {
{ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, ACTION_START }
};
}
return data;
}
namespace workaround {
static void map_developer_role_to_system(json & messages) {
@ -2242,6 +2382,15 @@ std::optional<common_chat_params> common_chat_try_specialized_template(
return common_chat_params_init_kimi_k2(tmpl, params);
}
// Cohere2 MoE / North Code - marker-wrapped format with <|START_TEXT|> content and
// <|START_ACTION|> JSON tool calls. <|START_TEXT|> is unique to this template (the older
// Command-R templates use <|START_RESPONSE|>).
if (src.find("<|START_TEXT|>") != std::string::npos &&
src.find("<|START_ACTION|>") != std::string::npos) {
LOG_DBG("Using specialized template: Cohere2 MoE\n");
return common_chat_params_init_cohere2moe(tmpl, params);
}
if (is_lfm2_template(src)) {
LOG_DBG("Using specialized template: LFM2\n");
return common_chat_params_init_lfm2(tmpl, params, /* tool_list_tokens = */ true);

View file

@ -316,12 +316,22 @@ value filter_expression::execute_impl(context & ctx) {
JJ_DEBUG("Applying filter to %s", input->type().c_str());
auto set_filter_alias = [](auto & filter_id) {
if (filter_id == "count") {
filter_id = "length";
} else if (filter_id == "d") {
filter_id = "default";
} else if (filter_id == "e") {
filter_id = "escape";
} else if (filter_id == "trim") {
filter_id = "strip";
}
};
if (is_stmt<identifier>(filter)) {
auto filter_id = cast_stmt<identifier>(filter)->val;
if (filter_id == "trim") {
filter_id = "strip"; // alias
}
set_filter_alias(filter_id);
JJ_DEBUG("Applying filter '%s' to %s", filter_id.c_str(), input->type().c_str());
// TODO: Refactor filters so this coercion can be done automatically
if (!input->is_undefined() && !is_val<value_string>(input) && (
@ -345,9 +355,7 @@ value filter_expression::execute_impl(context & ctx) {
}
auto filter_id = cast_stmt<identifier>(call->callee)->val;
if (filter_id == "trim") {
filter_id = "strip"; // alias
}
set_filter_alias(filter_id);
JJ_DEBUG("Applying filter '%s' with arguments to %s", filter_id.c_str(), input->type().c_str());
func_args args(ctx);
for (const auto & arg_expr : call->args) {

View file

@ -1272,13 +1272,13 @@ common_peg_parser common_peg_parser_builder::string_content(char delimiter) {
common_peg_parser common_peg_parser_builder::double_quoted_string() {
return rule("double-quoted-string", [this]() {
return sequence({literal("\""), string_content('"'), literal("\""), space()});
return sequence({literal("\""), string_content('"'), literal("\"")});
});
}
common_peg_parser common_peg_parser_builder::single_quoted_string() {
return rule("single-quoted-string", [this]() {
return sequence({literal("'"), string_content('\''), literal("'"), space()});
return sequence({literal("'"), string_content('\''), literal("'")});
});
}
@ -1301,25 +1301,25 @@ common_peg_parser common_peg_parser_builder::json_number() {
// At EOF in partial mode, chars returns NEED_MORE → negate propagates NEED_MORE → number not committed.
// This prevents premature commits of partial numbers (e.g. "3" when "3.14" is incoming).
auto not_number_continuation = negate(chars("[0-9.eE+-]", 1, 1));
return sequence({ optional(literal("-")), int_part, optional(frac), optional(exp), not_number_continuation, space() });
return sequence({ optional(literal("-")), int_part, optional(frac), optional(exp), not_number_continuation });
});
}
common_peg_parser common_peg_parser_builder::json_string() {
return rule("json-string", [this]() {
return sequence({literal("\""), string_content('"'), literal("\""), space()});
return sequence({literal("\""), string_content('"'), literal("\"")});
});
}
common_peg_parser common_peg_parser_builder::json_bool() {
return rule("json-bool", [this]() {
return sequence({choice({literal("true"), literal("false")}), space()});
return choice({literal("true"), literal("false")});
});
}
common_peg_parser common_peg_parser_builder::json_null() {
return rule("json-null", [this]() {
return sequence({literal("null"), space()});
return literal("null");
});
}
@ -1334,8 +1334,7 @@ common_peg_parser common_peg_parser_builder::json_object() {
choice({
literal("}"),
sequence({members, ws, literal("}")})
}),
ws
})
});
});
}
@ -1350,8 +1349,7 @@ common_peg_parser common_peg_parser_builder::json_array() {
choice({
literal("]"),
sequence({elements, ws, literal("]")})
}),
ws
})
});
});
}
@ -1381,16 +1379,13 @@ common_peg_parser common_peg_parser_builder::python_number() {
common_peg_parser common_peg_parser_builder::python_bool() {
return rule("python-bool", [this]() {
return sequence({
choice({literal("True"), literal("False")}),
space()
});
return choice({literal("True"), literal("False")});
});
}
common_peg_parser common_peg_parser_builder::python_null() {
return rule("python-none", [this]() {
return sequence({literal("None"), space()});
return literal("None");
});
}

View file

@ -25,7 +25,7 @@ import gguf
from gguf.constants import GGUFValueType
# reuse model definitions from the conversion/ package
from conversion import LazyTorchTensor, ModelBase, get_model_class
from conversion import LazyTorchTensor, ModelBase, get_model_class, ModelType, get_model_architecture
logger = logging.getLogger("lora-to-gguf")
@ -396,12 +396,12 @@ if __name__ == '__main__':
hparams = ModelBase.load_hparams(dir_base_model, False)
with torch.inference_mode():
model_arch = get_model_architecture(hparams, ModelType.TEXT)
try:
model_arch = hparams.get("text_config", {}).get("architectures", hparams["architectures"])[0]
logger.info("Using model architecture: %s", model_arch)
model_class = get_model_class(model_arch)
logger.info("Using model architecture: %s", model_arch)
except NotImplementedError:
logger.error(f"Model {hparams['architectures'][0]} is not supported")
logger.error(f"Model {model_arch} is not supported")
sys.exit(1)
class LoraModel(model_class): # ty: ignore[unsupported-base]

View file

@ -256,7 +256,6 @@
#define ggml_gemm_q8_0_4x8_q8_0_generic ggml_gemm_q8_0_4x8_q8_0
#elif defined(__wasm__)
// quants.c
#define ggml_vec_dot_q4_1_q8_1_generic ggml_vec_dot_q4_1_q8_1
#define ggml_vec_dot_tq1_0_q8_K_generic ggml_vec_dot_tq1_0_q8_K
#define ggml_vec_dot_tq2_0_q8_K_generic ggml_vec_dot_tq2_0_q8_K
#define ggml_vec_dot_iq2_xxs_q8_K_generic ggml_vec_dot_iq2_xxs_q8_K

View file

@ -5358,8 +5358,9 @@ static bool ggml_backend_cuda_device_supports_op(ggml_backend_dev_t dev, const g
} break;
case GGML_OP_REPEAT:
{
// the CUDA REPEAT path only implements F32/F16; other types assert at runtime
ggml_type src0_type = op->src[0]->type;
return src0_type != GGML_TYPE_I32 && src0_type != GGML_TYPE_I16;
return src0_type == GGML_TYPE_F32 || src0_type == GGML_TYPE_F16;
} break;
case GGML_OP_REPEAT_BACK:
return op->type == GGML_TYPE_F32 && (op->src[0]->ne[2]*op->src[0]->ne[3]) <= (1 << 15);

View file

@ -1418,6 +1418,9 @@ typedef decltype(kernel_repeat<float>) kernel_repeat_t;
template [[host_name("kernel_repeat_f32")]] kernel kernel_repeat_t kernel_repeat<float>;
template [[host_name("kernel_repeat_f16")]] kernel kernel_repeat_t kernel_repeat<half>;
#if defined(GGML_METAL_HAS_BF16)
template [[host_name("kernel_repeat_bf16")]] kernel kernel_repeat_t kernel_repeat<bfloat>;
#endif
template [[host_name("kernel_repeat_i32")]] kernel kernel_repeat_t kernel_repeat<int>;
template [[host_name("kernel_repeat_i16")]] kernel kernel_repeat_t kernel_repeat<short>;

View file

@ -2516,7 +2516,8 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
clean_spaces = false;
ignore_merges = true;
} else if (
tokenizer_pre == "tiny_aya") {
tokenizer_pre == "tiny_aya" ||
tokenizer_pre == "cohere2moe") {
pre_type = LLAMA_VOCAB_PRE_TYPE_TINY_AYA;
clean_spaces = false;
} else if (

View file

@ -35,6 +35,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
"dompurify": "3.4.5",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",
@ -8651,9 +8652,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {

View file

@ -54,6 +54,7 @@
"bits-ui": "2.18.1",
"clsx": "2.1.1",
"dexie": "4.4.3",
"dompurify": "3.4.5",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-storybook": "10.4.2",

View file

@ -9,7 +9,10 @@
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
%sveltekit.head%
</head>

View file

@ -56,6 +56,7 @@
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const hasReasoningError = $derived(
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
@ -316,9 +317,13 @@
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{#if renderThinkingAsMarkdown}
<MarkdownContent content={section.content} attachments={message?.extra} />
{:else}
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{/if}
</div>
</CollapsibleContentBlock>
{:else if section.type === AgenticSectionType.REASONING_PENDING}
@ -336,9 +341,13 @@
onToggle={() => toggleExpanded(index, section)}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{#if renderThinkingAsMarkdown}
<MarkdownContent content={section.content} attachments={message?.extra} />
{:else}
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{section.content}
</div>
{/if}
</div>
</CollapsibleContentBlock>
{/if}

View file

@ -18,6 +18,8 @@
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
@ -38,11 +40,26 @@
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING,
SETTINGS_KEYS
SETTINGS_KEYS,
MERMAID_WRAPPER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_RENDERED_ATTR,
SVG_WRAPPER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
XML_LANGUAGE,
SVG_TAG_PREFIX,
SVG_SOURCE_ATTR,
SVG_RENDERED_ATTR,
SVG_INLINE_SHADOW_STYLE
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
@ -77,11 +94,32 @@
let renderedBlocks = $state<MarkdownBlock[]>([]);
let unstableBlockHtml = $state('');
let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
const streamingSvgCode = $derived.by(() => {
const block = incompleteCodeBlock;
if (!block) return null;
if (block.language === SVG_LANGUAGE) return block.code;
if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
return block.code;
return null;
});
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
let previewDialogOpen = $state(false);
let previewCode = $state('');
let previewLanguage = $state('text');
let mermaidPreviewOpen = $state(false);
let mermaidPreviewSvgHtml = $state('');
let svgPreviewLive = $state(false);
let streamingSvgHost = $state<HTMLDivElement | null>(null);
// While the zoom dialog is open on a streaming svg, mirror the live render into it
$effect(() => {
if (svgPreviewLive && liveSvgHtml) mermaidPreviewSvgHtml = liveSvgHtml;
});
// Mount the streaming svg into its shadow host on every chunk so it renders live
$effect(() => {
if (streamingSvgHost) mountSvgShadow(streamingSvgHost, liveSvgHtml, SVG_INLINE_SHADOW_STYLE);
});
let streamingCodeScrollContainer = $state<HTMLDivElement>();
@ -124,8 +162,10 @@
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
.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
@ -462,17 +502,19 @@
const target = event.target as HTMLElement;
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
if (copyBtn || previewBtn) {
const wrapper = target.closest('.mermaid-block-wrapper');
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
if (copyBtn) {
event.preventDefault();
@ -491,19 +533,70 @@
const svg = preElement.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
// Check if clicking on copy or preview button in svg block
const svgCopyBtn = target.closest(`.${SVG_WRAPPER_CLASS} .copy-code-btn`);
const svgPreviewBtn = target.closest(`.${SVG_WRAPPER_CLASS} .preview-code-btn`);
if (svgCopyBtn || svgPreviewBtn) {
const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
);
if (!preElement) return;
if (svgCopyBtn) {
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
} catch (error) {
console.error('Failed to copy svg source:', error);
}
return;
}
if (svgPreviewBtn) {
event.preventDefault();
event.stopPropagation();
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
// Open preview when clicking the svg block itself. A final block carries its
// source, a streaming block does not and is mirrored live into the dialog.
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
if (svgEl) {
const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
if (source !== null) {
mermaidPreviewSvgHtml = sanitizeSvg(source);
svgPreviewLive = false;
} else {
svgPreviewLive = true;
}
mermaidPreviewOpen = true;
return;
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest('.mermaid');
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
}
@ -515,6 +608,7 @@
mermaidPreviewOpen = open;
if (!open) {
mermaidPreviewSvgHtml = '';
svgPreviewLive = false;
}
}
@ -527,12 +621,14 @@
async function renderMermaidDiagrams() {
if (!containerRef) return;
const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
const nodes = containerRef.querySelectorAll(
`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
// This avoids needing a guard that would block node discovery.
nodes.forEach((node) => node.setAttribute('data-mermaid-rendered', 'true'));
nodes.forEach((node) => node.setAttribute(MERMAID_RENDERED_ATTR, 'true'));
// Read mode before await so Svelte tracks it reactively.
const isDark = mode.current === ColorMode.DARK;
@ -565,6 +661,34 @@
}
}
/**
* Renders svg diagrams that haven't been rendered yet.
* Sanitizes the source before injecting and marks each node so it renders once.
* An empty sanitize result keeps the raw source as escaped text.
*/
function renderSvgDiagrams() {
if (!containerRef) return;
const nodes = containerRef.querySelectorAll<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
nodes.forEach((node) => {
node.setAttribute(SVG_RENDERED_ATTR, 'true');
const source = node.getAttribute(SVG_SOURCE_ATTR) ?? node.textContent ?? '';
const clean = sanitizeSvg(source);
if (clean) {
node.textContent = '';
const host = document.createElement('div');
node.appendChild(host);
mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
}
});
}
/**
* Handles image load errors by replacing the image with a fallback UI.
* Shows a placeholder with a link to open the image in a new tab.
@ -647,6 +771,7 @@
setupCodeBlockActions();
setupImageErrorHandlers();
renderMermaidDiagrams();
renderSvgDiagrams();
}
});
@ -689,7 +814,7 @@
{/if}
{#if incompleteCodeBlock}
{#if incompleteCodeBlock.language === 'mermaid'}
{#if incompleteCodeBlock.language === MERMAID_LANGUAGE}
<div class="mermaid-block-wrapper streaming-mermaid-block">
<div class="code-block-header">
<span class="code-language">mermaid</span>
@ -705,6 +830,30 @@
<span class="mermaid-loading-text">Generating diagram...</span>
</div>
</div>
{:else if streamingSvgCode !== null}
<div class="svg-block-wrapper streaming-svg-block">
<div class="code-block-header">
<span class="code-language">svg</span>
<div class="code-block-actions">
<ActionIconCopyToClipboard
text={incompleteCodeBlock.code}
canCopy={false}
ariaLabel="Diagram incomplete"
/>
</div>
</div>
{#if liveSvgHtml}
<div class="svg-scroll-container">
<div class={SVG_BLOCK_CLASS}>
<div bind:this={streamingSvgHost}></div>
</div>
</div>
{:else}
<div class="mermaid-loading-placeholder">
<span class="mermaid-loading-text">Rendering svg...</span>
</div>
{/if}
</div>
{:else}
<div class="code-block-wrapper streaming-code-block relative">
<div class="code-block-header">

View file

@ -560,8 +560,9 @@ div.markdown-user-content :global(.table-wrapper) {
border-color: var(--primary);
}
/* Mermaid diagrams */
.markdown-content :global(pre.mermaid) {
/* Mermaid and svg blocks share the same block styling */
.markdown-content :global(pre.mermaid),
.markdown-content :global(.svg-block) {
background: transparent;
border: none;
padding: 0;
@ -572,13 +573,25 @@ div.markdown-user-content :global(.table-wrapper) {
position: relative;
}
/* The svg block fills its flex container so the shadow host has a definite width to render into */
.markdown-content :global(.svg-block) {
width: 100%;
}
/* Hide mermaid code text until rendered - prevents flash */
.markdown-content :global(pre.mermaid:not([data-mermaid-rendered])),
.markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) {
display: none;
}
.markdown-content :global(pre.mermaid:hover) {
/* Hide svg source until rendered - prevents flash. A rendered-but-unsanitized
block (oversized source) keeps its raw text visible as a safe fallback. */
.markdown-content :global(pre.svg-block:not([data-svg-rendered])) {
display: none;
}
.markdown-content :global(pre.mermaid:hover),
.markdown-content :global(.svg-block:hover) {
opacity: 0.85;
}
@ -590,8 +603,9 @@ div.markdown-user-content :global(.table-wrapper) {
padding: 3rem 1rem;
}
/* Mermaid block wrapper - matches code block styling */
.markdown-content :global(.mermaid-block-wrapper) {
/* Diagram block wrapper - matches code block styling */
.markdown-content :global(.mermaid-block-wrapper),
.markdown-content :global(.svg-block-wrapper) {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
@ -603,11 +617,13 @@ div.markdown-user-content :global(.table-wrapper) {
max-height: var(--max-message-height);
}
.markdown-content:global(.dark) :global(.mermaid-block-wrapper) {
.markdown-content:global(.dark) :global(.mermaid-block-wrapper),
.markdown-content:global(.dark) :global(.svg-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
.markdown-content :global(.mermaid-scroll-container) {
.markdown-content :global(.mermaid-scroll-container),
.markdown-content :global(.svg-scroll-container) {
min-height: 350px;
max-height: var(--max-message-height);
overflow-y: auto;
@ -618,17 +634,20 @@ div.markdown-user-content :global(.table-wrapper) {
padding: 3rem 1rem 1rem;
}
.full-height-code-blocks :global(.mermaid-block-wrapper) {
.full-height-code-blocks :global(.mermaid-block-wrapper),
.full-height-code-blocks :global(.svg-block-wrapper) {
max-height: none;
}
.full-height-code-blocks :global(.mermaid-scroll-container) {
.full-height-code-blocks :global(.mermaid-scroll-container),
.full-height-code-blocks :global(.svg-scroll-container) {
max-height: none;
overflow-y: visible;
}
/* Mermaid block uses same header styling as code blocks */
.markdown-content :global(.mermaid-block-wrapper .code-block-header) {
/* Diagram block uses same header styling as code blocks */
.markdown-content :global(.mermaid-block-wrapper .code-block-header),
.markdown-content :global(.svg-block-wrapper .code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
@ -640,14 +659,16 @@ div.markdown-user-content :global(.table-wrapper) {
right: 0;
}
.markdown-content :global(.mermaid-block-wrapper .code-block-actions) {
.markdown-content :global(.mermaid-block-wrapper .code-block-actions),
.markdown-content :global(.svg-block-wrapper .code-block-actions) {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Mermaid pre element - remove default margins */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid) {
/* Diagram pre element - remove default margins */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid),
.markdown-content :global(.svg-block-wrapper pre.svg-block) {
background: transparent;
border: none;
padding: 0;
@ -655,7 +676,6 @@ div.markdown-user-content :global(.table-wrapper) {
text-align: center;
}
/* Mermaid SVG should be bigger */
.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
width: unset !important;
height: auto;

View file

@ -4,6 +4,7 @@
*/
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
export interface PreviewState {
previewDialogOpen: boolean;
@ -106,17 +107,19 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
const target = event.target as HTMLElement;
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
if (copyBtn || previewBtn) {
const wrapper = target.closest('.mermaid-block-wrapper');
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
if (copyBtn) {
event.preventDefault();
@ -141,7 +144,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest('.mermaid');
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');

View file

@ -13,7 +13,14 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants';
import {
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_ID_ATTR
} from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
@ -43,11 +50,13 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid');
const isMermaid = className.some(
(cls) => typeof cls === 'string' && cls === MERMAID_BLOCK_CLASS
);
if (!isMermaid) return;
const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock');
const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
// Extract the mermaid syntax (text content of the pre element)
const diagramText = node.children
@ -60,22 +69,22 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
// Store the mermaid syntax in data attribute for copy functionality
node.properties = {
...node.properties,
'data-mermaid-syntax': diagramText,
'data-mermaid-id': mermaidId
[MERMAID_SYNTAX_ATTR]: diagramText,
[MERMAID_ID_ATTR]: mermaidId
};
const actions = [
createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'),
createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram')
createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'),
createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
];
const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions);
const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
const wrapper = createWrapper(
header,
node,
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
{ 'data-mermaid-id': mermaidId }
{ [MERMAID_ID_ATTR]: mermaidId }
);
// Replace pre with wrapper in parent

View file

@ -0,0 +1,80 @@
/**
* Rehype plugin to enhance svg blocks with wrapper, header, and action buttons.
*
* Wraps <pre class="svg-block"> elements with a container that includes:
* - Language label ("svg")
* - Copy button (copies svg source to clipboard)
* - Preview button (opens fullscreen preview dialog)
*
* Operates directly on the HAST tree and reuses the shared code-block builders.
*/
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import {
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
SVG_SOURCE_ATTR,
SVG_ID_ATTR
} from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
createWrapper,
generateBlockId
} from './code-block-utils';
declare global {
interface Window {
idxSvgBlock?: number;
}
}
export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
if (!isSvg) return;
const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
// Extract the svg source (text content of the pre element)
const svgSource = node.children
.map((child) => {
if (child.type === 'text') return child.value;
return '';
})
.join('');
// Store the svg source in data attribute for copy and render
node.properties = {
...node.properties,
[SVG_SOURCE_ATTR]: svgSource,
[SVG_ID_ATTR]: svgId
};
const actions = [
createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
];
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
const wrapper = createWrapper(header, node, SVG_WRAPPER_CLASS, SVG_SCROLL_CONTAINER_CLASS, {
[SVG_ID_ATTR]: svgId
});
// Replace pre with wrapper in parent
(parent.children as ElementContent[])[index] = wrapper;
});
};
};

View file

@ -1,67 +1,7 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
import { createPreTransform } from './pre-transform';
import { MERMAID_BLOCK_CLASS, MERMAID_LANGUAGE } from '$lib/constants';
/**
* Recursively extracts all text content from a HAST node.
* Handles nested elements (e.g., span wrappers from syntax highlighting).
* Converts mermaid code blocks to <pre class="mermaid"> for client-side rendering.
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
return '';
}
/**
* Rehype plugin to convert mermaid code blocks to <pre class="mermaid"> elements.
*
* Transforms:
* <pre><code class="language-mermaid">graph TD; A-->B</code></pre>
* into:
* <pre class="mermaid">graph TD; A-->B</pre>
*
* The mermaid library renders these client-side via mermaid.run().
*
* Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped
* with code block headers/buttons (they have no <code> child, so they're skipped).
*/
export const rehypeMermaidPre: Plugin<[], Root> = () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const codeElement = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
);
if (!codeElement) return;
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return;
const isMermaid = className.some(
(cls) => typeof cls === 'string' && cls === 'language-mermaid'
);
if (!isMermaid) return;
// Recursively extract text to handle nested spans from syntax highlighting
const diagramText = codeElement.children.map(extractText).join('').trim();
if (!diagramText) return;
const mermaidPre: Element = {
type: 'element',
tagName: 'pre',
properties: {
className: ['mermaid']
},
children: [{ type: 'text', value: diagramText } as Text]
};
(parent.children as ElementContent[])[index] = mermaidPre;
});
};
};
export const rehypeMermaidPre = createPreTransform(MERMAID_LANGUAGE, MERMAID_BLOCK_CLASS);

View file

@ -0,0 +1,79 @@
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
/**
* Recursively extracts all text content from a HAST node.
* Handles nested elements (e.g., span wrappers from syntax highlighting).
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
return '';
}
/**
* Builds a rehype plugin that converts <pre><code class="language-{language}">
* blocks into <pre class="{targetClass}"> elements carrying the raw text.
*
* Accepts one or more source languages, and an optional contentGuard that
* receives the trimmed text and decides whether the block qualifies. The guard
* lets a shared fence language be claimed only when its content matches, e.g.
* an xml block is converted to svg only when it starts with <svg.
*
* The result has no <code> child, so rehypeEnhanceCodeBlocks skips it. Rendering
* happens client-side, so no markup is injected at this stage. Must run BEFORE
* rehypeEnhanceCodeBlocks.
*/
export function createPreTransform(
languages: string | string[],
targetClass: string,
contentGuard?: (text: string) => boolean
): Plugin<[], Root> {
const codeClasses = (Array.isArray(languages) ? languages : [languages]).map(
(language) => `language-${language}`
);
return () => {
return (tree: Root) => {
visit(tree, 'element', (node: Element, index, parent) => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const codeElement = node.children.find(
(child): child is Element => child.type === 'element' && child.tagName === 'code'
);
if (!codeElement) return;
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return;
const matches = className.some(
(cls) => typeof cls === 'string' && codeClasses.includes(cls)
);
if (!matches) return;
// Recursively extract text to handle nested spans from syntax highlighting
const text = codeElement.children.map(extractText).join('').trim();
if (!text) return;
if (contentGuard && !contentGuard(text)) return;
const pre: Element = {
type: 'element',
tagName: 'pre',
properties: {
className: [targetClass]
},
children: [{ type: 'text', value: text } as Text]
};
(parent.children as ElementContent[])[index] = pre;
});
};
};
}

View file

@ -0,0 +1,13 @@
import { createPreTransform } from './pre-transform';
import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
/**
* Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
* Also claims xml blocks whose content starts with <svg, since models often emit
* svg inside an xml fence.
*/
export const rehypeSvgPre = createPreTransform(
[SVG_LANGUAGE, XML_LANGUAGE],
SVG_BLOCK_CLASS,
(text) => text.startsWith(SVG_TAG_PREFIX)
);

View file

@ -1,5 +1,7 @@
<script lang="ts">
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
interface Props {
svgHtml: string;
@ -7,6 +9,13 @@
let { svgHtml }: Props = $props();
let svgHost = $state<HTMLDivElement | null>(null);
// Re-mount on every svgHtml change so a live streaming svg keeps rendering while zoomed
$effect(() => {
if (svgHost) mountSvgShadow(svgHost, svgHtml, SVG_DIALOG_SHADOW_STYLE);
});
// Zoom and pan state
let scale = $state(1);
let translateX = $state(0);
@ -99,8 +108,7 @@
onpointerup={handlePointerUp}
onpointerleave={handlePointerUp}
>
<!-- eslint-disable-next-line no-at-html-tags -->
{@html svgHtml}
<div bind:this={svgHost}></div>
</div>
<MermaidPreviewControls
@ -111,16 +119,3 @@
onResetView={resetView}
/>
</div>
<style lang="postcss" scoped>
/* Styles for SVGs rendered via {@html} — no Tailwind class can target child elements */
.mermaid-preview-diagram :global(svg) {
min-height: min(50vh, 12rem);
min-width: min(80vw, 20rem);
max-width: none !important;
max-height: none !important;
height: auto !important;
width: auto !important;
display: block;
}
</style>

View file

@ -41,7 +41,7 @@
data-slot="sidebar-wrapper"
style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
'group/sidebar-wrapper flex flex-col h-dvh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
bind:this={ref}

View file

@ -1 +1,3 @@
export const MEGAPIXELS_TO_PIXELS = 1_000_000;
export const HEIC_JPEG_QUALITY = 0.85;

View file

@ -29,6 +29,7 @@ export * from './latex-protection';
export * from './literal-html';
export * from './markdown';
export * from './mermaid-blocks';
export * from './svg-blocks';
export * from './max-bundle-size';
export * from './mcp';
export * from './mcp-form';

View file

@ -1,2 +1,9 @@
export const MERMAID_WRAPPER_CLASS = 'mermaid-block-wrapper';
export const MERMAID_SCROLL_CONTAINER_CLASS = 'mermaid-scroll-container';
export const MERMAID_BLOCK_CLASS = 'mermaid';
export const MERMAID_LANGUAGE = 'mermaid';
export const MERMAID_SYNTAX_ATTR = 'data-mermaid-syntax';
export const MERMAID_ID_ATTR = 'data-mermaid-id';
export const MERMAID_RENDERED_ATTR = 'data-mermaid-rendered';

View file

@ -33,6 +33,7 @@ export const SETTINGS_KEYS = {
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_BUILD_VERSION: 'showBuildVersion',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
// Sampling
TEMPERATURE: 'temperature',
DYNATEMP_RANGE: 'dynatemp_range',

View file

@ -282,6 +282,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
label: 'Render thinking as Markdown',
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',

View file

@ -63,6 +63,10 @@ export const IMAGE_FILE_TYPES = {
[FileTypeImage.SVG]: {
extensions: [FileExtensionImage.SVG],
mimeTypes: [MimeTypeImage.SVG]
},
[FileTypeImage.HEIC]: {
extensions: [FileExtensionImage.HEIC, FileExtensionImage.HEIF],
mimeTypes: [MimeTypeImage.HEIC, MimeTypeImage.HEIF]
}
} as const;

View file

@ -0,0 +1,49 @@
export const SVG_WRAPPER_CLASS = 'svg-block-wrapper';
export const SVG_SCROLL_CONTAINER_CLASS = 'svg-scroll-container';
export const SVG_BLOCK_CLASS = 'svg-block';
export const SVG_LANGUAGE = 'svg';
export const XML_LANGUAGE = 'xml';
export const SVG_TAG_PREFIX = '<svg';
export const SVG_SOURCE_ATTR = 'data-svg-source';
export const SVG_ID_ATTR = 'data-svg-id';
export const SVG_RENDERED_ATTR = 'data-svg-rendered';
/**
* Hard size ceiling for a single inline svg block.
* Above this the source is left as raw text instead of being rendered.
*/
export const SVG_MAX_BYTES = 256 * 1024;
/**
* DOMPurify config for untrusted svg coming from model output.
*
* foreignObject and script stay forbidden unconditionally, they are the only
* inline svg vectors that execute arbitrary html or js. Everything else is
* allowed for maximum rendering compatibility: href and xlink:href stay so
* use, image, a and animateMotion work, and DOMPurify still neutralizes
* javascript: and data: uri schemes natively. External resource refs are
* allowed by design on a local first tool, the user browser fetches them.
*
* The sanitized svg is always mounted inside a shadow root (see svg-shadow),
* so an author <style> stays scoped to that root and can not reach the page.
*/
export const SVG_SANITIZE_CONFIG = {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['foreignObject', 'script']
};
/**
* Shadow root style for an inline svg block. Mirrors the centered, padded
* sizing the light dom used before the svg moved behind a shadow boundary.
*/
export const SVG_INLINE_SHADOW_STYLE =
':host{display:block;width:100%;text-align:center}svg{display:block;margin:0 auto;width:auto;height:auto;max-width:100%;max-height:70vh;min-height:8rem;padding:3rem 1rem}';
/**
* Shadow root style for the zoom dialog svg. Lets the svg grow past its
* intrinsic size so pan and zoom have room to work.
*/
export const SVG_DIALOG_SHADOW_STYLE =
':host{display:inline-block}svg{min-height:min(50vh,12rem);min-width:min(80vw,20rem);max-width:none;max-height:none;height:auto;width:auto;display:block}';

View file

@ -25,7 +25,9 @@ export enum FileTypeImage {
PNG = 'png',
GIF = 'gif',
WEBP = 'webp',
SVG = 'svg'
SVG = 'svg',
HEIC = 'heic',
HEIF = 'heif'
}
export enum FileTypeAudio {
@ -90,7 +92,9 @@ export enum FileExtensionImage {
PNG = '.png',
GIF = '.gif',
WEBP = '.webp',
SVG = '.svg'
SVG = '.svg',
HEIC = '.heic',
HEIF = '.heif'
}
export enum FileExtensionAudio {
@ -205,7 +209,9 @@ export enum MimeTypeImage {
WEBP = 'image/webp',
SVG = 'image/svg+xml',
ICO = 'image/x-icon',
ICO_MICROSOFT = 'image/vnd.microsoft.icon'
ICO_MICROSOFT = 'image/vnd.microsoft.icon',
HEIC = 'image/heic',
HEIF = 'image/heif'
}
export enum MimeTypeText {

View file

@ -53,6 +53,8 @@ export function usePwa() {
// This comparison detects server upgrades for non-PWA users.
$effect(() => {
if (!browser) return;
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value;
if (!currentVersion) return;

View file

@ -30,6 +30,8 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
case MimeTypeImage.GIF:
case MimeTypeImage.WEBP:
case MimeTypeImage.SVG:
case MimeTypeImage.HEIC:
case MimeTypeImage.HEIF:
return FileTypeCategory.IMAGE;
// Audio
@ -118,6 +120,8 @@ export function getFileTypeCategoryByExtension(filename: string): FileTypeCatego
case FileExtensionImage.GIF:
case FileExtensionImage.WEBP:
case FileExtensionImage.SVG:
case FileExtensionImage.HEIC:
case FileExtensionImage.HEIF:
return FileTypeCategory.IMAGE;
// Audio

View file

@ -0,0 +1,56 @@
import { MimeTypeImage } from '$lib/enums';
import { HEIC_JPEG_QUALITY } from '$lib/constants/image-size';
// heic requires a relatively large decoder, in order to reduce primary bundle size
// we lazily load this decoder from a CDN when needed, and cache it for future conversions
const HEIC_TO_CDN_URL = 'https://cdn.jsdelivr.net/npm/heic-to@1.5.2/dist/heic-to.js';
interface HeicToModule {
heicTo(args: { blob: Blob; type: string; quality?: number }): Promise<Blob>;
}
let modulePromise: Promise<HeicToModule> | null = null;
/**
* Lazily load the heic-to decoder from the CDN and cache it
* @returns Promise resolving to the heic-to module
*/
function getHeicTo(): Promise<HeicToModule> {
if (!modulePromise) {
modulePromise = import(/* @vite-ignore */ HEIC_TO_CDN_URL) as Promise<HeicToModule>;
}
return modulePromise;
}
/**
* Convert a HEIC/HEIF file to a compressed JPEG data URL
* @param file - The HEIC/HEIF file to convert
* @returns Promise resolving to JPEG data URL
*/
export async function heicFileToJpegDataURL(file: File | Blob): Promise<string> {
const { heicTo } = await getHeicTo();
const jpegBlob = await heicTo({
blob: file,
type: MimeTypeImage.JPEG,
quality: HEIC_JPEG_QUALITY
});
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(jpegBlob);
});
}
/**
* Check if a MIME type represents a HEIC/HEIF image
* @param mimeType - The MIME type to check
* @returns True if the MIME type is image/heic or image/heif
*/
export function isHeicMimeType(mimeType: string): boolean {
const normalized = mimeType.trim().toLowerCase();
return normalized === MimeTypeImage.HEIC || normalized === MimeTypeImage.HEIF;
}

View file

@ -1,5 +1,6 @@
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { heicFileToJpegDataURL, isHeicMimeType } from './heic-to-jpeg';
import { FileTypeCategory } from '$lib/enums';
import { SETTINGS_KEYS } from '$lib/constants';
import { modelsStore } from '$lib/stores/models.svelte';
@ -68,7 +69,7 @@ export async function processFilesToChatUploaded(
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
let preview = await readFileAsDataURL(file);
// Normalize SVG and WebP to PNG in previews
// Normalize SVG and WebP to PNG, and HEIC to compressed JPEG, in previews
if (isSvgMimeType(file.type)) {
try {
preview = await svgBase64UrlToPngDataURL(preview);
@ -81,6 +82,13 @@ export async function processFilesToChatUploaded(
} catch (err) {
console.error('Failed to convert WebP to PNG:', err);
}
} else if (isHeicMimeType(file.type)) {
try {
preview = await heicFileToJpegDataURL(file);
} catch (err) {
console.error('Failed to convert HEIC to PNG:', err);
continue;
}
}
results.push({ ...base, preview });

View file

@ -0,0 +1,22 @@
import DOMPurify from 'dompurify';
import { SVG_MAX_BYTES, SVG_SANITIZE_CONFIG, SVG_TAG_PREFIX } from '$lib/constants';
/**
* Sanitizes a raw svg string for safe inline rendering.
* Returns the cleaned svg markup, or an empty string when the input is not a
* usable svg, exceeds the size ceiling, or sanitizes to nothing. An empty
* return tells the caller to keep the raw code block instead of rendering.
*/
export function sanitizeSvg(source: string): string {
const trimmed = source.trim();
if (!trimmed || trimmed.length > SVG_MAX_BYTES) return '';
if (!trimmed.startsWith(SVG_TAG_PREFIX)) return '';
const clean = DOMPurify.sanitize(trimmed, SVG_SANITIZE_CONFIG) as unknown as string;
if (!clean || !clean.includes(SVG_TAG_PREFIX)) return '';
return clean;
}

View file

@ -0,0 +1,10 @@
/**
* Mounts svg markup inside an open shadow root on the host element.
* The shadow boundary scopes the svg <style> and its animations to the host,
* so model authored css can not reach the surrounding page. The caller passes
* markup that is already sanitized, this only isolates and sizes it.
*/
export function mountSvgShadow(host: HTMLElement, markup: string, style: string): void {
const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
root.innerHTML = markup ? `<style>${style}</style>${markup}` : '';
}

View file

@ -312,7 +312,7 @@
/>
<Sidebar.Provider bind:open={sidebarOpen}>
<div class="flex h-screen w-full">
<div class="flex h-full w-full grow">
<Sidebar.Root variant="floating" class="h-full"
><SidebarNavigation bind:this={chatSidebar} /></Sidebar.Root
>