diff --git a/common/chat-auto-parser-generator.cpp b/common/chat-auto-parser-generator.cpp index db3a6cc6f..6f825a131 100644 --- a/common/chat-auto-parser-generator.cpp +++ b/common/chat-auto-parser-generator.cpp @@ -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); diff --git a/common/chat-diff-analyzer.cpp b/common/chat-diff-analyzer.cpp index 0875c5347..ecd9c807c 100644 --- a/common/chat-diff-analyzer.cpp +++ b/common/chat-diff-analyzer.cpp @@ -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; } } diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp index 9bc5ac98b..23b5b3841 100644 --- a/common/chat-peg-parser.cpp +++ b/common/chat-peg-parser.cpp @@ -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) { diff --git a/common/chat.cpp b/common/chat.cpp index ca8cd040f..9e3b8fbf1 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -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_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); diff --git a/common/jinja/runtime.cpp b/common/jinja/runtime.cpp index 8d8d80b22..1fae7884e 100644 --- a/common/jinja/runtime.cpp +++ b/common/jinja/runtime.cpp @@ -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(filter)) { auto filter_id = cast_stmt(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(input) && ( @@ -345,9 +355,7 @@ value filter_expression::execute_impl(context & ctx) { } auto filter_id = cast_stmt(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) { diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp index e37c1ce80..310bebf73 100644 --- a/common/peg-parser.cpp +++ b/common/peg-parser.cpp @@ -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"); }); } diff --git a/convert_lora_to_gguf.py b/convert_lora_to_gguf.py index 45202b333..47c09af53 100755 --- a/convert_lora_to_gguf.py +++ b/convert_lora_to_gguf.py @@ -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] diff --git a/ggml/src/ggml-cpu/arch-fallback.h b/ggml/src/ggml-cpu/arch-fallback.h index 66a8424bf..11e721733 100644 --- a/ggml/src/ggml-cpu/arch-fallback.h +++ b/ggml/src/ggml-cpu/arch-fallback.h @@ -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 diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu index c42a83112..5beca7a04 100644 --- a/ggml/src/ggml-cuda/ggml-cuda.cu +++ b/ggml/src/ggml-cuda/ggml-cuda.cu @@ -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); diff --git a/ggml/src/ggml-metal/ggml-metal.metal b/ggml/src/ggml-metal/ggml-metal.metal index 0aea68455..072f14e68 100644 --- a/ggml/src/ggml-metal/ggml-metal.metal +++ b/ggml/src/ggml-metal/ggml-metal.metal @@ -1418,6 +1418,9 @@ typedef decltype(kernel_repeat) kernel_repeat_t; template [[host_name("kernel_repeat_f32")]] kernel kernel_repeat_t kernel_repeat; template [[host_name("kernel_repeat_f16")]] kernel kernel_repeat_t kernel_repeat; +#if defined(GGML_METAL_HAS_BF16) +template [[host_name("kernel_repeat_bf16")]] kernel kernel_repeat_t kernel_repeat; +#endif template [[host_name("kernel_repeat_i32")]] kernel kernel_repeat_t kernel_repeat; template [[host_name("kernel_repeat_i16")]] kernel kernel_repeat_t kernel_repeat; diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp index f2a47475b..f221764df 100644 --- a/src/llama-vocab.cpp +++ b/src/llama-vocab.cpp @@ -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 ( diff --git a/tools/ui/package-lock.json b/tools/ui/package-lock.json index f46a98d1a..61fa529d2 100644 --- a/tools/ui/package-lock.json +++ b/tools/ui/package-lock.json @@ -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": { diff --git a/tools/ui/package.json b/tools/ui/package.json index 6c750431f..2f47992e1 100644 --- a/tools/ui/package.json +++ b/tools/ui/package.json @@ -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", diff --git a/tools/ui/src/app.html b/tools/ui/src/app.html index db3e9edf5..e1de226dc 100644 --- a/tools/ui/src/app.html +++ b/tools/ui/src/app.html @@ -9,7 +9,10 @@ - + %sveltekit.head% diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index e21dff993..07b0489cc 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -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)} >
-
- {section.content} -
+ {#if renderThinkingAsMarkdown} + + {:else} +
+ {section.content} +
+ {/if}
{:else if section.type === AgenticSectionType.REASONING_PENDING} @@ -336,9 +341,13 @@ onToggle={() => toggleExpanded(index, section)} >
-
- {section.content} -
+ {#if renderThinkingAsMarkdown} + + {:else} +
+ {section.content} +
+ {/if}
{/if} diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 9c4c49c0c..7139d2e63 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -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([]); let unstableBlockHtml = $state(''); let incompleteCodeBlock = $state(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(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(); @@ -124,8 +162,10 @@ .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,
    ) inside Markdown tables .use(rehypeEnhanceLinks) // Add target="_blank" to links .use(rehypeMermaidPre) // Convert mermaid blocks to
    +			.use(rehypeSvgPre) // Convert svg blocks to 
     			.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('pre.mermaid[data-mermaid-syntax]');
    +			const preElement = wrapper.querySelector(
    +				`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(
    +				`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(
    +			`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}
     			
    mermaid @@ -705,6 +830,30 @@ Generating diagram...
    + {:else if streamingSvgCode !== null} +
    +
    + svg +
    + +
    +
    + {#if liveSvgHtml} +
    +
    +
    +
    +
    + {:else} +
    + Rendering svg... +
    + {/if} +
    {:else}
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index 07904f768..072db383d 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -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; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts index 554408485..80052945f 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-handlers.ts @@ -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('pre.mermaid[data-mermaid-syntax]'); + const preElement = wrapper.querySelector( + `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'); diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts index ab24e7823..f9decf206 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts @@ -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 diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts new file mode 100644 index 000000000..e5e7514ed --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts @@ -0,0 +1,80 @@ +/** + * Rehype plugin to enhance svg blocks with wrapper, header, and action buttons. + * + * Wraps
     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;
    +		});
    +	};
    +};
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    index e2270a658..61322f045 100644
    --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/mermaid-pre.ts
    @@ -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 
     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 
     elements.
    - *
    - * Transforms:
    - *   
    graph TD; A-->B
    - * into: - *
    graph TD; A-->B
    - * - * 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 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); diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts new file mode 100644 index 000000000..06848eb26 --- /dev/null +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts @@ -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
    
    + * blocks into 
     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  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;
    +			});
    +		};
    +	};
    +}
    diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
    new file mode 100644
    index 000000000..eb0e2c699
    --- /dev/null
    +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/svg-pre.ts
    @@ -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 
     for client-side rendering.
    + * Also claims xml blocks whose content starts with  text.startsWith(SVG_TAG_PREFIX)
    +);
    diff --git a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    index d4825889d..a30f585b9 100644
    --- a/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    +++ b/tools/ui/src/lib/components/app/content/MermaidPreview.svelte
    @@ -1,5 +1,7 @@