mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-08-16 03:45:03 +00:00
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:
commit
bdbcd049b1
40 changed files with 811 additions and 171 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
149
common/chat.cpp
149
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_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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
7
tools/ui/package-lock.json
generated
7
tools/ui/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export const MEGAPIXELS_TO_PIXELS = 1_000_000;
|
||||
|
||||
export const HEIC_JPEG_QUALITY = 0.85;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
49
tools/ui/src/lib/constants/svg-blocks.ts
Normal file
49
tools/ui/src/lib/constants/svg-blocks.ts
Normal 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}';
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
tools/ui/src/lib/utils/heic-to-jpeg.ts
Normal file
56
tools/ui/src/lib/utils/heic-to-jpeg.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
22
tools/ui/src/lib/utils/sanitize-svg.ts
Normal file
22
tools/ui/src/lib/utils/sanitize-svg.ts
Normal 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;
|
||||
}
|
||||
10
tools/ui/src/lib/utils/svg-shadow.ts
Normal file
10
tools/ui/src/lib/utils/svg-shadow.ts
Normal 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}` : '';
|
||||
}
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue