diff --git a/common/chat-parser-xml-toolcall.cpp b/common/chat-parser-xml-toolcall.cpp new file mode 100644 index 000000000..734989555 --- /dev/null +++ b/common/chat-parser-xml-toolcall.cpp @@ -0,0 +1,861 @@ +#include "chat.h" +#include "chat-parser.h" +#include "common.h" +#include "json-partial.h" +#include "json-schema-to-grammar.h" +#include "log.h" +#include "regex-partial.h" + +using json = nlohmann::ordered_json; + +class xml_toolcall_syntax_exception : public std::runtime_error { + public: + xml_toolcall_syntax_exception(const std::string & message) : std::runtime_error(message) {} +}; + +template +inline void sort_uniq(std::vector &vec) { + std::sort(vec.begin(), vec.end()); + vec.erase(std::unique(vec.begin(), vec.end()), vec.end()); +} + +template +inline bool all_space(const T &str) { + return std::all_of(str.begin(), str.end(), [](unsigned char ch) { return std::isspace(ch); }); +} + +static size_t utf8_truncate_safe(const std::string_view s) { + size_t len = s.size(); + if (len == 0) return 0; + size_t i = len; + for (size_t back = 0; back < 4 && i > 0; ++back) { + --i; + unsigned char c = s[i]; + if ((c & 0x80) == 0) { + return len; + } else if ((c & 0xC0) == 0xC0) { + size_t expected_len = 0; + if ((c & 0xE0) == 0xC0) expected_len = 2; + else if ((c & 0xF0) == 0xE0) expected_len = 3; + else if ((c & 0xF8) == 0xF0) expected_len = 4; + else return i; + if (len - i >= expected_len) { + return len; + } else { + return i; + } + } + } + return len - std::min(len, size_t(3)); +} + +inline void utf8_truncate_safe_resize(std::string &s) { + s.resize(utf8_truncate_safe(s)); +} + +inline std::string_view utf8_truncate_safe_view(const std::string_view s) { + return s.substr(0, utf8_truncate_safe(s)); +} + +static std::optional try_find_2_literal_splited_by_spaces(common_chat_msg_parser & builder, const std::string & literal1, const std::string & literal2) { + if (literal1.size() == 0) return builder.try_find_literal(literal2); + const auto saved_pos = builder.pos(); + while (auto res = builder.try_find_literal(literal1)) { + builder.consume_spaces(); + const auto match_len = std::min(literal2.size(), builder.input().size() - builder.pos()); + if (builder.input().compare(builder.pos(), match_len, literal2, 0, match_len) == 0) { + if (res->prelude.size() != res->groups[0].begin - saved_pos) { + res->prelude = builder.str({saved_pos, res->groups[0].begin}); + } + builder.move_to(builder.pos() + match_len); + res->groups[0].end = builder.pos(); + GGML_ASSERT(res->groups[0].begin != res->groups[0].end); + return res; + } + builder.move_to(res->groups[0].begin + 1); + } + builder.move_to(saved_pos); + return std::nullopt; +} + +/** + * make a GBNF that accept any strings except those containing any of the forbidden strings. + */ +std::string make_gbnf_excluding(std::vector forbids) { + constexpr auto charclass_escape = [](unsigned char c) -> std::string { + if (c == '\\' || c == ']' || c == '^' || c == '-') { + std::string s = "\\"; + s.push_back((char)c); + return s; + } + if (isprint(c)) { + return std::string(1, (char)c); + } + char buf[16]; + snprintf(buf, 15, "\\x%02X", c); + return std::string(buf); + }; + constexpr auto build_expr = [charclass_escape](auto self, const std::vector& forbids, int l, int r, int depth) -> std::string { + std::vector>> children; + int i = l; + while (i < r) { + const std::string &s = forbids[i]; + if ((int)s.size() == depth) { + ++i; + continue; + } + unsigned char c = (unsigned char)s[depth]; + int j = i; + while (j < r && (int)forbids[j].size() > depth && + (unsigned char)forbids[j][depth] == c) { + ++j; + } + children.push_back({c, {i, j}}); + i = j; + } + std::vector alts; + if (!children.empty()) { + std::string cls; + for (auto &ch : children) cls += charclass_escape(ch.first); + alts.push_back(std::string("[^") + cls + "]"); + } + for (auto &ch : children) { + std::string childExpr = self(self, forbids, ch.second.first, ch.second.second, depth+1); + if (!childExpr.empty()) { + std::string quoted_ch = "\""; + if (ch.first == '\\') quoted_ch += "\\\\"; + else if (ch.first == '"') quoted_ch += "\\\""; + else if (isprint(ch.first)) quoted_ch.push_back(ch.first); + else { + char buf[16]; + snprintf(buf, 15, "\\x%02X", ch.first); + quoted_ch += buf; + } + quoted_ch += "\""; + std::string branch = quoted_ch + std::string(" ") + childExpr; + alts.push_back(branch); + } + } + if (alts.empty()) return ""; + std::ostringstream oss; + oss << "( "; + for (size_t k = 0; k < alts.size(); ++k) { + if (k) oss << " | "; + oss << alts[k]; + } + oss << " )"; + return oss.str(); + }; + if (forbids.empty()) return "( . )*"; + sort(forbids.begin(), forbids.end()); + std::string expr = build_expr(build_expr, forbids, 0, forbids.size(), 0); + if (expr.empty()) { + std::string cls; + for (auto &s : forbids) if (!s.empty()) cls += charclass_escape((unsigned char)s[0]); + expr = std::string("( [^") + cls + "] )"; + } + if (forbids.size() == 1) + return expr + "*"; + else + return std::string("( ") + expr + " )*"; +} + +/** + * Build grammar for xml-style tool call + * form.scope_start and form.scope_end can be empty. + * Requires data.format for model-specific hacks. + */ +void build_grammar_xml_tool_call(common_chat_params & data, const json & tools, const struct xml_tool_call_format & form) { + GGML_ASSERT(!form.tool_start.empty()); + GGML_ASSERT(!form.tool_sep.empty()); + GGML_ASSERT(!form.key_start.empty()); + GGML_ASSERT(!form.val_end.empty()); + GGML_ASSERT(!form.tool_end.empty()); + + std::string key_val_sep = form.key_val_sep; + if (form.key_val_sep2) { + key_val_sep += "\n"; + key_val_sep += *form.key_val_sep2; + } + GGML_ASSERT(!key_val_sep.empty()); + + if (tools.is_array() && !tools.empty()) { + data.grammar = build_grammar([&](const common_grammar_builder &builder) { + auto string_arg_val = form.last_val_end ? + builder.add_rule("string-arg-val", make_gbnf_excluding({form.val_end, *form.last_val_end})) : + builder.add_rule("string-arg-val", make_gbnf_excluding({form.val_end})); + + std::vector tool_rules; + for (const auto & tool : tools) { + if (!tool.contains("type") || tool.at("type") != "function" || !tool.contains("function")) { + LOG_WRN("Skipping tool without function: %s", tool.dump(2).c_str()); + continue; + } + const auto & function = tool.at("function"); + if (!function.contains("name") || !function.at("name").is_string()) { + LOG_WRN("Skipping invalid function (invalid name): %s", function.dump(2).c_str()); + continue; + } + if (!function.contains("parameters") || !function.at("parameters").is_object()) { + LOG_WRN("Skipping invalid function (invalid parameters): %s", function.dump(2).c_str()); + continue; + } + std::string name = function.at("name"); + auto parameters = function.at("parameters"); + builder.resolve_refs(parameters); + + struct parameter_rule { + std::string symbol_name; + bool is_required; + }; + std::vector arg_rules; + if (!parameters.contains("properties") || !parameters.at("properties").is_object()) { + LOG_WRN("Skipping invalid function (invalid properties): %s", function.dump(2).c_str()); + continue; + } else { + std::vector requiredParameters; + if (parameters.contains("required")) { + try { parameters.at("required").get_to(requiredParameters); } + catch (const std::runtime_error&) { + LOG_WRN("Invalid function required parameters, ignoring: %s", function.at("required").dump(2).c_str()); + } + } + sort_uniq(requiredParameters); + for (const auto & [key, value] : parameters.at("properties").items()) { + std::string quoted_key = key; + bool required = std::binary_search(requiredParameters.begin(), requiredParameters.end(), key); + if (form.key_start.back() == '"' && key_val_sep[0] == '"') { + quoted_key = gbnf_format_literal(key); + quoted_key = quoted_key.substr(1, quoted_key.size() - 2); + } + arg_rules.push_back(parameter_rule {builder.add_rule("func-" + name + "-kv-" + key, + gbnf_format_literal(form.key_start) + " " + + gbnf_format_literal(quoted_key) + " " + + gbnf_format_literal(key_val_sep) + " " + + ((value.contains("type") && value["type"].is_string() && value["type"] == "string" && (!form.raw_argval || *form.raw_argval)) ? + (form.raw_argval ? + string_arg_val : + "( " + string_arg_val + " | " + builder.add_schema(name + "-arg-" + key, value) + " )" + ) : + builder.add_schema(name + "-arg-" + key, value) + ) + ), required}); + } + } + + auto next_arg_with_sep = builder.add_rule(name + "-last-arg-end", form.last_val_end ? gbnf_format_literal(*form.last_val_end) : gbnf_format_literal(form.val_end)); + decltype(next_arg_with_sep) next_arg = "\"\""; + for (auto i = arg_rules.size() - 1; /* i >= 0 && */ i < arg_rules.size(); --i) { + std::string include_this_arg = arg_rules[i].symbol_name + " " + next_arg_with_sep; + next_arg = builder.add_rule(name + "-arg-after-" + std::to_string(i), arg_rules[i].is_required ? + include_this_arg : "( " + include_this_arg + " ) | " + next_arg + ); + include_this_arg = gbnf_format_literal(form.val_end) + " " + include_this_arg; + next_arg_with_sep = builder.add_rule(name + "-arg-after-" + std::to_string(i) + "-with-sep", arg_rules[i].is_required ? + include_this_arg : "( " + include_this_arg + " ) | " + next_arg_with_sep + ); + } + + std::string quoted_name = name; + if (form.tool_start.back() == '"' && form.tool_sep[0] == '"') { + quoted_name = gbnf_format_literal(name); + quoted_name = quoted_name.substr(1, quoted_name.size() - 2); + } + quoted_name = gbnf_format_literal(quoted_name); + // Kimi-K2 uses functions.{{ tool_call['function']['name'] }}:{{ loop.index }} as function name + if (data.format == COMMON_CHAT_FORMAT_KIMI_K2) { + quoted_name = "\"functions.\" " + quoted_name + " \":\" [0-9]+"; + } + tool_rules.push_back(builder.add_rule(name + "-call", + gbnf_format_literal(form.tool_start) + " " + + quoted_name + " " + + gbnf_format_literal(form.tool_sep) + " " + + next_arg + )); + } + + auto tool_call_once = builder.add_rule("root-tool-call-once", string_join(tool_rules, " | ")); + auto tool_call_more = builder.add_rule("root-tool-call-more", gbnf_format_literal(form.tool_end) + " " + tool_call_once); + auto call_end = builder.add_rule("root-call-end", form.last_tool_end ? gbnf_format_literal(*form.last_tool_end) : gbnf_format_literal(form.tool_end)); + auto tool_call_multiple_with_end = builder.add_rule("root-tool-call-multiple-with-end", tool_call_once + " " + tool_call_more + "* " + call_end); + builder.add_rule("root", + (form.scope_start.empty() ? "" : gbnf_format_literal(form.scope_start) + " ") + + tool_call_multiple_with_end + "?" + + (form.scope_end.empty() ? "" : " " + gbnf_format_literal(form.scope_end)) + ); + }); + + // grammar trigger for tool call + data.grammar_triggers.push_back({ COMMON_GRAMMAR_TRIGGER_TYPE_WORD, form.scope_start + form.tool_start }); + } +} + +/** + * Parse XML-Style tool call for given xml_tool_call_format. Return false for invalid syntax and get the position untouched. + * Throws xml_toolcall_syntax_exception if there is invalid syntax and cannot recover the original status for common_chat_msg_parser. + * form.scope_start, form.tool_sep and form.scope_end can be empty. + */ +inline bool parse_xml_tool_calls(common_chat_msg_parser & builder, const struct xml_tool_call_format & form) { + GGML_ASSERT(!form.tool_start.empty()); + GGML_ASSERT(!form.key_start.empty()); + GGML_ASSERT(!form.key_val_sep.empty()); + GGML_ASSERT(!form.val_end.empty()); + GGML_ASSERT(!form.tool_end.empty()); + + // Helper to choose return false or throw error + constexpr auto return_error = [](common_chat_msg_parser & builder, auto &start_pos, const bool &recovery) { + LOG_DBG("Failed to parse XML-Style tool call at position: %s\n", gbnf_format_literal(builder.consume_rest().substr(0, 20)).c_str()); + if (recovery) { + builder.move_to(start_pos); + return false; + } else throw xml_toolcall_syntax_exception("Tool call parsing failed with unrecoverable errors. Try using a grammar to constrain the model’s output."); + }; + // Drop substring from needle to end from a JSON + constexpr auto partial_json = [](std::string &json_str, std::string_view needle = "XML_TOOL_CALL_PARTIAL_FLAG") { + auto pos = json_str.rfind(needle); + if (pos == std::string::npos) { + return false; + } + for (auto i = pos + needle.size(); i < json_str.size(); ++i) { + unsigned char ch = static_cast(json_str[i]); + if (ch != '\'' && ch != '"' && ch != '}' && ch != ':' && !std::isspace(ch)) { + return false; + } + } + if (pos != 0 && json_str[pos - 1] == '"') { + --pos; + } + json_str.resize(pos); + return true; + }; + // Helper to generate a partial argument JSON + constexpr auto gen_partial_json = [partial_json](auto set_partial_arg, auto &arguments, auto &builder, auto &function_name) { + auto rest = builder.consume_rest(); + utf8_truncate_safe_resize(rest); + set_partial_arg(rest, "XML_TOOL_CALL_PARTIAL_FLAG"); + auto tool_str = arguments.dump(); + if (partial_json(tool_str)) { + if (builder.add_tool_call(function_name, "", tool_str)) { + return; + } + } + LOG_DBG("Failed to parse partial XML-Style tool call, fallback to non-partial: %s\n", tool_str.c_str()); + }; + // Helper to find a close (because there may be form.last_val_end or form.last_tool_end) + constexpr auto try_find_close = []( + common_chat_msg_parser & builder, + const std::string & end, + const std::optional & alt_end, + const std::string & end_next, + const std::optional & alt_end_next + ) { + auto saved_pos = builder.pos(); + auto tc = builder.try_find_literal(end); + auto val_end_size = end.size(); + if (alt_end) { + auto pos_1 = builder.pos(); + builder.move_to(saved_pos); + auto tc2 = try_find_2_literal_splited_by_spaces(builder, *alt_end, end_next); + if (alt_end_next) { + builder.move_to(saved_pos); + auto tc3 = try_find_2_literal_splited_by_spaces(builder, *alt_end, *alt_end_next); + if (tc3 && (!tc2 || tc2->prelude.size() > tc3->prelude.size())) { + tc2 = tc3; + } + } + if (tc2 && (!tc || tc->prelude.size() > tc2->prelude.size())) { + tc = tc2; + tc->groups[0].end = std::min(builder.input().size(), tc->groups[0].begin + alt_end->size()); + builder.move_to(tc->groups[0].end); + val_end_size = alt_end->size(); + } else { + builder.move_to(pos_1); + } + } + return std::make_pair(val_end_size, tc); + }; + // Helper to find a val_end or last_val_end, returns matched pattern size + const auto try_find_val_end = [try_find_close, &builder, &form]() { + return try_find_close(builder, form.val_end, form.last_val_end, form.tool_end, form.last_tool_end); + }; + // Helper to find a tool_end or last_tool_end, returns matched pattern size + const auto try_find_tool_end = [try_find_close, &builder, &form]() { + return try_find_close(builder, form.tool_end, form.last_tool_end, form.scope_end, std::nullopt); + }; + + bool recovery = true; + const auto start_pos = builder.pos(); + if (!all_space(form.scope_start)) { + if (auto tc = builder.try_find_literal(form.scope_start)) { + if (all_space(tc->prelude)) { + if (form.scope_start.size() != tc->groups[0].end - tc->groups[0].begin) + throw common_chat_msg_partial_exception("Partial literal: " + gbnf_format_literal(form.scope_start)); + } else { + builder.move_to(start_pos); + return false; + } + } else return false; + } + while (auto tc = builder.try_find_literal(form.tool_start)) { + if (!all_space(tc->prelude)) { + LOG_DBG("XML-Style tool call: Expected %s, but found %s, trying to match next pattern\n", + gbnf_format_literal(form.tool_start).c_str(), + gbnf_format_literal(tc->prelude).c_str() + ); + builder.move_to(tc->groups[0].begin - tc->prelude.size()); + break; + } + + // Find tool name + auto func_name = builder.try_find_literal(all_space(form.tool_sep) ? form.key_start : form.tool_sep); + if (!func_name) { + auto [sz, tc] = try_find_tool_end(); + func_name = tc; + } + if (!func_name) { + // Partial tool name not supported + throw common_chat_msg_partial_exception("incomplete tool_call"); + } + // If the model generate multiple tool call and the first tool call has no argument + if (func_name->prelude.find(form.tool_end) != std::string::npos || (form.last_tool_end ? func_name->prelude.find(*form.last_tool_end) != std::string::npos : false)) { + builder.move_to(func_name->groups[0].begin - func_name->prelude.size()); + auto [sz, tc] = try_find_tool_end(); + func_name = tc; + } + + // Parse tool name + builder.move_to(all_space(form.tool_sep) ? func_name->groups[0].begin : func_name->groups[0].end); + std::string function_name = string_strip(func_name->prelude); + // Kimi-K2 uses functions.{{ tool_call['function']['name'] }}:{{ loop.index }} as function name + if (builder.syntax().format == COMMON_CHAT_FORMAT_KIMI_K2) { + if (string_starts_with(function_name, "functions.")) { + static const std::regex re(":\\d+$"); + if (std::regex_search(function_name, re)) { + function_name = function_name.substr(10, function_name.rfind(":") - 10); + } + } + } + + // Argument JSON + json arguments = json::object(); + + // Helper to generate a partial argument JSON + const auto gen_partial_args = [&](auto set_partial_arg) { + gen_partial_json(set_partial_arg, arguments, builder, function_name); + }; + + // Parse all arg_key/arg_value pairs + while (auto tc = builder.try_find_literal(form.key_start)) { + if (!all_space(tc->prelude)) { + LOG_DBG("XML-Style tool call: Expected %s, but found %s, trying to match next pattern\n", + gbnf_format_literal(form.key_start).c_str(), + gbnf_format_literal(tc->prelude).c_str() + ); + builder.move_to(tc->groups[0].begin - tc->prelude.size()); + break; + } + if (tc->groups[0].end - tc->groups[0].begin != form.key_start.size()) { + auto tool_call_arg = arguments.dump(); + if (tool_call_arg.size() != 0 && tool_call_arg[tool_call_arg.size() - 1] == '}') { + tool_call_arg.resize(tool_call_arg.size() - 1); + } + builder.add_tool_call(function_name, "", tool_call_arg); + throw common_chat_msg_partial_exception("Partial literal: " + gbnf_format_literal(form.key_start)); + } + + // Parse arg_key + auto key_res = builder.try_find_literal(form.key_val_sep); + if (!key_res) { + gen_partial_args([&](auto &rest, auto &needle) {arguments[rest + needle] = "";}); + throw common_chat_msg_partial_exception("Expected " + gbnf_format_literal(form.key_val_sep) + " after " + gbnf_format_literal(form.key_start)); + } + if (key_res->groups[0].end - key_res->groups[0].begin != form.key_val_sep.size()) { + gen_partial_args([&](auto &, auto &needle) {arguments[key_res->prelude + needle] = "";}); + throw common_chat_msg_partial_exception("Partial literal: " + gbnf_format_literal(form.key_val_sep)); + } + auto &key = key_res->prelude; + recovery = false; + + // Parse arg_value + if (form.key_val_sep2) { + if (auto tc = builder.try_find_literal(*form.key_val_sep2)) { + if (!all_space(tc->prelude)) { + LOG_DBG("Failed to parse XML-Style tool call: Unexcepted %s between %s and %s\n", + gbnf_format_literal(tc->prelude).c_str(), + gbnf_format_literal(form.key_val_sep).c_str(), + gbnf_format_literal(*form.key_val_sep2).c_str() + ); + return return_error(builder, start_pos, false); + } + if (tc->groups[0].end - tc->groups[0].begin != form.key_val_sep2->size()) { + gen_partial_args([&](auto &, auto &needle) {arguments[key] = needle;}); + throw common_chat_msg_partial_exception("Partial literal: " + gbnf_format_literal(*form.key_val_sep2)); + } + } else { + gen_partial_args([&](auto &, auto &needle) {arguments[key] = needle;}); + throw common_chat_msg_partial_exception("Expected " + gbnf_format_literal(*form.key_val_sep2) + " after " + gbnf_format_literal(form.key_val_sep)); + } + } + auto val_start = builder.pos(); + + // Test if arg_val is a partial JSON + std::optional value_json = std::nullopt; + if (!form.raw_argval || !*form.raw_argval) { + try { value_json = builder.try_consume_json(); } + catch (const std::runtime_error&) { builder.move_to(val_start); } + // TODO: Delete this when json_partial adds top-level support for null/true/false + if (builder.pos() == val_start) { + const static std::regex number_regex(R"([0-9-][0-9]*(\.\d*)?([eE][+-]?\d*)?)"); + builder.consume_spaces(); + std::string_view sv = utf8_truncate_safe_view(builder.input()); + sv.remove_prefix(builder.pos()); + std::string rest = "a"; + if (sv.size() < 6) rest = sv; + if (string_starts_with("null", rest) || string_starts_with("true", rest) || string_starts_with("false", rest) || std::regex_match(sv.begin(), sv.end(), number_regex)) { + value_json = {123, {"123", "123"}}; + builder.consume_rest(); + } else { + builder.move_to(val_start); + } + } + } + + // If it is a JSON and followed by , parse as json + // cannot support streaming because it may be a plain text starting with JSON + if (value_json) { + auto json_end = builder.pos(); + builder.consume_spaces(); + if (builder.pos() == builder.input().size()) { + if (form.raw_argval && !*form.raw_argval && (value_json->json.is_string() || value_json->json.is_object() || value_json->json.is_array())) { + arguments[key] = value_json->json; + auto json_str = arguments.dump(); + if (!value_json->healing_marker.json_dump_marker.empty()) { + GGML_ASSERT(std::string::npos != json_str.rfind(value_json->healing_marker.json_dump_marker)); + json_str.resize(json_str.rfind(value_json->healing_marker.json_dump_marker)); + } else { + GGML_ASSERT(json_str.back() == '}'); + json_str.resize(json_str.size() - 1); + } + builder.add_tool_call(function_name, "", json_str); + } else { + gen_partial_args([&](auto &, auto &needle) {arguments[key] = needle;}); + } + LOG_DBG("Possible JSON arg_value: %s\n", value_json->json.dump().c_str()); + throw common_chat_msg_partial_exception("JSON arg_value detected. Waiting for more tokens for validations."); + } + builder.move_to(json_end); + auto [val_end_size, tc] = try_find_val_end(); + if (tc && all_space(tc->prelude) && value_json->healing_marker.marker.empty()) { + if (tc->groups[0].end - tc->groups[0].begin != val_end_size) { + gen_partial_args([&](auto &, auto &needle) {arguments[key] = needle;}); + LOG_DBG("Possible terminated JSON arg_value: %s\n", value_json->json.dump().c_str()); + throw common_chat_msg_partial_exception("Partial literal: " + gbnf_format_literal(form.val_end) + (form.last_val_end ? gbnf_format_literal(*form.last_val_end) : "")); + } else arguments[key] = value_json->json; + } else builder.move_to(val_start); + } + + // If not, parse as plain text + if (val_start == builder.pos()) { + if (auto [val_end_size, value_plain] = try_find_val_end(); value_plain) { + auto &value_str = value_plain->prelude; + if (form.trim_raw_argval) value_str = string_strip(value_str); + if (value_plain->groups[0].end - value_plain->groups[0].begin != val_end_size) { + gen_partial_args([&](auto &, auto &needle) {arguments[key] = value_str + needle;}); + throw common_chat_msg_partial_exception( + "Expected " + gbnf_format_literal(form.val_end) + + " after " + gbnf_format_literal(form.key_val_sep) + + (form.key_val_sep2 ? " " + gbnf_format_literal(*form.key_val_sep2) : "") + ); + } + arguments[key] = value_str; + } else { + if (form.trim_raw_argval) { + gen_partial_args([&](auto &rest, auto &needle) {arguments[key] = string_strip(rest) + needle;}); + } else { + gen_partial_args([&](auto &rest, auto &needle) {arguments[key] = rest + needle;}); + } + throw common_chat_msg_partial_exception( + "Expected " + gbnf_format_literal(form.val_end) + + " after " + gbnf_format_literal(form.key_val_sep) + + (form.key_val_sep2 ? " " + gbnf_format_literal(*form.key_val_sep2) : "") + ); + } + } + } + + // Consume closing tag + if (auto [tool_end_size, tc] = try_find_tool_end(); tc) { + if (!all_space(tc->prelude)) { + LOG_DBG("Failed to parse XML-Style tool call: Expected %s, but found %s\n", + gbnf_format_literal(form.tool_end).c_str(), + gbnf_format_literal(tc->prelude).c_str() + ); + return return_error(builder, start_pos, recovery); + } + if (tc->groups[0].end - tc->groups[0].begin == tool_end_size) { + // Add the parsed tool call + if (!builder.add_tool_call(function_name, "", arguments.dump())) { + throw common_chat_msg_partial_exception("Failed to add XML-Style tool call"); + } + recovery = false; + continue; + } + } + + auto tool_call_arg = arguments.dump(); + if (tool_call_arg.size() != 0 && tool_call_arg[tool_call_arg.size() - 1] == '}') { + tool_call_arg.resize(tool_call_arg.size() - 1); + } + builder.add_tool_call(function_name, "", tool_call_arg); + throw common_chat_msg_partial_exception("Expected " + gbnf_format_literal(form.tool_end) + " after " + gbnf_format_literal(form.val_end)); + } + if (auto tc = builder.try_find_literal(form.scope_end)) { + if (!all_space(tc->prelude)) { + LOG_DBG("Failed to parse XML-Style tool call: Expected %s, but found %s\n", + gbnf_format_literal(form.scope_end).c_str(), + gbnf_format_literal(tc->prelude).c_str() + ); + return return_error(builder, start_pos, recovery); + } + } else { + if (all_space(form.scope_end)) return true; + builder.consume_spaces(); + if (builder.pos() == builder.input().size()) + throw common_chat_msg_partial_exception("incomplete tool calls"); + LOG_DBG("Failed to parse XML-Style tool call: Expected %s, but found %s\n", + gbnf_format_literal(form.scope_end).c_str(), + gbnf_format_literal(builder.consume_rest()).c_str() + ); + return return_error(builder, start_pos, recovery); + } + + return true; +} + +/** + * Parse XML-Style tool call for given xml_tool_call_format. Return false for invalid syntax and get the position untouched. + * May cause std::runtime_error if there is invalid syntax because partial valid tool call is already sent out to client. + * form.scope_start, form.tool_sep and form.scope_end can be empty. + */ +bool common_chat_msg_parser::try_consume_xml_tool_calls(const struct xml_tool_call_format & form) { + auto pos = pos_; + auto tsize = result_.tool_calls.size(); + try { return parse_xml_tool_calls(*this, form); } + catch (const xml_toolcall_syntax_exception&) {} + move_to(pos); + result_.tool_calls.resize(tsize); + return false; +} + +/** + * Parse content uses reasoning and XML-Style tool call + * TODO: Note that form.allow_toolcall_in_think is not tested yet. If anyone confirms it works, this comment can be removed. + */ +inline void parse_msg_with_xml_tool_calls(common_chat_msg_parser & builder, const struct xml_tool_call_format & form, const std::string & start_think = "", const std::string & end_think = "") { + constexpr auto rstrip = [](std::string &s) { + s.resize(std::distance(s.begin(), std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) { return !std::isspace(ch); }).base())); + }; + // Erase substring from l to r, along with additional spaces nearby + constexpr auto erase_spaces = [](auto &str, size_t l, size_t r) { + while (/* l > -1 && */ --l < str.size() && std::isspace(static_cast(str[l]))); + ++l; + while (++r < str.size() && std::isspace(static_cast(str[r]))); + if (l < r) str[l] = '\n'; + if (l + 1 < r) str[l + 1] = '\n'; + if (l != 0) l += 2; + str.erase(l, r - l); + return l; + }; + constexpr auto trim_suffix = [](std::string &content, std::initializer_list list) { + auto best_match = content.size(); + for (auto pattern: list) { + if (pattern.size() == 0) continue; + for (auto match_idx = content.size() - std::min(pattern.size(), content.size()); content.size() > match_idx; match_idx++) { + auto match_len = content.size() - match_idx; + if (content.compare(match_idx, match_len, pattern.data(), match_len) == 0 && best_match > match_idx) { + best_match = match_idx; + } + } + } + if (content.size() > best_match) { + content.erase(best_match); + } + }; + const auto trim_potential_partial_word = [&start_think, &end_think, &form, trim_suffix](std::string &content) { + return trim_suffix(content, { + start_think, end_think, form.scope_start, form.tool_start, form.tool_sep, form.key_start, + form.key_val_sep, form.key_val_sep2 ? form.key_val_sep2->c_str() : "", + form.val_end, form.last_val_end ? form.last_val_end->c_str() : "", + form.tool_end, form.last_tool_end ? form.last_tool_end->c_str() : "", + form.scope_end + }); + }; + + + // Trim leading spaces without affecting keyword matching + static const common_regex spaces_regex("\\s*"); + { + auto tc = builder.consume_regex(spaces_regex); + auto spaces = builder.str(tc.groups[0]); + auto s1 = spaces.size(); + trim_potential_partial_word(spaces); + auto s2 = spaces.size(); + builder.move_to(builder.pos() - (s1 - s2)); + } + + // Parse content + bool reasoning_unclosed = builder.syntax().thinking_forced_open; + std::string unclosed_reasoning_content(""); + for (;;) { + auto tc = try_find_2_literal_splited_by_spaces(builder, form.scope_start, form.tool_start); + std::string content; + std::string tool_call_start; + + if (tc) { + content = std::move(tc->prelude); + tool_call_start = builder.str(tc->groups[0]); + LOG_DBG("Matched tool start: %s\n", gbnf_format_literal(tool_call_start).c_str()); + } else { + content = builder.consume_rest(); + utf8_truncate_safe_resize(content); + } + + // Handle unclosed think block + if (reasoning_unclosed) { + if (auto pos = content.find(end_think); pos == std::string::npos && builder.pos() != builder.input().size()) { + unclosed_reasoning_content += content; + if (form.allow_toolcall_in_think) { + builder.move_to(tc->groups[0].begin); + if (!builder.try_consume_xml_tool_calls(form)) { + unclosed_reasoning_content += tool_call_start; + builder.move_to(tc->groups[0].end); + } + } else { + unclosed_reasoning_content += tool_call_start; + } + continue; + } else { + reasoning_unclosed = false; + std::string reasoning_content; + if (pos == std::string::npos) { + reasoning_content = std::move(content); + } else { + reasoning_content = content.substr(0, pos); + content.erase(0, pos + end_think.size()); + } + if (builder.pos() == builder.input().size() && all_space(content)) { + rstrip(reasoning_content); + trim_potential_partial_word(reasoning_content); + rstrip(reasoning_content); + if (reasoning_content.empty()) { + rstrip(unclosed_reasoning_content); + trim_potential_partial_word(unclosed_reasoning_content); + rstrip(unclosed_reasoning_content); + if (unclosed_reasoning_content.empty()) continue; + } + } + if (builder.syntax().reasoning_format == COMMON_REASONING_FORMAT_NONE || builder.syntax().reasoning_in_content) { + builder.add_content(start_think); + builder.add_content(unclosed_reasoning_content); + builder.add_content(reasoning_content); + if (builder.pos() != builder.input().size() || !all_space(content)) + builder.add_content(end_think); + } else { + builder.add_reasoning_content(unclosed_reasoning_content); + builder.add_reasoning_content(reasoning_content); + } + unclosed_reasoning_content.clear(); + } + } + + // Handle multiple think block + bool toolcall_in_think = false; + for (auto think_start = content.find(start_think); think_start != std::string::npos; think_start = content.find(start_think, think_start)) { + if (auto think_end = content.find(end_think, think_start + start_think.size()); think_end != std::string::npos) { + if (builder.syntax().reasoning_format != COMMON_REASONING_FORMAT_NONE && !builder.syntax().reasoning_in_content) { + auto reasoning_content = content.substr(think_start + start_think.size(), think_end - think_start - start_think.size()); + builder.add_reasoning_content(reasoning_content); + think_start = erase_spaces(content, think_start, think_end + end_think.size() - 1); + } else { + think_start = think_end + end_think.size() - 1; + } + } else { + // This start is in thinking block, skip this tool call + auto pos = think_start + start_think.size(); + unclosed_reasoning_content = content.substr(pos) + tool_call_start; + reasoning_unclosed = true; + content.resize(think_start); + toolcall_in_think = true; + } + } + + if (builder.syntax().reasoning_format != COMMON_REASONING_FORMAT_NONE && !builder.syntax().reasoning_in_content) { + rstrip(content); + // Handle unclosed token from content: delete all token + if (auto pos = content.rfind(end_think); pos != std::string::npos) { + while (pos != std::string::npos) { + pos = erase_spaces(content, pos, pos + end_think.size() - 1); + pos = content.rfind(end_think, pos); + } + } + // Strip if needed + if (content.size() > 0 && std::isspace(static_cast(content[0]))) { + content = string_strip(content); + } + } + + // remove potential partial suffix + if (content.size() > 0 && builder.pos() == builder.input().size() && unclosed_reasoning_content.empty()) { + rstrip(content); + trim_potential_partial_word(content); + rstrip(content); + } + + // Add content + if (content.size() != 0) { + // If there are multiple content blocks + if (builder.syntax().reasoning_format != COMMON_REASONING_FORMAT_NONE && !builder.syntax().reasoning_in_content && builder.result().content.size() != 0) { + builder.add_content("\n\n"); + } + builder.add_content(content); + } + + // This start is in thinking block, skip this tool call + if (toolcall_in_think && !form.allow_toolcall_in_think) { + continue; + } + + // There is no tool call and all content is parsed + if (!tc) { + GGML_ASSERT(builder.pos() == builder.input().size()); + GGML_ASSERT(unclosed_reasoning_content.empty()); + GGML_ASSERT(!reasoning_unclosed); + break; + } + + builder.move_to(tc->groups[0].begin); + if (builder.try_consume_xml_tool_calls(form)) { + auto end_of_tool = builder.pos(); + builder.consume_spaces(); + if (builder.pos() != builder.input().size()) { + builder.move_to(end_of_tool); + if (!builder.result().content.empty()) { + builder.add_content("\n\n"); + } + } + } else { + static const common_regex next_char_regex("."); + auto c = builder.str(builder.consume_regex(next_char_regex).groups[0]); + rstrip(c); + builder.add_content(c); + } + } +} + +/** + * Parse content uses reasoning and XML-Style tool call + * TODO: Note that form.allow_toolcall_in_think is not tested yet. If anyone confirms it works, this comment can be removed. + */ +void common_chat_msg_parser::consume_reasoning_with_xml_tool_calls(const struct xml_tool_call_format & form, const std::string & start_think, const std::string & end_think) { + parse_msg_with_xml_tool_calls(*this, form, start_think, end_think); +} diff --git a/common/chat-parser-xml-toolcall.h b/common/chat-parser-xml-toolcall.h new file mode 100644 index 000000000..67face2b9 --- /dev/null +++ b/common/chat-parser-xml-toolcall.h @@ -0,0 +1,45 @@ +#pragma once + +#include "chat.h" + +#include + +#include +#include +#include + + +// Sample config: +// MiniMax-M2 (left): \n\nvalue\n...\n... +// GLM 4.5 (right): function_name\nkey\nvalue\n +struct xml_tool_call_format { + std::string scope_start; // \n // \n // can be empty + std::string tool_start; // + std::string tool_sep; // \">\n // \n // can be empty only for parse_xml_tool_calls + std::string key_start; // + std::string key_val_sep; // \"> // \n + std::string val_end; // \n // \n + std::string tool_end; // \n // \n + std::string scope_end; // // // can be empty + // Set this if there can be dynamic spaces inside key_val_sep. + // e.g. key_val_sep= key_val_sep2= for GLM4.5 + std::optional key_val_sep2 = std::nullopt; + // Set true if argval should only be raw string. e.g. Hello "world" hi + // Set false if argval should only be json string. e.g. "Hello \"world\" hi" + // Defaults to std::nullopt, both will be allowed. + std::optional raw_argval = std::nullopt; + std::optional last_val_end = std::nullopt; + std::optional last_tool_end = std::nullopt; + bool trim_raw_argval = false; + bool allow_toolcall_in_think = false; // TODO: UNTESTED!!! +}; + +// make a GBNF that accept any strings except those containing any of the forbidden strings. +std::string make_gbnf_excluding(std::vector forbids); + +/** + * Build grammar for xml-style tool call + * form.scope_start and form.scope_end can be empty. + * Requires data.format for model-specific hacks. + */ +void build_grammar_xml_tool_call(common_chat_params & data, const nlohmann::ordered_json & tools, const struct xml_tool_call_format & form); diff --git a/common/chat-parser.h b/common/chat-parser.h index c8cdc63fb..78c4b74c2 100644 --- a/common/chat-parser.h +++ b/common/chat-parser.h @@ -1,6 +1,7 @@ #pragma once #include "chat.h" +#include "chat-parser-xml-toolcall.h" #include "json-partial.h" #include "regex-partial.h" @@ -119,5 +120,14 @@ class common_chat_msg_parser { const std::vector> & content_paths = {} ); + /** + * Parse XML-Style tool call for given xml_tool_call_format. Return false for invalid syntax and get the position untouched. + * form.scope_start, form.tool_sep and form.scope_end can be empty. + */ + bool try_consume_xml_tool_calls(const struct xml_tool_call_format & form); + + // Parse content uses reasoning and XML-Style tool call + void consume_reasoning_with_xml_tool_calls(const struct xml_tool_call_format & form, const std::string & start_think = "", const std::string & end_think = ""); + void clear_tools(); }; diff --git a/common/chat.cpp b/common/chat.cpp index c6dfcc50b..dfa12b511 100644 --- a/common/chat.cpp +++ b/common/chat.cpp @@ -8,6 +8,7 @@ #include "minja/chat-template.hpp" #include "minja/minja.hpp" #include "regex-partial.cpp" +#include "chat-parser-xml-toolcall.cpp" #include #include @@ -643,6 +644,12 @@ const char * common_chat_format_name(common_chat_format format) { case COMMON_CHAT_FORMAT_NEMOTRON_V2: return "Nemotron V2"; case COMMON_CHAT_FORMAT_APERTUS: return "Apertus"; case COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS: return "LFM2 with JSON tools"; + case COMMON_CHAT_FORMAT_MINIMAX_M2: return "MiniMax-M2"; + case COMMON_CHAT_FORMAT_GLM_4_5: return "GLM 4.5"; + case COMMON_CHAT_FORMAT_KIMI_K2: return "Kimi K2"; + case COMMON_CHAT_FORMAT_QWEN3_CODER_XML: return "Qwen3 Coder"; + case COMMON_CHAT_FORMAT_APRIEL_1_5: return "Apriel 1.5"; + case COMMON_CHAT_FORMAT_XIAOMI_MIMO: return "Xiaomi MiMo"; default: throw std::runtime_error("Unknown chat format"); } @@ -1807,6 +1814,278 @@ static void common_chat_parse_deepseek_v3_1(common_chat_msg_parser & builder) { } } + +static common_chat_params common_chat_params_init_minimax_m2(const common_chat_template & tmpl, const struct templates_params & params) { + common_chat_params data; + data.grammar_lazy = params.tools.is_array() && !params.tools.empty() && params.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + + data.prompt = apply(tmpl, params); + data.format = COMMON_CHAT_FORMAT_MINIMAX_M2; + + // Handle thinking tags based on prompt ending + if (string_ends_with(data.prompt, "\n")) { + if (!params.enable_thinking) { + // Close the thinking tag immediately if thinking is disabled + data.prompt += "\n\n"; + } else { + // Mark thinking as forced open (template started with ) + data.thinking_forced_open = true; + } + } + + // Preserve MiniMax-M2 special tokens + data.preserved_tokens = { + "", + "", + "", + "", + }; + + // build grammar for tool call + static const xml_tool_call_format form { + /* form.scope_start = */ "\n", + /* form.tool_start = */ "\n", + /* form.key_start = */ "", + /* form.val_end = */ "\n", + /* form.tool_end = */ "\n", + /* form.scope_end = */ "", + }; + build_grammar_xml_tool_call(data, params.tools, form); + + return data; +} + +static void common_chat_parse_minimax_m2(common_chat_msg_parser & builder) { + static const xml_tool_call_format form { + /* form.scope_start = */ "", + /* form.tool_start = */ "", + /* form.key_start = */ "", + /* form.val_end = */ "", + /* form.tool_end = */ "", + /* form.scope_end = */ "", + }; + builder.consume_reasoning_with_xml_tool_calls(form, "", ""); +} + +static common_chat_params common_chat_params_init_qwen3_coder_xml(const common_chat_template & tmpl, const struct templates_params & params) { + common_chat_params data; + data.grammar_lazy = params.tools.is_array() && !params.tools.empty() && params.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + + data.prompt = apply(tmpl, params); + data.format = COMMON_CHAT_FORMAT_QWEN3_CODER_XML; + + data.preserved_tokens = { + "", + "", + "", + "", + }; + + // build grammar for tool call + static const xml_tool_call_format form { + /* form.scope_start = */ "\n", + /* form.tool_start = */ "\n", + /* form.key_start = */ "\n", + /* form.val_end = */ "\n\n", + /* form.tool_end = */ "\n", + /* form.scope_end = */ "", + }; + build_grammar_xml_tool_call(data, params.tools, form); + + return data; +} + +static void common_chat_parse_qwen3_coder_xml(common_chat_msg_parser & builder) { + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = ""; + form.tool_start = "", + "", + "<|tool_calls_section_begin|>", + "<|tool_call_begin|>", + "<|tool_call_argument_begin|>", + "<|tool_call_end|>", + "<|tool_calls_section_end|>", + "<|im_end|>", + "<|im_system|>", + "<|im_middle|>", + }; + + data.additional_stops.insert(data.additional_stops.end(), { + "<|im_end|>", + "<|im_middle|>" + }); + // build grammar for tool call + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = "<|tool_calls_section_begin|>"; + form.tool_start = "<|tool_call_begin|>"; + form.tool_sep = "<|tool_call_argument_begin|>{"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}<|tool_call_end|>"; + form.scope_end = "<|tool_calls_section_end|>"; + form.raw_argval = false; + form.last_val_end = ""; + return form; + })(); + build_grammar_xml_tool_call(data, params.tools, form); + + return data; +} + +static void common_chat_parse_kimi_k2(common_chat_msg_parser & builder) { + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = "<|tool_calls_section_begin|>"; + form.tool_start = "<|tool_call_begin|>"; + form.tool_sep = "<|tool_call_argument_begin|>{"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}<|tool_call_end|>"; + form.scope_end = "<|tool_calls_section_end|>"; + form.raw_argval = false; + form.last_val_end = ""; + return form; + })(); + builder.consume_reasoning_with_xml_tool_calls(form, "", ""); +} + +static common_chat_params common_chat_params_init_apriel_1_5(const common_chat_template & tmpl, const struct templates_params & params) { + common_chat_params data; + data.grammar_lazy = params.tools.is_array() && !params.tools.empty() && params.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + + data.prompt = apply(tmpl, params); + data.format = COMMON_CHAT_FORMAT_APRIEL_1_5; + + data.preserved_tokens = { + "", + "", + "", + "", + }; + + // build grammar for tool call + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = "["; + form.tool_start = "{\"name\": \""; + form.tool_sep = "\", \"arguments\": {"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}, "; + form.scope_end = "]"; + form.raw_argval = false; + form.last_val_end = ""; + form.last_tool_end = "}"; + return form; + })(); + build_grammar_xml_tool_call(data, params.tools, form); + + return data; +} + +static void common_chat_parse_apriel_1_5(common_chat_msg_parser & builder) { + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = "["; + form.tool_start = "{\"name\": \""; + form.tool_sep = "\", \"arguments\": {"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}, "; + form.scope_end = "]"; + form.raw_argval = false; + form.last_val_end = ""; + form.last_tool_end = "}"; + return form; + })(); + builder.consume_reasoning_with_xml_tool_calls(form, "", ""); +} + +static common_chat_params common_chat_params_init_xiaomi_mimo(const common_chat_template & tmpl, const struct templates_params & params) { + common_chat_params data; + data.grammar_lazy = params.tools.is_array() && !params.tools.empty() && params.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + + data.prompt = apply(tmpl, params); + data.format = COMMON_CHAT_FORMAT_XIAOMI_MIMO; + + data.preserved_tokens = { + "", + "", + }; + + // build grammar for tool call + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = "\n"; + form.tool_start = "\n{\"name\": \""; + form.tool_sep = "\", \"arguments\": {"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}\n"; + form.scope_end = ""; + form.raw_argval = false; + form.last_val_end = ""; + return form; + })(); + build_grammar_xml_tool_call(data, params.tools, form); + + return data; +} + +static void common_chat_parse_xiaomi_mimo(common_chat_msg_parser & builder) { + static const xml_tool_call_format form = ([]() { + xml_tool_call_format form {}; + form.scope_start = ""; + form.tool_start = "\n{\"name\": \""; + form.tool_sep = "\", \"arguments\": {"; + form.key_start = "\""; + form.key_val_sep = "\": "; + form.val_end = ", "; + form.tool_end = "}\n"; + form.scope_end = ""; + form.raw_argval = false; + form.last_val_end = ""; + return form; + })(); + builder.consume_reasoning_with_xml_tool_calls(form); +} + static common_chat_params common_chat_params_init_gpt_oss(const common_chat_template & tmpl, const struct templates_params & inputs) { common_chat_params data; @@ -2041,6 +2320,100 @@ static void common_chat_parse_gpt_oss(common_chat_msg_parser & builder) { } } +static common_chat_params common_chat_params_init_glm_4_5(const common_chat_template & tmpl, const struct templates_params & inputs) { + common_chat_params data; + data.grammar_lazy = inputs.tools.is_array() && !inputs.tools.empty() && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED; + + std::string prompt = apply(tmpl, inputs); + + // match the existing trimming behavior + if (inputs.add_bos && string_starts_with(prompt, tmpl.bos_token())) { + prompt.erase(0, tmpl.bos_token().size()); + } + if (inputs.add_eos && string_ends_with(prompt, tmpl.eos_token())) { + prompt.erase(prompt.size() - tmpl.eos_token().size()); + } + if (string_ends_with(prompt, "")) { + if (!inputs.enable_thinking) { + prompt += ""; + } else { + data.thinking_forced_open = true; + } + } + + // add GLM preserved tokens + data.preserved_tokens = { + "<|endoftext|>", + "[MASK]", + "[gMASK]", + "[sMASK]", + "", + "", + "<|system|>", + "<|user|>", + "<|assistant|>", + "<|observation|>", + "<|begin_of_image|>", + "<|end_of_image|>", + "<|begin_of_video|>", + "<|end_of_video|>", + "<|begin_of_audio|>", + "<|end_of_audio|>", + "<|begin_of_transcription|>", + "<|end_of_transcription|>", + "<|code_prefix|>", + "<|code_middle|>", + "<|code_suffix|>", + "/nothink", + "", + "", + "", + "", + "", + "", + "", + "" + }; + + // extra GLM 4.5 stop word + data.additional_stops.insert(data.additional_stops.end(), { + "<|user|>", + "<|observation|>" + }); + + // build grammar for tool call + static const xml_tool_call_format form { + /* form.scope_start = */ "", + /* form.tool_start = */ "\n", + /* form.tool_sep = */ "\n", + /* form.key_start = */ "", + /* form.key_val_sep = */ "\n", + /* form.val_end = */ "\n", + /* form.tool_end = */ "\n", + /* form.scope_end = */ "", + }; + build_grammar_xml_tool_call(data, inputs.tools, form); + + data.prompt = prompt; + data.format = COMMON_CHAT_FORMAT_GLM_4_5; + return data; +} + +static void common_chat_parse_glm_4_5(common_chat_msg_parser & builder) { + static const xml_tool_call_format form { + /* form.scope_start = */ "", + /* form.tool_start = */ "", + /* form.tool_sep = */ "", + /* form.key_start = */ "", + /* form.key_val_sep = */ "", + /* form.val_end = */ "", + /* form.tool_end = */ "", + /* form.scope_end = */ "", + /* form.key_val_sep2 = */ "", + }; + builder.consume_reasoning_with_xml_tool_calls(form, "", ""); +} + static common_chat_params common_chat_params_init_firefunction_v2(const common_chat_template & tmpl, const struct templates_params & inputs) { LOG_DBG("%s\n", __func__); common_chat_params data; @@ -2704,91 +3077,17 @@ static void common_chat_parse_lfm2(common_chat_msg_parser & builder) { } static void common_chat_parse_seed_oss(common_chat_msg_parser & builder) { - // Parse thinking tags first - this handles the main reasoning content - builder.try_parse_reasoning("", ""); - - if (!builder.syntax().parse_tool_calls) { - builder.add_content(builder.consume_rest()); - return; - } - - // Parse tool calls - Seed-OSS uses format - static const common_regex tool_call_begin_regex(""); - static const common_regex tool_call_end_regex(""); - static const common_regex function_regex("]+)>"); - static const common_regex param_regex("]+)>"); - - while (auto tool_res = builder.try_find_regex(tool_call_begin_regex)) { - builder.consume_spaces(); // Consume whitespace after - - // Look for function call inside tool call, ignore any content before it - if (auto func_res = builder.try_find_regex(function_regex, std::string::npos, false)) { - auto function_name = builder.str(func_res->groups[1]); - - // Parse Seed-OSS parameters value - json args = json::object(); - // Parse all parameters - while (auto param_res = builder.try_find_regex(param_regex, std::string::npos, false)) { - // again, ignore noise around parameters - auto param_name = builder.str(param_res->groups[1]); - builder.move_to(param_res->groups[0].end); - builder.consume_spaces(); // Consume whitespace after parameter - auto savedPos = builder.pos(); - if (auto param_parse = builder.try_find_literal("")) { - auto param = param_parse->prelude; - builder.move_to(savedPos); - try { - if (auto param_res = builder.try_consume_json()) { - args[param_name] = param_res->json; - } else { - args[param_name] = param; - } - } catch (json::exception &) { - args[param_name] = param; - } - } else { - throw common_chat_msg_partial_exception("Incomplete tool parameter"); - } - } - // Look for closing function tag - auto end_func = builder.try_find_literal(""); - if (end_func) { - builder.move_to(end_func->groups[0].end); - builder.consume_spaces(); // Consume whitespace after - - // Add the tool call with parsed arguments, but only if we REALLY got the literal - auto eaten_fragment = builder.input().substr(end_func->groups[0].begin, end_func->groups[0].end); - auto funlen = std::string("").length(); - if (eaten_fragment.length() >= funlen && eaten_fragment.substr(0, funlen) == std::string("")) { - if (!builder.add_tool_call(function_name, "", args.dump())) { - throw common_chat_msg_partial_exception("Incomplete tool call"); - } - } else { - throw common_chat_msg_partial_exception("Incomplete tool call"); - } - } else { - throw common_chat_msg_partial_exception("Incomplete tool call"); - } - // Look for closing tool call tag - if (auto end_tool = builder.try_find_regex(tool_call_end_regex, std::string::npos, false)) { - builder.move_to(end_tool->groups[0].end); - builder.consume_spaces(); // Consume trailing whitespace after tool call - } else { - throw common_chat_msg_partial_exception("Incomplete tool call"); - } - } else { - // No function found - don't consume content here, let it be handled at the end - break; - } - } - - // Consume any remaining whitespace after all tool call processing - builder.consume_spaces(); - auto remaining = builder.consume_rest(); - // If there's any non-whitespace content remaining, add it as content - if (!string_strip(remaining).empty()) { - builder.add_content(remaining); - } + static const xml_tool_call_format form { + /* form.scope_start = */ "", + /* form.tool_start = */ "", + /* form.key_start = */ "", + /* form.val_end = */ "", + /* form.tool_end = */ "", + /* form.scope_end = */ "", + }; + builder.consume_reasoning_with_xml_tool_calls(form, "", ""); } static common_chat_params common_chat_params_init_without_tools(const common_chat_template & tmpl, const struct templates_params & inputs) { @@ -2927,6 +3226,35 @@ static common_chat_params common_chat_templates_apply_jinja( return common_chat_params_init_granite(tmpl, params); } + // GLM 4.5: detect by and tags (check before Hermes since both use ) + if (src.find("[gMASK]") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + params.json_schema.is_null()) { + return common_chat_params_init_glm_4_5(tmpl, params); + } + + // Qwen3-Coder XML format detection (must come before Hermes 2 Pro) + // Detect via explicit XML markers unique to Qwen3-Coder to avoid false positives in other templates. + // Require presence of , , and blocks. + if (src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("# Tools") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos) { + return common_chat_params_init_xiaomi_mimo(tmpl, params); + } + // Hermes 2/3 Pro, Qwen 2.5 Instruct (w/ tools) if (src.find("") != std::string::npos && params.json_schema.is_null()) { return common_chat_params_init_hermes_2_pro(tmpl, params); @@ -2958,6 +3286,29 @@ static common_chat_params common_chat_templates_apply_jinja( return common_chat_params_init_lfm2(tmpl, params); } + // MiniMax-M2 format detection + if (src.find("]~!b[") != std::string::npos && src.find("]~b]") != std::string::npos) { + return common_chat_params_init_minimax_m2(tmpl, params); + } + + // Kimi K2 format detection + if (src.find("<|im_system|>tool_declare<|im_middle|>") != std::string::npos && + src.find("<|tool_calls_section_begin|>") != std::string::npos && + src.find("## Return of") != std::string::npos) { + return common_chat_params_init_kimi_k2(tmpl, params); + } + + // Apriel 1.5 format detection + if (src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("") != std::string::npos && + src.find("<|assistant|>") != std::string::npos && + src.find("<|tool_result|>") != std::string::npos && + src.find("[") != std::string::npos && + src.find("]") != std::string::npos) { + return common_chat_params_init_apriel_1_5(tmpl, params); + } + // Use generic handler when mixing tools + JSON schema. // TODO: support that mix in handlers below. if ((params.tools.is_array() && params.json_schema.is_object())) { @@ -3009,7 +3360,7 @@ static common_chat_params common_chat_templates_apply_legacy( const struct common_chat_templates * tmpls, const struct common_chat_templates_inputs & inputs) { - int alloc_size = 0; + size_t alloc_size = 0; std::vector chat; std::vector contents; @@ -3031,7 +3382,8 @@ static common_chat_params common_chat_templates_apply_legacy( const auto & msg = inputs.messages[i]; const auto & content = contents[i]; chat.push_back({msg.role.c_str(), content.c_str()}); - alloc_size += (msg.role.size() + content.size()) * 1.25; + size_t msg_size = msg.role.size() + content.size(); + alloc_size += msg_size + (msg_size / 4); // == msg_size * 1.25 but avoiding float ops } std::vector buf(alloc_size); @@ -3053,6 +3405,11 @@ static common_chat_params common_chat_templates_apply_legacy( res = llama_chat_apply_template(src.c_str(), chat.data(), chat.size(), inputs.add_generation_prompt, buf.data(), buf.size()); } + // for safety, we check the result again + if (res < 0 || (size_t) res > buf.size()) { + throw std::runtime_error("failed to apply chat template, try using --jinja"); + } + common_chat_params params; params.prompt = std::string(buf.data(), res); if (!inputs.json_schema.empty()) { @@ -3139,6 +3496,24 @@ static void common_chat_parse(common_chat_msg_parser & builder) { case COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS: common_chat_parse_lfm2(builder); break; + case COMMON_CHAT_FORMAT_MINIMAX_M2: + common_chat_parse_minimax_m2(builder); + break; + case COMMON_CHAT_FORMAT_GLM_4_5: + common_chat_parse_glm_4_5(builder); + break; + case COMMON_CHAT_FORMAT_KIMI_K2: + common_chat_parse_kimi_k2(builder); + break; + case COMMON_CHAT_FORMAT_QWEN3_CODER_XML: + common_chat_parse_qwen3_coder_xml(builder); + break; + case COMMON_CHAT_FORMAT_APRIEL_1_5: + common_chat_parse_apriel_1_5(builder); + break; + case COMMON_CHAT_FORMAT_XIAOMI_MIMO: + common_chat_parse_xiaomi_mimo(builder); + break; default: throw std::runtime_error(std::string("Unsupported format: ") + common_chat_format_name(builder.syntax().format)); } diff --git a/common/chat.h b/common/chat.h index 50efb0d4e..754c411e2 100644 --- a/common/chat.h +++ b/common/chat.h @@ -117,6 +117,12 @@ enum common_chat_format { COMMON_CHAT_FORMAT_NEMOTRON_V2, COMMON_CHAT_FORMAT_APERTUS, COMMON_CHAT_FORMAT_LFM2_WITH_JSON_TOOLS, + COMMON_CHAT_FORMAT_GLM_4_5, + COMMON_CHAT_FORMAT_MINIMAX_M2, + COMMON_CHAT_FORMAT_KIMI_K2, + COMMON_CHAT_FORMAT_QWEN3_CODER_XML, + COMMON_CHAT_FORMAT_APRIEL_1_5, + COMMON_CHAT_FORMAT_XIAOMI_MIMO, COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats }; diff --git a/common/common.cpp b/common/common.cpp index 3c130c7c8..b4b72eae3 100644 --- a/common/common.cpp +++ b/common/common.cpp @@ -34,7 +34,6 @@ #include #include #include -#include #include #include @@ -68,6 +67,14 @@ #pragma warning(disable: 4244 4267) // possible loss of data #endif +common_time_meas::common_time_meas(int64_t & t_acc, bool disable) : t_start_us(disable ? -1 : ggml_time_us()), t_acc(t_acc) {} + +common_time_meas::~common_time_meas() { + if (t_start_us >= 0) { + t_acc += ggml_time_us() - t_start_us; + } +} + // // CPU utils // diff --git a/common/common.h b/common/common.h index 9ebf7abe9..d4e3dca2a 100644 --- a/common/common.h +++ b/common/common.h @@ -2,17 +2,15 @@ #pragma once +#include "ggml-opt.h" +#include "llama-cpp.h" + #include #include #include #include #include #include -#include -#include - -#include "ggml-opt.h" -#include "llama-cpp.h" #ifdef _WIN32 #define DIRECTORY_SEPARATOR '\\' @@ -30,6 +28,15 @@ #define DEFAULT_MODEL_PATH "models/7B/ggml-model-f16.gguf" +struct common_time_meas { + common_time_meas(int64_t & t_acc, bool disable = false); + ~common_time_meas(); + + const int64_t t_start_us; + + int64_t & t_acc; +}; + struct common_adapter_lora_info { std::string path; float scale; diff --git a/common/json-partial.cpp b/common/json-partial.cpp index 919927dc3..aaf11310a 100644 --- a/common/json-partial.cpp +++ b/common/json-partial.cpp @@ -297,8 +297,25 @@ bool common_json_parse( it = temptative_end; return true; } - // TODO: handle unclosed top-level primitive if the stack was empty but we got an error (e.g. "tru", "\"", etc...) - // fprintf(stderr, "Closing: TODO\n"); + // handle unclosed top-level primitive + if (err_loc.position != 0 && !healing_marker.empty() && err_loc.stack.empty()) { + std::string str(it, temptative_end); + const auto & magic_seed = out.healing_marker.marker = healing_marker; + if (can_parse(str + "\"")) { + // Was inside an string + str += (out.healing_marker.json_dump_marker = magic_seed) + "\""; + } else if (str[str.length() - 1] == '\\' && can_parse(str + "\\\"")) { + // Was inside an string after an escape + str += (out.healing_marker.json_dump_marker = "\\" + magic_seed) + "\""; + } else { + // TODO: handle more unclosed top-level primitive if the stack was empty but we got an error (e.g. "tru", "\"", etc...) + // fprintf(stderr, "Closing: TODO\n"); + return false; + } + out.json = json::parse(str); + it = temptative_end; + return true; + } return false; } out.json = json::parse(it, end); diff --git a/common/json-schema-to-grammar.cpp b/common/json-schema-to-grammar.cpp index 478aa1be7..e64dc059f 100644 --- a/common/json-schema-to-grammar.cpp +++ b/common/json-schema-to-grammar.cpp @@ -303,6 +303,8 @@ static std::string format_literal(const std::string & literal) { return "\"" + escaped + "\""; } +std::string gbnf_format_literal(const std::string & literal) { return format_literal(literal); } + class SchemaConverter { private: friend std::string build_grammar(const std::function & cb, const common_grammar_options & options); diff --git a/common/json-schema-to-grammar.h b/common/json-schema-to-grammar.h index 362991b54..c89ab7f99 100644 --- a/common/json-schema-to-grammar.h +++ b/common/json-schema-to-grammar.h @@ -18,4 +18,6 @@ struct common_grammar_options { bool dotall = false; }; +std::string gbnf_format_literal(const std::string & literal); + std::string build_grammar(const std::function & cb, const common_grammar_options & options = {}); diff --git a/common/sampling.cpp b/common/sampling.cpp index c69d525b5..7a6b7be1e 100644 --- a/common/sampling.cpp +++ b/common/sampling.cpp @@ -3,9 +3,10 @@ #include "common.h" #include "log.h" -#include -#include #include +#include +#include +#include // the ring buffer works similarly to std::deque, but with a fixed capacity // TODO: deduplicate with llama-impl.h @@ -112,6 +113,13 @@ struct common_sampler { llama_token_data_array cur_p; + void reset() { + prev.clear(); + + llama_sampler_reset(grmr); + llama_sampler_reset(chain); + } + void set_logits(struct llama_context * ctx, int idx) { const auto * logits = llama_get_logits_ith(ctx, idx); @@ -128,6 +136,12 @@ struct common_sampler { cur_p = { cur.data(), cur.size(), -1, false }; } + + common_time_meas tm() { + return common_time_meas(t_total_us, params.no_perf); + } + + mutable int64_t t_total_us = 0; }; std::string common_params_sampling::print() const { @@ -298,6 +312,8 @@ void common_sampler_free(struct common_sampler * gsmpl) { } void common_sampler_accept(struct common_sampler * gsmpl, llama_token token, bool accept_grammar) { + const auto tm = gsmpl->tm(); + if (accept_grammar) { llama_sampler_accept(gsmpl->grmr, token); } @@ -308,9 +324,7 @@ void common_sampler_accept(struct common_sampler * gsmpl, llama_token token, boo } void common_sampler_reset(struct common_sampler * gsmpl) { - llama_sampler_reset(gsmpl->grmr); - - llama_sampler_reset(gsmpl->chain); + gsmpl->reset(); } struct common_sampler * common_sampler_clone(common_sampler * gsmpl) { @@ -327,16 +341,54 @@ struct common_sampler * common_sampler_clone(common_sampler * gsmpl) { void common_perf_print(const struct llama_context * ctx, const struct common_sampler * gsmpl) { // TODO: measure grammar performance + const double t_sampling_ms = gsmpl ? 1e-3*gsmpl->t_total_us : 0; + + llama_perf_sampler_data data_smpl; + llama_perf_context_data data_ctx; + + memset(&data_smpl, 0, sizeof(data_smpl)); + memset(&data_ctx, 0, sizeof(data_ctx)); + if (gsmpl) { - llama_perf_sampler_print(gsmpl->chain); + auto & data = data_smpl; + + data = llama_perf_sampler(gsmpl->chain); + + // note: the sampling time includes the samplers time + extra time spent in common/sampling + LOG_INF("%s: sampling time = %10.2f ms\n", __func__, t_sampling_ms); + LOG_INF("%s: samplers time = %10.2f ms / %5d tokens\n", __func__, data.t_sample_ms, data.n_sample); } + if (ctx) { - llama_perf_context_print(ctx); + auto & data = data_ctx; + + data = llama_perf_context(ctx); + + const double t_end_ms = 1e-3 * ggml_time_us(); + + const double t_total_ms = t_end_ms - data.t_start_ms; + const double t_unacc_ms = t_total_ms - (t_sampling_ms + data.t_p_eval_ms + data.t_eval_ms); + const double t_unacc_pc = 100.0 * t_unacc_ms / t_total_ms; + + LOG_INF("%s: load time = %10.2f ms\n", __func__, data.t_load_ms); + LOG_INF("%s: prompt eval time = %10.2f ms / %5d tokens (%8.2f ms per token, %8.2f tokens per second)\n", + __func__, data.t_p_eval_ms, data.n_p_eval, data.t_p_eval_ms / data.n_p_eval, 1e3 / data.t_p_eval_ms * data.n_p_eval); + LOG_INF("%s: eval time = %10.2f ms / %5d runs (%8.2f ms per token, %8.2f tokens per second)\n", + __func__, data.t_eval_ms, data.n_eval, data.t_eval_ms / data.n_eval, 1e3 / data.t_eval_ms * data.n_eval); + LOG_INF("%s: total time = %10.2f ms / %5d tokens\n", __func__, (t_end_ms - data.t_start_ms), (data.n_p_eval + data.n_eval)); + LOG_INF("%s: unaccounted time = %10.2f ms / %5.1f %% (total - sampling - prompt eval - eval) / (total)\n", __func__, t_unacc_ms, t_unacc_pc); + LOG_INF("%s: graphs reused = %10d\n", __func__, data.n_reused); + llama_memory_breakdown_print(ctx); } } llama_token common_sampler_sample(struct common_sampler * gsmpl, struct llama_context * ctx, int idx, bool grammar_first) { + llama_synchronize(ctx); + + // start measuring sampling time after the llama_context synchronization in order to not measure any ongoing async operations + const auto tm = gsmpl->tm(); + gsmpl->set_logits(ctx, idx); auto & grmr = gsmpl->grmr; @@ -428,6 +480,8 @@ uint32_t common_sampler_get_seed(const struct common_sampler * gsmpl) { // helpers llama_token_data_array * common_sampler_get_candidates(struct common_sampler * gsmpl, bool do_sort) { + const auto tm = gsmpl->tm(); + auto * res = &gsmpl->cur_p; if (do_sort && !res->sorted) { diff --git a/convert_hf_to_gguf.py b/convert_hf_to_gguf.py index 0cc3df097..8743202ad 100755 --- a/convert_hf_to_gguf.py +++ b/convert_hf_to_gguf.py @@ -1673,11 +1673,9 @@ class GPTNeoXModel(TextModel): model_arch = gguf.MODEL_ARCH.GPTNEOX def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] - self.gguf_writer.add_context_length(self.hparams["max_position_embeddings"]) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_rope_dimension_count( int(self.hparams["rotary_pct"] * (self.hparams["hidden_size"] // self.hparams["num_attention_heads"])), @@ -1735,7 +1733,7 @@ class BloomModel(TextModel): self.gguf_writer.add_context_length(self.hparams.get("seq_length", n_embed)) self.gguf_writer.add_embedding_length(n_embed) self.gguf_writer.add_feed_forward_length(4 * n_embed) - self.gguf_writer.add_block_count(self.hparams["n_layer"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(n_head) self.gguf_writer.add_head_count_kv(n_head) self.gguf_writer.add_layer_norm_eps(self.hparams["layer_norm_epsilon"]) @@ -1798,10 +1796,9 @@ class MPTModel(TextModel): self.gguf_writer.add_unk_token_id(0) def set_gguf_parameters(self): - block_count = self.hparams["n_layers"] self.gguf_writer.add_context_length(self.hparams["max_seq_len"]) self.gguf_writer.add_embedding_length(self.hparams["d_model"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(4 * self.hparams["d_model"]) self.gguf_writer.add_head_count(self.hparams["n_heads"]) if kv_n_heads := self.hparams["attn_config"].get("kv_n_heads"): @@ -1834,7 +1831,6 @@ class OrionModel(TextModel): self._set_vocab_sentencepiece() def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] head_count = self.hparams["num_attention_heads"] head_count_kv = self.hparams.get("num_key_value_heads", head_count) @@ -1852,7 +1848,7 @@ class OrionModel(TextModel): self.gguf_writer.add_tensor_data_layout("Meta AI original pth") self.gguf_writer.add_context_length(ctx_length) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_head_count(head_count) self.gguf_writer.add_head_count_kv(head_count_kv) @@ -1869,7 +1865,6 @@ class BaichuanModel(TextModel): self._set_vocab_sentencepiece() def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] head_count = self.hparams["num_attention_heads"] head_count_kv = self.hparams.get("num_key_value_heads", head_count) @@ -1886,7 +1881,7 @@ class BaichuanModel(TextModel): self.gguf_writer.add_tensor_data_layout("Meta AI original pth") self.gguf_writer.add_context_length(ctx_length) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_rope_dimension_count(self.hparams["hidden_size"] // self.hparams["num_attention_heads"]) self.gguf_writer.add_head_count(head_count) @@ -1993,7 +1988,6 @@ class XverseModel(TextModel): special_vocab.add_to_gguf(self.gguf_writer) def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] head_count = self.hparams["num_attention_heads"] head_count_kv = self.hparams.get("num_key_value_heads", head_count) @@ -2010,7 +2004,7 @@ class XverseModel(TextModel): self.gguf_writer.add_tensor_data_layout("Meta AI original pth") self.gguf_writer.add_context_length(ctx_length) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_rope_dimension_count(self.hparams["hidden_size"] // self.hparams["num_attention_heads"]) self.gguf_writer.add_head_count(head_count) @@ -2053,10 +2047,6 @@ class FalconModel(TextModel): model_arch = gguf.MODEL_ARCH.FALCON def set_gguf_parameters(self): - block_count = self.hparams.get("num_hidden_layers") - if block_count is None: - block_count = self.hparams["n_layer"] # old name - n_head = self.hparams.get("num_attention_heads") if n_head is None: n_head = self.hparams["n_head"] # old name @@ -2069,7 +2059,7 @@ class FalconModel(TextModel): self.gguf_writer.add_tensor_data_layout("jploski") # qkv tensor transform self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) self.gguf_writer.add_feed_forward_length(4 * self.hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(n_head) self.gguf_writer.add_head_count_kv(n_head_kv) self.gguf_writer.add_layer_norm_eps(self.hparams["layer_norm_epsilon"]) @@ -2107,12 +2097,10 @@ class StarCoderModel(TextModel): model_arch = gguf.MODEL_ARCH.STARCODER def set_gguf_parameters(self): - block_count = self.hparams["n_layer"] - self.gguf_writer.add_context_length(self.hparams["n_positions"]) self.gguf_writer.add_embedding_length(self.hparams["n_embd"]) self.gguf_writer.add_feed_forward_length(4 * self.hparams["n_embd"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(self.hparams["n_head"]) self.gguf_writer.add_head_count_kv(1) self.gguf_writer.add_layer_norm_eps(self.hparams["layer_norm_epsilon"]) @@ -2142,14 +2130,12 @@ class RefactModel(TextModel): multiple_of = 256 ff_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of) - block_count = self.hparams["n_layer"] - # refact uses Alibi. So this is from config.json which might be used by training. self.gguf_writer.add_context_length(self.hparams["n_positions"]) self.gguf_writer.add_embedding_length(self.hparams["n_embd"]) self.gguf_writer.add_feed_forward_length(ff_dim) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(self.hparams["n_head"]) self.gguf_writer.add_head_count_kv(1) self.gguf_writer.add_layer_norm_rms_eps(self.hparams["layer_norm_epsilon"]) @@ -2196,11 +2182,10 @@ class StableLMModel(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] self.gguf_writer.add_context_length(hparams["max_position_embeddings"]) self.gguf_writer.add_embedding_length(hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(hparams["intermediate_size"]) rotary_factor = self.find_hparam(["partial_rotary_factor", "rope_pct"]) self.gguf_writer.add_rope_dimension_count(int(rotary_factor * (hparams["hidden_size"] // hparams["num_attention_heads"]))) @@ -3151,7 +3136,7 @@ class DbrxModel(TextModel): def set_gguf_parameters(self): ffn_config = self.hparams["ffn_config"] attn_config = self.hparams["attn_config"] - self.gguf_writer.add_block_count(self.hparams["n_layers"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_context_length(self.hparams["max_seq_len"]) self.gguf_writer.add_embedding_length(self.hparams["d_model"]) @@ -3353,7 +3338,7 @@ class QwenModel(TextModel): def set_gguf_parameters(self): self.gguf_writer.add_context_length(self.hparams["max_position_embeddings"]) - self.gguf_writer.add_block_count(self.hparams["num_hidden_layers"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_rope_freq_base(self.hparams["rotary_emb_base"]) @@ -4384,7 +4369,7 @@ class GPT2Model(TextModel): model_arch = gguf.MODEL_ARCH.GPT2 def set_gguf_parameters(self): - self.gguf_writer.add_block_count(self.hparams["n_layer"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_context_length(self.hparams["n_ctx"]) self.gguf_writer.add_embedding_length(self.hparams["n_embd"]) self.gguf_writer.add_feed_forward_length(4 * self.hparams["n_embd"]) @@ -4416,8 +4401,6 @@ class Phi2Model(TextModel): model_arch = gguf.MODEL_ARCH.PHI2 def set_gguf_parameters(self): - block_count = self.find_hparam(["num_hidden_layers", "n_layer"]) - rot_pct = self.find_hparam(["partial_rotary_factor"]) n_embd = self.find_hparam(["hidden_size", "n_embd"]) n_head = self.find_hparam(["num_attention_heads", "n_head"]) @@ -4426,7 +4409,7 @@ class Phi2Model(TextModel): self.gguf_writer.add_embedding_length(n_embd) self.gguf_writer.add_feed_forward_length(4 * n_embd) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(n_head) self.gguf_writer.add_head_count_kv(n_head) self.gguf_writer.add_layer_norm_eps(self.find_hparam(["layer_norm_epsilon", "layer_norm_eps"])) @@ -4544,8 +4527,6 @@ class Phi3MiniModel(TextModel): special_vocab.add_to_gguf(self.gguf_writer) def set_gguf_parameters(self): - block_count = self.find_hparam(["num_hidden_layers", "n_layer"]) - n_embd = self.find_hparam(["hidden_size", "n_embd"]) n_head = self.find_hparam(["num_attention_heads", "n_head"]) n_head_kv = self.find_hparam(["num_key_value_heads", "n_head_kv"]) @@ -4559,7 +4540,7 @@ class Phi3MiniModel(TextModel): self.gguf_writer.add_rope_scaling_orig_ctx_len(orig_max_pos_embds) self.gguf_writer.add_embedding_length(n_embd) self.gguf_writer.add_feed_forward_length(self.find_hparam(["intermediate_size"])) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(n_head) self.gguf_writer.add_head_count_kv(n_head_kv) self.gguf_writer.add_layer_norm_rms_eps(rms_eps) @@ -4679,12 +4660,11 @@ class PlamoModel(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] self.gguf_writer.add_context_length(4096) # not in config.json self.gguf_writer.add_embedding_length(hparams["hidden_size"]) self.gguf_writer.add_feed_forward_length(hparams["intermediate_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(hparams["num_attention_heads"]) self.gguf_writer.add_head_count_kv(5) # hparams["num_key_value_heads"]) is wrong self.gguf_writer.add_layer_norm_rms_eps(hparams["rms_norm_eps"]) @@ -4807,7 +4787,6 @@ class Plamo2Model(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] self.gguf_writer.add_vocab_size(self.hparams["vocab_size"]) # Which layers are Mamba layers @@ -4819,10 +4798,10 @@ class Plamo2Model(TextModel): num_attention_heads = [] if mamba_enabled: - for i in range(block_count): - if block_count <= (mamba_step // 2): + for i in range(self.block_count): + if self.block_count <= (mamba_step // 2): # use attention in last layer - is_mamba = (i != block_count - 1) + is_mamba = (i != self.block_count - 1) else: is_mamba = (i % mamba_step) != (mamba_step // 2) if is_mamba: @@ -4840,7 +4819,7 @@ class Plamo2Model(TextModel): self.gguf_writer.add_embedding_length(hparams.get("hidden_size", 4096)) self.gguf_writer.add_key_length(hparams.get("hidden_size_per_head", 128)) self.gguf_writer.add_value_length(hparams.get("hidden_size_per_head", 128)) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_layer_norm_rms_eps(hparams.get("rms_norm_eps", 1e-06)) self.gguf_writer.add_rope_freq_base(hparams.get("rope_theta", 10000)) @@ -4897,12 +4876,10 @@ class CodeShellModel(TextModel): model_arch = gguf.MODEL_ARCH.CODESHELL def set_gguf_parameters(self): - block_count = self.hparams["n_layer"] - self.gguf_writer.add_context_length(self.hparams["n_positions"]) self.gguf_writer.add_embedding_length(self.hparams["n_embd"]) self.gguf_writer.add_feed_forward_length(4 * self.hparams["n_embd"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(self.hparams["n_head"]) self.gguf_writer.add_head_count_kv(self.hparams["num_query_groups"]) self.gguf_writer.add_layer_norm_eps(self.hparams["layer_norm_epsilon"]) @@ -5044,7 +5021,7 @@ class InternLM2Model(TextModel): def set_gguf_parameters(self): self.gguf_writer.add_context_length(self.hparams["max_position_embeddings"]) - self.gguf_writer.add_block_count(self.hparams["num_hidden_layers"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_embedding_length(self.hparams["hidden_size"]) self.gguf_writer.add_feed_forward_length(self.hparams["intermediate_size"]) self.gguf_writer.add_rope_freq_base(self.hparams["rope_theta"]) @@ -5665,11 +5642,10 @@ class GemmaModel(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] self.gguf_writer.add_context_length(hparams["max_position_embeddings"]) self.gguf_writer.add_embedding_length(hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(hparams["intermediate_size"]) self.gguf_writer.add_head_count(hparams["num_attention_heads"]) self.gguf_writer.add_head_count_kv(self.hparams["num_key_value_heads"] if "num_key_value_heads" in hparams else hparams["num_attention_heads"]) @@ -5705,11 +5681,10 @@ class Gemma2Model(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] self.gguf_writer.add_context_length(hparams["max_position_embeddings"]) self.gguf_writer.add_embedding_length(hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(hparams["intermediate_size"]) self.gguf_writer.add_head_count(hparams["num_attention_heads"]) self.gguf_writer.add_head_count_kv(self.hparams["num_key_value_heads"] if "num_key_value_heads" in hparams else hparams["num_attention_heads"]) @@ -5753,12 +5728,11 @@ class Gemma3Model(TextModel): def set_gguf_parameters(self): hparams = self.hparams - block_count = hparams["num_hidden_layers"] # some default values are not specified in the hparams self.gguf_writer.add_context_length(hparams.get("max_position_embeddings", 131072)) self.gguf_writer.add_embedding_length(hparams["hidden_size"]) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_feed_forward_length(hparams["intermediate_size"]) self.gguf_writer.add_head_count(hparams.get("num_attention_heads", 8)) self.gguf_writer.add_layer_norm_rms_eps(self.hparams.get("rms_norm_eps", 1e-6)) @@ -6034,7 +6008,6 @@ class Rwkv6Model(TextModel): self._set_vocab_rwkv_world() def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] head_size = self.hparams["head_size"] hidden_size = self.hparams["hidden_size"] layer_norm_eps = self.hparams["layer_norm_epsilon"] @@ -6046,7 +6019,7 @@ class Rwkv6Model(TextModel): # RWKV isn't context limited self.gguf_writer.add_context_length(1048576) self.gguf_writer.add_embedding_length(hidden_size) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_layer_norm_eps(layer_norm_eps) self.gguf_writer.add_rescale_every_n_layers(rescale_every_n_layers) self.gguf_writer.add_wkv_head_size(head_size) @@ -6110,7 +6083,6 @@ class RWKV6Qwen2Model(Rwkv6Model): self._set_vocab_gpt2() def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] num_attention_heads = self.hparams["num_attention_heads"] num_key_value_heads = self.hparams["num_key_value_heads"] hidden_size = self.hparams["hidden_size"] @@ -6123,7 +6095,7 @@ class RWKV6Qwen2Model(Rwkv6Model): # RWKV isn't context limited self.gguf_writer.add_context_length(1048576) self.gguf_writer.add_embedding_length(hidden_size) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_wkv_head_size(head_size) self.gguf_writer.add_time_mix_extra_dim(time_mix_extra_dim) self.gguf_writer.add_time_decay_extra_dim(time_decay_extra_dim) @@ -6164,7 +6136,6 @@ class Rwkv7Model(TextModel): return max(1, round(hidden_size ** exponent * multiplier / 32)) * 32 def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] try: head_size = self.hparams["head_size"] layer_norm_eps = self.hparams["layer_norm_epsilon"] @@ -6189,7 +6160,7 @@ class Rwkv7Model(TextModel): # RWKV isn't context limited self.gguf_writer.add_context_length(1048576) self.gguf_writer.add_embedding_length(hidden_size) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_layer_norm_eps(layer_norm_eps) self.gguf_writer.add_wkv_head_size(head_size) self.gguf_writer.add_decay_lora_rank(lora_rank_decay) @@ -6283,7 +6254,6 @@ class ARwkv7Model(Rwkv7Model): self._set_vocab_gpt2() def set_gguf_parameters(self): - block_count = self.hparams["num_hidden_layers"] hidden_size = self.hparams["hidden_size"] head_size = self.hparams["head_size"] rms_norm_eps = self.hparams["rms_norm_eps"] @@ -6300,7 +6270,7 @@ class ARwkv7Model(Rwkv7Model): # RWKV isn't context limited self.gguf_writer.add_context_length(1048576) self.gguf_writer.add_embedding_length(hidden_size) - self.gguf_writer.add_block_count(block_count) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_layer_norm_rms_eps(rms_norm_eps) self.gguf_writer.add_wkv_head_size(head_size) self.gguf_writer.add_decay_lora_rank(lora_rank_decay) @@ -7524,7 +7494,7 @@ class T5Model(TextModel): self.gguf_writer.add_context_length(n_ctx) self.gguf_writer.add_embedding_length(self.hparams["d_model"]) self.gguf_writer.add_feed_forward_length(self.hparams["d_ff"]) - self.gguf_writer.add_block_count(self.hparams["num_layers"]) + self.gguf_writer.add_block_count(self.block_count) if (dec_n_layer := self.hparams.get("num_decoder_layers")) is not None: self.gguf_writer.add_decoder_block_count(dec_n_layer) self.gguf_writer.add_head_count(self.hparams["num_heads"]) @@ -7663,7 +7633,7 @@ class T5EncoderModel(TextModel): self.gguf_writer.add_context_length(n_ctx) self.gguf_writer.add_embedding_length(self.hparams["d_model"]) self.gguf_writer.add_feed_forward_length(self.hparams["d_ff"]) - self.gguf_writer.add_block_count(self.hparams["num_layers"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(self.hparams["num_heads"]) self.gguf_writer.add_key_length(self.hparams["d_kv"]) self.gguf_writer.add_value_length(self.hparams["d_kv"]) @@ -7726,7 +7696,7 @@ class JaisModel(TextModel): self._set_vocab_gpt2() def set_gguf_parameters(self): - self.gguf_writer.add_block_count(self.hparams["n_layer"]) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_context_length(self.hparams["n_positions"]) self.gguf_writer.add_embedding_length(self.hparams["n_embd"]) self.gguf_writer.add_feed_forward_length(self.hparams["n_inner"]) @@ -8068,7 +8038,7 @@ class ChatGLMModel(TextModel): self.gguf_writer.add_context_length(self.hparams.get("seq_length", n_embed)) self.gguf_writer.add_embedding_length(n_embed) self.gguf_writer.add_feed_forward_length(self.hparams.get("ffn_hidden_size", self.hparams.get("intermediate_size", 4 * n_embed))) - self.gguf_writer.add_block_count(self.hparams.get("num_layers", self.hparams["num_hidden_layers"])) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_head_count(n_head) self.gguf_writer.add_head_count_kv(n_head_kv) self.gguf_writer.add_layer_norm_rms_eps(self.hparams.get("layernorm_epsilon",1e-5)) @@ -8150,7 +8120,6 @@ class ExaoneModel(TextModel): num_kv_heads = hparams.get("num_key_value_heads", num_heads) layer_norm_eps = hparams["layer_norm_epsilon"] intermediate_size = hparams["intermediate_size"] if "intermediate_size" in hparams else 4 * embed_dim - num_layers = hparams["num_layers"] # ignore for now as EXAONE-3.0-7.8B-Instruct attentino_dropout is 0.0 # attention_dropout_rate = hparams["attention_dropout"] # ignore for now as EXAONE-3.0-7.8B-Instruct embed_dropout is 0.0 @@ -8161,7 +8130,7 @@ class ExaoneModel(TextModel): self.gguf_writer.add_context_length(max_position_embeddings) self.gguf_writer.add_layer_norm_rms_eps(layer_norm_eps) self.gguf_writer.add_feed_forward_length(intermediate_size) - self.gguf_writer.add_block_count(num_layers) + self.gguf_writer.add_block_count(self.block_count) self.gguf_writer.add_file_type(self.ftype) if (rope_theta := self.hparams.get("rope_theta")) is not None: diff --git a/convert_lora_to_gguf.py b/convert_lora_to_gguf.py index befe8ab9c..57c6cd0df 100755 --- a/convert_lora_to_gguf.py +++ b/convert_lora_to_gguf.py @@ -277,10 +277,15 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def load_hparams_from_hf(hf_model_id: str) -> dict[str, Any]: +def load_hparams_from_hf(hf_model_id: str) -> tuple[dict[str, Any], Path | None]: + from huggingface_hub import try_to_load_from_cache + # normally, adapter does not come with base model config, we need to load it from AutoConfig config = AutoConfig.from_pretrained(hf_model_id) - return config.to_dict() + cache_dir = try_to_load_from_cache(hf_model_id, "config.json") + cache_dir = Path(cache_dir).parent if isinstance(cache_dir, str) else None + + return config.to_dict(), cache_dir if __name__ == '__main__': @@ -325,13 +330,13 @@ if __name__ == '__main__': # load base model if base_model_id is not None: logger.info(f"Loading base model from Hugging Face: {base_model_id}") - hparams = load_hparams_from_hf(base_model_id) + hparams, dir_base_model = load_hparams_from_hf(base_model_id) elif dir_base_model is None: if "base_model_name_or_path" in lparams: model_id = lparams["base_model_name_or_path"] logger.info(f"Loading base model from Hugging Face: {model_id}") try: - hparams = load_hparams_from_hf(model_id) + hparams, dir_base_model = load_hparams_from_hf(model_id) except OSError as e: logger.error(f"Failed to load base model config: {e}") logger.error("Please try downloading the base model and add its path to --base") @@ -480,6 +485,7 @@ if __name__ == '__main__': dir_lora_model=dir_lora, lora_alpha=alpha, hparams=hparams, + remote_hf_model_id=base_model_id, ) logger.info("Exporting model...") diff --git a/embd_res/klite.embd b/embd_res/klite.embd index fa441654a..d058ead51 100644 --- a/embd_res/klite.embd +++ b/embd_res/klite.embd @@ -12,7 +12,7 @@ Current version indicated by LITEVER below. --> + +
+
+ {#if isPdf} +
+ + + +
+ {/if} +
+ +
+ {#if isImage && displayPreview} +
+ {displayName} +
+ {:else if isPdf && pdfViewMode === 'pages'} + {#if pdfImagesLoading} +
+
+
+ +

Converting PDF to images...

+
+
+ {:else if pdfImagesError} +
+
+ + +

Failed to load PDF images

+ +

{pdfImagesError}

+ + +
+
+ {:else if pdfImages.length > 0} +
+ {#each pdfImages as image, index (image)} +
+

Page {index + 1}

+ + PDF Page {index + 1} +
+ {/each} +
+ {:else} +
+
+ + +

No PDF pages available

+
+
+ {/if} + {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent} +
+ {displayTextContent} +
+ {:else if isAudio} +
+
+ + + {#if attachment?.type === 'audioFile'} + + {:else if uploadedFile?.preview} + + {:else} +

Audio preview not available

+ {/if} + +

+ {displayName} +

+
+
+ {:else} +
+
+ {#if IconComponent} + + {/if} + +

Preview not available for this file type

+
+
+ {/if} +
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte deleted file mode 100644 index 8a3389b65..000000000 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte +++ /dev/null @@ -1,314 +0,0 @@ - - - - - -
-
- {#if IconComponent} - - {/if} - -
- {displayName} - -
- {displayType} - - {#if displaySize} - - - {formatFileSize(displaySize)} - {/if} -
-
-
- - {#if isPdf} -
- - - -
- {/if} -
-
- -
- {#if isImage && displayPreview} -
- {displayName} -
- {:else if isPdf && pdfViewMode === 'pages'} - {#if pdfImagesLoading} -
-
-
- -

Converting PDF to images...

-
-
- {:else if pdfImagesError} -
-
- - -

Failed to load PDF images

- -

{pdfImagesError}

- - -
-
- {:else if pdfImages.length > 0} -
- {#each pdfImages as image, index (image)} -
-

Page {index + 1}

- - PDF Page {index + 1} -
- {/each} -
- {:else} -
-
- - -

No PDF pages available

-
-
- {/if} - {:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent} -
- {displayTextContent} -
- {:else if isAudio} -
-
- - - {#if attachment?.type === 'audioFile'} - - {:else if uploadedFile?.preview} - - {:else} -

Audio preview not available

- {/if} - -

- {displayName} -

-
-
- {:else} -
-
- {#if IconComponent} - - {/if} - -

Preview not available for this file type

-
-
- {/if} -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte rename to tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte rename to tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte index a2aea0232..050c79331 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte @@ -1,11 +1,10 @@ - - - - - - - All Attachments ({displayItems.length}) - - View and manage all attached files - - - -
- {#if fileItems.length > 0} -
-

Files ({fileItems.length})

-
- {#each fileItems as item (item.id)} - openPreview(item, event)} - /> - {/each} -
-
- {/if} - - {#if imageItems.length > 0} -
-

Images ({imageItems.length})

-
- {#each imageItems as item (item.id)} - {#if item.preview} - openPreview(item, event)} - /> - {/if} - {/each} -
-
- {/if} +
+
+ {#if fileItems.length > 0} +
+

Files ({fileItems.length})

+
+ {#each fileItems as item (item.id)} + openPreview(item, event)} + /> + {/each} +
- - - + {/if} + + {#if imageItems.length > 0} +
+

Images ({imageItems.length})

+
+ {#each imageItems as item (item.id)} + {#if item.preview} + openPreview(item, event)} + /> + {/if} + {/each} +
+
+ {/if} +
+
{#if previewItem} - import { Square, ArrowUp } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; - import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte'; - import ChatFormActionRecord from './ChatFormActionRecord.svelte'; - import ChatFormModelSelector from './ChatFormModelSelector.svelte'; + import { + ChatFormActionFileAttachments, + ChatFormActionRecord, + ChatFormModelSelector + } from '$lib/components/app'; import { config } from '$lib/stores/settings.svelte'; import type { FileTypeCategory } from '$lib/enums/files'; diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index e47a5a7db..ae0dc2ed9 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -10,6 +10,7 @@ class?: string; message: DatabaseMessage; onCopy?: (message: DatabaseMessage) => void; + onContinueAssistantMessage?: (message: DatabaseMessage) => void; onDelete?: (message: DatabaseMessage) => void; onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void; onEditWithReplacement?: ( @@ -17,6 +18,7 @@ newContent: string, shouldBranch: boolean ) => void; + onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void; onNavigateToSibling?: (siblingId: string) => void; onRegenerateWithBranching?: (message: DatabaseMessage) => void; siblingInfo?: ChatMessageSiblingInfo | null; @@ -26,9 +28,11 @@ class: className = '', message, onCopy, + onContinueAssistantMessage, onDelete, onEditWithBranching, onEditWithReplacement, + onEditUserMessagePreserveResponses, onNavigateToSibling, onRegenerateWithBranching, siblingInfo = null @@ -133,17 +137,33 @@ onRegenerateWithBranching?.(message); } + function handleContinue() { + onContinueAssistantMessage?.(message); + } + function handleSaveEdit() { if (message.role === 'user') { + // For user messages, trim to avoid accidental whitespace onEditWithBranching?.(message, editedContent.trim()); } else { - onEditWithReplacement?.(message, editedContent.trim(), shouldBranchAfterEdit); + // For assistant messages, preserve exact content including trailing whitespace + // This is important for the Continue feature to work properly + onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit); } isEditing = false; shouldBranchAfterEdit = false; } + function handleSaveEditOnly() { + if (message.role === 'user') { + // For user messages, trim to avoid accidental whitespace + onEditUserMessagePreserveResponses?.(message, editedContent.trim()); + } + + isEditing = false; + } + function handleShowDeleteDialogChange(show: boolean) { showDeleteDialog = show; } @@ -166,6 +186,7 @@ onEditedContentChange={handleEditedContentChange} {onNavigateToSibling} onSaveEdit={handleSaveEdit} + onSaveEditOnly={handleSaveEditOnly} onShowDeleteDialogChange={handleShowDeleteDialogChange} {showDeleteDialog} {siblingInfo} @@ -181,6 +202,7 @@ messageContent={message.content} onCancelEdit={handleCancelEdit} onConfirmDelete={handleConfirmDelete} + onContinue={handleContinue} onCopy={handleCopy} onDelete={handleDelete} onEdit={handleEdit} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte index c16a3105c..ff335c328 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte @@ -1,7 +1,10 @@ - - -
- - + +
+
+ +
+ - -
-
- Settings - - -
- - -
-
- {#each settingSections as section (section.title)} - - {/each} -
-
- - +
+
+ {#each settingSections as section (section.title)} + + {/each}
+ +
- - -
-
- - - {#if currentSection.title === 'Import/Export'} - - {:else} -
- -
- {/if} -
- -
-

- Settings are saved in browser's localStorage -

-
-
-
+
- - - + +
+
+ + + {#if currentSection.title === 'Import/Export'} + + {:else} +
+ +
+ {/if} +
+ +
+

Settings are saved in browser's localStorage

+
+
+
+
+ + diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte index d17f7e422..8834e3e3e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte @@ -1,5 +1,5 @@ - - - - - - - - - Select Conversations to {mode === 'export' ? 'Export' : 'Import'} - - - - {#if mode === 'export'} - Choose which conversations you want to export. Selected conversations will be downloaded - as a JSON file. - {:else} - Choose which conversations you want to import. Selected conversations will be merged - with your existing conversations. - {/if} - - - -
-
- - - - - {#if searchQuery} - - {/if} -
- -
- - {selectedIds.size} of {conversations.length} selected - {#if searchQuery} - ({filteredConversations.length} shown) - {/if} - -
- -
- - - - - - - - - - - - - {#if filteredConversations.length === 0} - - - - {:else} - {#each filteredConversations as conv (conv.id)} - toggleConversation(conv.id, e.shiftKey)} - > - - - - - - - {/each} - {/if} - -
- - Conversation NameMessages
- {#if searchQuery} - No conversations found matching "{searchQuery}" - {:else} - No conversations available - {/if} -
- { - e.preventDefault(); - e.stopPropagation(); - toggleConversation(conv.id, e.shiftKey); - }} - /> - -
- {conv.name || 'Untitled conversation'} -
-
- {messageCountMap.get(conv.id) ?? 0} -
-
-
-
- - - - - - -
-
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte index 5976e5dd0..34f3da53e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { Trash2 } from '@lucide/svelte'; - import { ChatSidebarConversationItem, ConfirmationDialog } from '$lib/components/app'; + import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app'; import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; import * as Sidebar from '$lib/components/ui/sidebar'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; @@ -158,7 +158,7 @@
- + import * as Dialog from '$lib/components/ui/dialog'; + import { ChatAttachmentPreview } from '$lib/components/app'; + import { formatFileSize } from '$lib/utils/file-preview'; + + interface Props { + open: boolean; + // Either an uploaded file or a stored attachment + uploadedFile?: ChatUploadedFile; + attachment?: DatabaseMessageExtra; + // For uploaded files + preview?: string; + name?: string; + type?: string; + size?: number; + textContent?: string; + } + + let { + open = $bindable(), + uploadedFile, + attachment, + preview, + name, + type, + size, + textContent + }: Props = $props(); + + let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state(); + + let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File'); + + let displayType = $derived( + uploadedFile?.type || + (attachment?.type === 'imageFile' + ? 'image' + : attachment?.type === 'textFile' + ? 'text' + : attachment?.type === 'audioFile' + ? attachment.mimeType || 'audio' + : attachment?.type === 'pdfFile' + ? 'application/pdf' + : type || 'unknown') + ); + + let displaySize = $derived(uploadedFile?.size || size); + + $effect(() => { + if (open && chatAttachmentPreviewRef) { + chatAttachmentPreviewRef.reset(); + } + }); + + + + + + {displayName} + + {displayType} + {#if displaySize} + • {formatFileSize(displaySize)} + {/if} + + + + + + diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte new file mode 100644 index 000000000..8f6ca76d4 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte @@ -0,0 +1,51 @@ + + + + + + + + + All Attachments ({totalCount}) + View and manage all attached files + + + + + + diff --git a/tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte rename to tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte new file mode 100644 index 000000000..e9aaa1000 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte @@ -0,0 +1,37 @@ + + + + + + + diff --git a/tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte rename to tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte new file mode 100644 index 000000000..1f8ea64be --- /dev/null +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte @@ -0,0 +1,68 @@ + + + + + + + + + + Select Conversations to {mode === 'export' ? 'Export' : 'Import'} + + + {#if mode === 'export'} + Choose which conversations you want to export. Selected conversations will be downloaded + as a JSON file. + {:else} + Choose which conversations you want to import. Selected conversations will be merged + with your existing conversations. + {/if} + + + + + + + diff --git a/tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte rename to tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte diff --git a/tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte similarity index 100% rename from tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte rename to tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index a695f9974..54bd8d5aa 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -1,56 +1,63 @@ +// Chat + +export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte'; +export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte'; +export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte'; export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte'; -export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte'; -export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte'; -export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte'; -export { default as ChatAttachmentsViewAllDialog } from './chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte'; +export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte'; export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte'; -export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte'; -export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte'; -export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte'; -export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActionRecord.svelte'; -export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte'; -export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte'; +export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte'; +export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte'; +export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte'; export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte'; +export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte'; +export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte'; +export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte'; export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte'; export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte'; +export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte'; -export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte'; -export { default as ChatProcessingInfo } from './chat/ChatProcessingInfo.svelte'; - -export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte'; -export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte'; export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte'; +export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte'; +export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte'; +export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte'; -export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte'; +export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte'; export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte'; export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte'; -export { default as ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte'; -export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte'; -export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte'; +export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte'; +export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte'; export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte'; export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte'; export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte'; -export { default as ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte'; -export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte'; -export { default as ConversationTitleUpdateDialog } from './dialogs/ConversationTitleUpdateDialog.svelte'; +// Dialogs +export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte'; +export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte'; +export { default as DialogChatError } from './dialogs/DialogChatError.svelte'; +export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte'; +export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte'; +export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte'; +export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte'; +export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte'; + +// Miscellanous + +export { default as ActionButton } from './misc/ActionButton.svelte'; +export { default as ActionDropdown } from './misc/ActionDropdown.svelte'; +export { default as ConversationSelection } from './misc/ConversationSelection.svelte'; export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte'; - export { default as MarkdownContent } from './misc/MarkdownContent.svelte'; - export { default as RemoveButton } from './misc/RemoveButton.svelte'; +// Server + export { default as ServerStatus } from './server/ServerStatus.svelte'; export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte'; export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte'; export { default as ServerInfo } from './server/ServerInfo.svelte'; - -// Shared components -export { default as ActionButton } from './misc/ActionButton.svelte'; -export { default as ActionDropdown } from './misc/ActionDropdown.svelte'; -export { default as ConfirmationDialog } from './dialogs/ConfirmationDialog.svelte'; diff --git a/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte b/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte new file mode 100644 index 000000000..e2095e087 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte @@ -0,0 +1,205 @@ + + +
+
+ + + + + {#if searchQuery} + + {/if} +
+ +
+ + {selectedIds.size} of {conversations.length} selected + {#if searchQuery} + ({filteredConversations.length} shown) + {/if} + +
+ +
+ + + + + + + + + + + + + {#if filteredConversations.length === 0} + + + + {:else} + {#each filteredConversations as conv (conv.id)} + toggleConversation(conv.id, e.shiftKey)} + > + + + + + + + {/each} + {/if} + +
+ + Conversation NameMessages
+ {#if searchQuery} + No conversations found matching "{searchQuery}" + {:else} + No conversations available + {/if} +
+ { + e.preventDefault(); + e.stopPropagation(); + toggleConversation(conv.id, e.shiftKey); + }} + /> + +
+ {conv.name || 'Untitled conversation'} +
+
+ {messageCountMap.get(conv.id) ?? 0} +
+
+
+ +
+ + + +
+
diff --git a/tools/server/webui/src/lib/constants/settings-config.ts b/tools/server/webui/src/lib/constants/settings-config.ts index 7547832d9..c25ea23f3 100644 --- a/tools/server/webui/src/lib/constants/settings-config.ts +++ b/tools/server/webui/src/lib/constants/settings-config.ts @@ -38,7 +38,8 @@ export const SETTING_CONFIG_DEFAULT: Record = max_tokens: -1, custom: '', // custom json-stringified object // experimental features - pyInterpreterEnabled: false + pyInterpreterEnabled: false, + enableContinueGeneration: false }; export const SETTING_CONFIG_INFO: Record = { @@ -96,5 +97,7 @@ export const SETTING_CONFIG_INFO: Record = { modelSelectorEnabled: 'Enable the model selector in the chat input to choose the inference model. Sends the associated model field in API requests.', pyInterpreterEnabled: - 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.' + 'Enable Python interpreter using Pyodide. Allows running Python code in markdown code blocks.', + enableContinueGeneration: + 'Enable "Continue" button for assistant messages. Currently works only with non-reasoning models.' }; diff --git a/tools/server/webui/src/lib/services/chat.ts b/tools/server/webui/src/lib/services/chat.ts index 1908d8390..aa83910b2 100644 --- a/tools/server/webui/src/lib/services/chat.ts +++ b/tools/server/webui/src/lib/services/chat.ts @@ -312,7 +312,6 @@ export class ChatService { let aggregatedContent = ''; let fullReasoningContent = ''; let aggregatedToolCalls: ApiChatCompletionToolCall[] = []; - let hasReceivedData = false; let lastTimings: ChatMessageTimings | undefined; let streamFinished = false; let modelEmitted = false; @@ -352,8 +351,6 @@ export class ChatService { return; } - hasReceivedData = true; - if (!abortSignal?.aborted) { onToolCallChunk?.(serializedToolCalls); } @@ -415,7 +412,6 @@ export class ChatService { if (content) { finalizeOpenToolCallBatch(); - hasReceivedData = true; aggregatedContent += content; if (!abortSignal?.aborted) { onChunk?.(content); @@ -424,7 +420,6 @@ export class ChatService { if (reasoningContent) { finalizeOpenToolCallBatch(); - hasReceivedData = true; fullReasoningContent += reasoningContent; if (!abortSignal?.aborted) { onReasoningChunk?.(reasoningContent); @@ -446,15 +441,6 @@ export class ChatService { if (streamFinished) { finalizeOpenToolCallBatch(); - if ( - !hasReceivedData && - aggregatedContent.length === 0 && - aggregatedToolCalls.length === 0 - ) { - const noResponseError = new Error('No response received from server. Please try again.'); - throw noResponseError; - } - const finalToolCalls = aggregatedToolCalls.length > 0 ? JSON.stringify(aggregatedToolCalls) : undefined; diff --git a/tools/server/webui/src/lib/stores/chat.svelte.ts b/tools/server/webui/src/lib/stores/chat.svelte.ts index 5b5a9d74a..c70b9580c 100644 --- a/tools/server/webui/src/lib/stores/chat.svelte.ts +++ b/tools/server/webui/src/lib/stores/chat.svelte.ts @@ -1486,6 +1486,10 @@ class ChatStore { timestamp: Date.now() }); + // Ensure currNode points to the edited message to maintain correct path + await DatabaseStore.updateCurrentNode(this.activeConversation.id, messageToEdit.id); + this.activeConversation.currNode = messageToEdit.id; + this.updateMessageAtIndex(messageIndex, { content: newContent, timestamp: Date.now() @@ -1499,6 +1503,69 @@ class ChatStore { } } + /** + * Edits a user message and preserves all responses below + * Updates the message content in-place without deleting or regenerating responses + * + * **Use Case**: When you want to fix a typo or rephrase a question without losing the assistant's response + * + * **Important Behavior:** + * - Does NOT create a branch (unlike editMessageWithBranching) + * - Does NOT regenerate assistant responses + * - Only updates the user message content in the database + * - Preserves the entire conversation tree below the edited message + * - Updates conversation title if this is the first user message + * + * @param messageId - The ID of the user message to edit + * @param newContent - The new content for the message + */ + async editUserMessagePreserveResponses(messageId: string, newContent: string): Promise { + if (!this.activeConversation) return; + + try { + const messageIndex = this.findMessageIndex(messageId); + if (messageIndex === -1) { + console.error('Message not found for editing'); + return; + } + + const messageToEdit = this.activeMessages[messageIndex]; + if (messageToEdit.role !== 'user') { + console.error('Only user messages can be edited with this method'); + return; + } + + // Simply update the message content in-place + await DatabaseStore.updateMessage(messageId, { + content: newContent, + timestamp: Date.now() + }); + + this.updateMessageAtIndex(messageIndex, { + content: newContent, + timestamp: Date.now() + }); + + // Check if first user message for title update + const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); + const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); + const isFirstUserMessage = + rootMessage && messageToEdit.parent === rootMessage.id && messageToEdit.role === 'user'; + + if (isFirstUserMessage && newContent.trim()) { + await this.updateConversationTitleWithConfirmation( + this.activeConversation.id, + newContent.trim(), + this.titleUpdateConfirmationCallback + ); + } + + this.updateConversationTimestamp(); + } catch (error) { + console.error('Failed to edit user message:', error); + } + } + /** * Edits a message by creating a new branch with the edited content * @param messageId - The ID of the message to edit @@ -1696,6 +1763,200 @@ class ChatStore { } } + /** + * Continues generation for an existing assistant message + * @param messageId - The ID of the assistant message to continue + */ + async continueAssistantMessage(messageId: string): Promise { + if (!this.activeConversation || this.isLoading) return; + + try { + const messageIndex = this.findMessageIndex(messageId); + if (messageIndex === -1) { + console.error('Message not found for continuation'); + return; + } + + const messageToContinue = this.activeMessages[messageIndex]; + if (messageToContinue.role !== 'assistant') { + console.error('Only assistant messages can be continued'); + return; + } + + // Race condition protection: Check if this specific conversation is already loading + // This prevents multiple rapid clicks on "Continue" from creating concurrent operations + if (this.isConversationLoading(this.activeConversation.id)) { + console.warn('Continuation already in progress for this conversation'); + return; + } + + this.errorDialogState = null; + this.setConversationLoading(this.activeConversation.id, true); + this.clearConversationStreaming(this.activeConversation.id); + + // IMPORTANT: Fetch the latest content from the database to ensure we have + // the most up-to-date content, especially after a stopped generation + // This prevents issues where the in-memory state might be stale + const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); + const dbMessage = allMessages.find((m) => m.id === messageId); + + if (!dbMessage) { + console.error('Message not found in database for continuation'); + this.setConversationLoading(this.activeConversation.id, false); + + return; + } + + // Use content from database as the source of truth + const originalContent = dbMessage.content; + const originalThinking = dbMessage.thinking || ''; + + // Get conversation context up to (but not including) the message to continue + const conversationContext = this.activeMessages.slice(0, messageIndex); + + const contextWithContinue = [ + ...conversationContext.map((msg) => { + if ('id' in msg && 'convId' in msg && 'timestamp' in msg) { + return msg as DatabaseMessage & { extra?: DatabaseMessageExtra[] }; + } + return msg as ApiChatMessageData; + }), + { + role: 'assistant' as const, + content: originalContent + } + ]; + + let appendedContent = ''; + let appendedThinking = ''; + let hasReceivedContent = false; + + await chatService.sendMessage( + contextWithContinue, + { + ...this.getApiOptions(), + + onChunk: (chunk: string) => { + hasReceivedContent = true; + appendedContent += chunk; + // Preserve originalContent exactly as-is, including any trailing whitespace + // The concatenation naturally preserves any whitespace at the end of originalContent + const fullContent = originalContent + appendedContent; + + this.setConversationStreaming( + messageToContinue.convId, + fullContent, + messageToContinue.id + ); + + this.updateMessageAtIndex(messageIndex, { + content: fullContent + }); + }, + + onReasoningChunk: (reasoningChunk: string) => { + hasReceivedContent = true; + appendedThinking += reasoningChunk; + + const fullThinking = originalThinking + appendedThinking; + + this.updateMessageAtIndex(messageIndex, { + thinking: fullThinking + }); + }, + + onComplete: async ( + finalContent?: string, + reasoningContent?: string, + timings?: ChatMessageTimings + ) => { + const fullContent = originalContent + (finalContent || appendedContent); + const fullThinking = originalThinking + (reasoningContent || appendedThinking); + + const updateData: { + content: string; + thinking: string; + timestamp: number; + timings?: ChatMessageTimings; + } = { + content: fullContent, + thinking: fullThinking, + timestamp: Date.now(), + timings: timings + }; + + await DatabaseStore.updateMessage(messageToContinue.id, updateData); + + this.updateMessageAtIndex(messageIndex, updateData); + + this.updateConversationTimestamp(); + + this.setConversationLoading(messageToContinue.convId, false); + this.clearConversationStreaming(messageToContinue.convId); + slotsService.clearConversationState(messageToContinue.convId); + }, + + onError: async (error: Error) => { + if (this.isAbortError(error)) { + // User cancelled - save partial continuation if any content was received + if (hasReceivedContent && appendedContent) { + const partialContent = originalContent + appendedContent; + const partialThinking = originalThinking + appendedThinking; + + await DatabaseStore.updateMessage(messageToContinue.id, { + content: partialContent, + thinking: partialThinking, + timestamp: Date.now() + }); + + this.updateMessageAtIndex(messageIndex, { + content: partialContent, + thinking: partialThinking, + timestamp: Date.now() + }); + } + + this.setConversationLoading(messageToContinue.convId, false); + this.clearConversationStreaming(messageToContinue.convId); + slotsService.clearConversationState(messageToContinue.convId); + + return; + } + + // Non-abort error - rollback to original content + console.error('Continue generation error:', error); + + // Rollback: Restore original content in UI + this.updateMessageAtIndex(messageIndex, { + content: originalContent, + thinking: originalThinking + }); + + // Ensure database has original content (in case of partial writes) + await DatabaseStore.updateMessage(messageToContinue.id, { + content: originalContent, + thinking: originalThinking + }); + + this.setConversationLoading(messageToContinue.convId, false); + this.clearConversationStreaming(messageToContinue.convId); + slotsService.clearConversationState(messageToContinue.convId); + + const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server'; + this.showErrorDialog(dialogType, error.message); + } + }, + messageToContinue.convId + ); + } catch (error) { + if (this.isAbortError(error)) return; + console.error('Failed to continue message:', error); + if (this.activeConversation) { + this.setConversationLoading(this.activeConversation.id, false); + } + } + } + /** * Public methods for accessing per-conversation states */ @@ -1743,8 +2004,11 @@ export const refreshActiveMessages = chatStore.refreshActiveMessages.bind(chatSt export const navigateToSibling = chatStore.navigateToSibling.bind(chatStore); export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore); export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore); +export const editUserMessagePreserveResponses = + chatStore.editUserMessagePreserveResponses.bind(chatStore); export const regenerateMessageWithBranching = chatStore.regenerateMessageWithBranching.bind(chatStore); +export const continueAssistantMessage = chatStore.continueAssistantMessage.bind(chatStore); export const deleteMessage = chatStore.deleteMessage.bind(chatStore); export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore); export const updateConversationName = chatStore.updateConversationName.bind(chatStore); diff --git a/tools/server/webui/src/lib/types/settings.d.ts b/tools/server/webui/src/lib/types/settings.d.ts index b85b0597d..b47842b66 100644 --- a/tools/server/webui/src/lib/types/settings.d.ts +++ b/tools/server/webui/src/lib/types/settings.d.ts @@ -7,6 +7,7 @@ export interface SettingsFieldConfig { key: string; label: string; type: 'input' | 'textarea' | 'checkbox' | 'select'; + isExperimental?: boolean; help?: string; options?: Array<{ value: string; label: string; icon?: typeof import('@lucide/svelte').Icon }>; } diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte index b08bd59c1..dfe094c07 100644 --- a/tools/server/webui/src/routes/+layout.svelte +++ b/tools/server/webui/src/routes/+layout.svelte @@ -1,7 +1,7 @@ + + diff --git a/tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte b/tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte deleted file mode 100644 index 1e53f7070..000000000 --- a/tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - - -