async parsing + tex caching

This commit is contained in:
2026-08-26 02:06:10 +02:00
parent e18875a752
commit 3946541f87
32 changed files with 2730 additions and 489 deletions
+15 -3
View File
@@ -1,9 +1,21 @@
#pragma once
#include "configobject.hpp"
#include <qhashfunctions.h>
#include <qobject.h>
#include <qqmlintegration.h>
namespace ZShell::config {
class LlmAppearance : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
public:
explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Llm : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
@@ -11,12 +23,12 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7)
// Whether tools are offered to the model. Models without a tool-calling
// template should run with this off.
CFG_PROPERTY(bool, tools, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
public:
explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {}
explicit Llm(QObject* parent = nullptr)
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
};
} // namespace ZShell::config
+246 -8
View File
@@ -14,15 +14,253 @@ find_path(JKQTPlotter6_CMAKE_DIR
DOC "Directory containing the JKQtPlotter cmake package files")
find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}")
# Embed the vendored highlight queries as C++ string literals.
set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql)
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n")
foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src)
string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n")
# --- tree-sitter grammar discovery (configure time) ---
#
# Discover installed tree-sitter grammars — system packages
# (libtree-sitter-<lang>.so) and the parsers Neovim's nvim-treesitter
# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with
# highlight queries. Query sources, in priority order:
# 1. vendored highlight-queries/*.scm (version-pinned; see the
# per-file source headers, MIT),
# 2. Neovim's own queries (version-matched to its parsers),
# 3. tree-sitter/highlighting from GitHub (cached in the build dir).
# The result is embedded as highlight-queries.hpp. Re-run cmake to pick
# up grammars installed later.
#
# Candidate entry points are read from the .so with `nm` rather than
# assumed to be tree_sitter_<id>; a missing entry point just makes that
# candidate fail at runtime.
function(_ts_entry_point out file)
# OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so
# capture through a temp file.
get_filename_component(_ts_nm_base "${file}" NAME)
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
execute_process(
COMMAND nm -D --defined-only "${file}"
RESULT_VARIABLE _ts_nm_rc
OUTPUT_FILE "${_ts_nm_file}"
ERROR_FILE "${_ts_nm_file}.err")
set(_ts_sym "tree_sitter_missing")
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
file(READ "${_ts_nm_file}" _ts_nm_out)
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
# The library also exports the external scanner functions; the
# entry point is the one without the _external suffix.
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
foreach(_ts_s IN LISTS _ts_syms)
string(REPLACE " T " "" _ts_s "${_ts_s}")
if(NOT _ts_s MATCHES "_external")
set(_ts_sym "${_ts_s}")
break()
endif()
endforeach()
endif()
set(${out} "${_ts_sym}" PARENT_SCOPE)
endfunction()
# Append a query text to <files> (PARENT_SCOPE) unless it duplicates a
# hash in <hashes>. The text is written to the build tree right away:
# the s-expression `;` comments would split CMake list items and the
# query `(` `)` break bracket arguments, so query text only ever lives
# in variables and files, never in lists.
# Expand Neovim's "; inherits:" directives by appending the inherited
# query set's highlights file (recursively; a visited set prevents
# cycles). Several languages are stubs that inherit the real query
# (html inherits html_tags, qmljs inherits ecma, ...).
function(_ts_expand_inherits query_dir text outVar)
set(result "${text}")
set(_visited "")
set(_depth 0)
while(_depth LESS 8)
# Do not match the leading `;`: a MATCHALL result that itself
# contains a semicolon is re-split into list items.
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
if(NOT _inh)
break()
endif()
set(_added FALSE)
foreach(_entry IN LISTS _inh)
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
set(_file "${query_dir}/${_name}/highlights.scm")
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
continue()
endif()
list(APPEND _visited "${_file}")
file(READ "${_file}" _inh_text)
string(APPEND result "\n${_inh_text}")
set(_added TRUE)
endforeach()
if(NOT _added)
break()
endif()
math(EXPR _depth "${_depth} + 1")
endwhile()
set(${outVar} "${result}" PARENT_SCOPE)
endfunction()
function(_ts_add_query sid text filesVar hashesVar)
# filesVar/hashesVar hold the caller's variable names.
set(files "${${filesVar}}")
set(hashes "${${hashesVar}}")
string(SHA256 _ts_qh "${text}")
if(_ts_qh IN_LIST hashes)
return()
endif()
list(APPEND hashes "${_ts_qh}")
list(LENGTH files _ts_qi)
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
file(WRITE "${_ts_qfile}" "${text}")
list(APPEND files "${_ts_qfile}")
set(${filesVar} "${files}" PARENT_SCOPE)
set(${hashesVar} "${hashes}" PARENT_SCOPE)
endfunction()
set(_ts_candidates "") # entries: <id>|<cmake-safe id>|<lib>|<symbol>
file(GLOB _ts_sys_files
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
foreach(_ts_file IN LISTS _ts_sys_files)
get_filename_component(_ts_name "${_ts_file}" NAME)
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
_ts_entry_point(_ts_sym "${_ts_file}")
list(APPEND _ts_candidates
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
endforeach()
string(APPEND _hl_header "}\n")
set(_ts_nvim_parser_dirs
"$ENV{HOME}/.local/share/nvim/site/parser"
"$ENV{HOME}/.local/share/nvim/runtime/parser"
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
set(_ts_nvim_query_dirs
"$ENV{HOME}/.local/share/nvim/site/queries"
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
foreach(_ts_file IN LISTS _ts_dir_files)
get_filename_component(_ts_name "${_ts_file}" NAME)
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
_ts_entry_point(_ts_sym "${_ts_file}")
list(APPEND _ts_candidates
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
endforeach()
endforeach()
set(_ts_ids "")
foreach(_ts_c IN LISTS _ts_candidates)
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
list(GET _ts_parts 0 _ts_id)
if(NOT _ts_id IN_LIST _ts_ids)
list(APPEND _ts_ids "${_ts_id}")
endif()
endforeach()
list(SORT _ts_ids)
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
set(_hl_header
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
set(_ts_grammar_rows "")
set(_ts_vendored
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
foreach(_ts_id IN LISTS _ts_ids)
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
# Query candidates in priority order (deduped; identical texts are
# skipped, the nvim site and plugin copies are usually the same
# file).
set(_ts_qfiles "")
set(_ts_qhashes "")
if(_ts_id STREQUAL "cpp")
# The C++ grammar is a superset of C and its query only covers
# the C++ delta; base C coverage comes from the C query.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
# The TS grammars reuse the JS node names; the JS query usually
# compiles against them and gives full coverage.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
elseif(_ts_id IN_LIST _ts_vendored)
file(READ
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
endforeach()
if(NOT _ts_qfiles)
# Last resort: fetch from the tree-sitter/highlighting repo.
# Version-skewed against locally installed grammars, so only
# used when nothing local exists. Cached; offline builds simply
# drop the language.
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
if(NOT EXISTS "${_ts_dl}")
file(DOWNLOAD
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
list(GET _ts_dl_status 0 _ts_dl_rc)
if(NOT _ts_dl_rc EQUAL 0)
file(REMOVE "${_ts_dl}")
endif()
endif()
if(EXISTS "${_ts_dl}")
file(READ "${_ts_dl}" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
endif()
list(LENGTH _ts_qfiles _ts_nq)
if(_ts_nq EQUAL 0)
continue()
endif()
# Emit query sources.
set(_ts_qn 0)
set(_ts_q_ptrs "")
foreach(_ts_qfile IN LISTS _ts_qfiles)
file(READ "${_ts_qfile}" _ts_q)
string(APPEND _hl_header
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
math(EXPR _ts_qn "${_ts_qn} + 1")
endforeach()
string(APPEND _hl_header
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
# Emit library candidates (system package first, then Neovim).
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
set(_ts_nc 0)
foreach(_ts_c IN LISTS _ts_candidates)
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
list(GET _ts_parts 0 _ts_cid)
if(_ts_cid STREQUAL _ts_id)
list(GET _ts_parts 2 _ts_lib)
list(GET _ts_parts 3 _ts_sym)
string(APPEND _hl_header
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
math(EXPR _ts_nc "${_ts_nc} + 1")
endif()
endforeach()
string(APPEND _hl_header "};\n")
if(_ts_nc EQUAL 0)
# Queries but no library: pointless, drop the language.
string(APPEND _hl_header "") # (arrays stay; grammar row is skipped)
continue()
endif()
list(APPEND _ts_grammar_rows
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
endforeach()
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
foreach(_ts_row IN LISTS _ts_grammar_rows)
string(APPEND _hl_header " ${_ts_row}\n")
endforeach()
string(APPEND _hl_header "};\n}\n")
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
# Embed the vendored Latin Modern fonts (GUST Font License; provenance
+232 -83
View File
@@ -4,14 +4,18 @@
#include "message.hpp"
#include "segment.hpp"
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QPointer>
#include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QThreadPool>
#include <QVector>
#include <QUuid>
#include <algorithm>
@@ -48,6 +52,33 @@ QString sqlText(const QString& value) {
return value;
}
// Plain data for one session's messages, fetched on a worker thread
// and turned into the QObject tree on the GUI thread. Messages are
// ordered as the model displays them (newest first).
struct SegmentRow {
QString type;
QString text;
QString name;
QString toolCallId;
QString arguments;
QString result;
int status = 0;
qint64 elapsedMs = 0;
qint64 timestamp = 0;
};
struct GenerationRow {
qint64 timestamp = 0;
bool active = false;
QVector<SegmentRow> segments;
};
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QVector<GenerationRow> generations;
};
} // namespace
ChatStore::ChatStore(QObject* parent)
@@ -69,16 +100,16 @@ QSqlDatabase ChatStore::db() const {
}
void ChatStore::openDb() {
const QString path =
m_dbPath =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(path).absolutePath());
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(path);
db.setDatabaseName(m_dbPath);
if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << path << ":"
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text();
return;
}
@@ -250,8 +281,13 @@ void ChatStore::setLlmClient(LlmClient* client) {
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (!session->isLoaded()) {
// Saving now would persist an incomplete model and wipe the
// stored history; run it again when the load lands.
m_pendingPersists.insert(session);
return;
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
session->ensureLoaded();
if (!saveSession(session))
return;
sortAndNotify();
@@ -386,90 +422,203 @@ bool ChatStore::saveSession(ChatSession* session) {
}
return ok;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order.
QSqlQuery query(db());
query.prepare(
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"ORDER BY rowid DESC");
query.bindValue(":id", session->id());
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for" << session->id()
<< ":" << query.lastError().text();
if (!session)
return;
}
auto* model = session->messagesModel();
QList<ChatMessage*> messages;
while (query.next()) {
const int messageId = query.value(0).toInt();
auto* message = model->createMessage(
query.value(1).toString() == QLatin1String("user")
? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
query.value(2).toLongLong());
QSqlQuery generationQuery(db());
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM generations "
"WHERE message_id = :mid ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
int activeIndex = 0;
if (generationQuery.exec()) {
int index = 0;
while (generationQuery.next()) {
auto* generation = message->addGeneration(
generationQuery.value(1).toLongLong());
QSqlQuery segmentQuery(db());
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, arguments, "
"result, status, elapsed_ms, timestamp FROM segments "
"WHERE generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
auto* segment = new LlmSegment(
segmentTypeFromName(
segmentQuery.value(0).toString()),
segmentQuery.value(8).toLongLong(),
generation);
segment->setText(
segmentQuery.value(1).toString());
segment->setName(
segmentQuery.value(2).toString());
segment->setToolCallId(
segmentQuery.value(3).toString());
segment->appendArguments(
segmentQuery.value(4).toString());
segment->setResult(
segmentQuery.value(5).toString());
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentQuery.value(6).toInt()));
segment->restore(
segmentQuery.value(7).toLongLong());
generation->addSegment(segment);
const QString sessionId = session->id();
const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects
// are thread-affine). Rows come back as plain data.
QThreadPool::globalInstance()->start(
[store = QPointer<ChatStore>(this),
session = QPointer<ChatSession>(session), sessionId, path]() {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db = QSqlDatabase::addDatabase(
QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
// Tolerate the GUI thread writing while we read.
QSqlQuery busy(db);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
// Newest first so the model receives rows in
// display order.
QSqlQuery query(db);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
qWarning()
<< "ChatStore: failed to load messages for"
<< sessionId << ":"
<< query.lastError().text();
} else {
while (query.next()) {
const int messageId = query.value(0).toInt();
MessageRow message;
message.user =
query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid",
generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
rows.append(message);
}
}
db.close();
} else {
qWarning() << "ChatStore: failed to load segments for "
<< "generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
qWarning()
<< "ChatStore: failed to open database for load:"
<< db.lastError().text();
}
if (generationQuery.value(2).toInt() != 0)
activeIndex = index;
++index;
}
} else {
qWarning() << "ChatStore: failed to load generations for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
session->adoptMessages(messages);
// Remove only once every QSqlDatabase copy and query is gone;
// while any reference is alive Qt refuses the removal and the
// connection is left dangling in a broken state.
QSqlDatabase::removeDatabase(connName);
// Build the object tree on the GUI thread. Deliver through
// the app instance (never destroyed) and re-check the
// pointers there: posting to `store` from the pool thread
// would race with its destruction.
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store;
ChatSession* s = session;
if (!st || !s)
return;
// Rows fetched from disk; newest first.
auto* model = s->model();
if (!model)
return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
auto* message = model->createMessage(
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active)
activeIndex = i;
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
// Rows added live while the load was in flight are
// newer than anything on disk; keep them in front.
if (model->rowCount() > 0) {
QList<ChatMessage*> live = messages;
for (int r = 0; r < model->rowCount(); ++r)
live.prepend(model->at(r));
messages = live;
}
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
// Mark loaded only once the model holds both the
// fetched history and the rows added live while the
// load ran, so a deferred startGeneration (triggered
// by loaded()) builds its context from the complete
// conversation.
s->markLoaded();
if (s->takeClearPending()) {
// Cleared while the load was in flight; drop
// everything now that the model is populated.
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
},
Qt::QueuedConnection);
});
}
void ChatStore::load() {
+8
View File
@@ -3,6 +3,7 @@
#include "session.hpp"
#include <QObject>
#include <QSet>
#include <QSqlDatabase>
#include <QString>
#include <QVariantList>
@@ -38,6 +39,9 @@ class ChatStore : public QObject {
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
// Loads the session's messages from the database. The SQL runs on a
// worker thread; the object tree is built and the model updated on
// the GUI thread when it arrives (ChatSession::loaded).
void loadMessagesInto(ChatSession* session);
Q_SIGNALS:
@@ -56,6 +60,10 @@ class ChatStore : public QObject {
QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr;
QString m_connectionName;
QString m_dbPath;
// Sessions whose persist() ran before their messages finished
// loading; persisted once the load lands.
QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const;
};
+131 -129
View File
@@ -4,10 +4,17 @@
#include <tree_sitter/api.h>
#include <QCoreApplication>
#include <QDir>
#include <QHash>
#include <QMap>
#include <QMutexLocker>
#include <QPointer>
#include <QStringList>
#include <QThreadPool>
#include <QVariantMap>
#include <cstring>
#include <dlfcn.h>
namespace ZShell::llm {
@@ -57,77 +64,21 @@ const char* roleName(Role role) {
using LanguageFn = const TSLanguage* (*)();
// Query sources, indexed by Grammar::queries.
struct QuerySources {
std::vector<std::string> sources;
int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended;
};
const QuerySources& querySources() {
static const QuerySources sources = [] {
QuerySources qs;
// javascript + typescript concatenated: the TS grammar reuses the
// JS node names, so the JS query usually compiles against it and
// gives full coverage; the TS-only query is the fallback.
const std::string tsExtended =
std::string(hq::javascript) + "\n" + std::string(hq::typescript);
// Same for cpp: the C++ grammar is a superset of C, and the C++
// query only covers the C++-specific delta. Base C coverage
// (keywords, types, calls, strings, comments) comes from the C
// query.
const std::string cppExtended =
std::string(hq::c) + "\n" + std::string(hq::cpp);
qs.sources.reserve(14);
qs.sources.push_back(hq::c);
qs.sources.push_back(hq::cpp);
qs.sources.push_back(hq::python);
qs.sources.push_back(hq::javascript);
qs.sources.push_back(tsExtended);
qs.sources.push_back(hq::typescript);
qs.sources.push_back(hq::bash);
qs.sources.push_back(hq::json);
qs.sources.push_back(hq::rust);
qs.sources.push_back(hq::go);
qs.sources.push_back(hq::yaml);
qs.sources.push_back(hq::toml);
qs.sources.push_back(hq::sql);
qs.sources.push_back(cppExtended);
qs.c = 0;
qs.cpp = 1;
qs.python = 2;
qs.javascript = 3;
qs.typescriptExtended = 4;
qs.typescript = 5;
qs.bash = 6;
qs.json = 7;
qs.rust = 8;
qs.go = 9;
qs.yaml = 10;
qs.toml = 11;
qs.sql = 12;
qs.cppExtended = 13;
return qs;
}();
return sources;
}
// Grammar registry generated by CMake from the installed grammars and
// their highlight queries (see CMakeLists.txt).
const QHash<QString, CodeHighlighter::Grammar>& grammars() {
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
const auto& qs = querySources();
QHash<QString, CodeHighlighter::Grammar> map;
map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}});
map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}});
map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}});
map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}});
map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}});
map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}});
map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}});
map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}});
map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}});
map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}});
map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}});
map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}});
map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}});
for (const auto& g : hq::grammars) {
CodeHighlighter::Grammar grammar;
for (int i = 0; i < g.nCandidates; ++i) {
grammar.libs.push_back(g.candidates[i].lib);
grammar.symbols.push_back(g.candidates[i].symbol);
}
for (int i = 0; i < g.nQueries; ++i)
grammar.queries.push_back(g.queries[i]);
map.insert(g.id, std::move(grammar));
}
return map;
}();
return grammars;
@@ -170,6 +121,8 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
map.insert("shell-session", "bash");
map.insert("zsh", "bash");
map.insert("console", "bash");
map.insert("qml", "qmljs");
map.insert("qmljs", "qmljs");
map.insert("json", "json");
map.insert("jsonc", "json");
map.insert("rust", "rust");
@@ -190,10 +143,6 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
return aliases;
}
const std::vector<std::string>& CodeHighlighter::querySources() const {
return hl::querySources().sources;
}
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
const QString n = QString::fromUtf8(name, length);
if (n == "comment")
@@ -204,15 +153,18 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
return hl::Role::String;
if (n.startsWith("number"))
return hl::Role::Number;
if (n.startsWith("constant"))
if (n.startsWith("constant") || n == "boolean" || n == "bool"
|| n.startsWith("character"))
return hl::Role::Constant;
if (n.startsWith("keyword"))
return hl::Role::Keyword;
if (n == "type" || n.startsWith("type."))
return hl::Role::Type;
if (n == "namespace" || n == "module")
if (n.startsWith("namespace") || n.startsWith("module")
|| n == "support.type" || n == "support.namespace")
return hl::Role::Type;
if (n.startsWith("function") || n == "constructor")
if (n.startsWith("function") || n == "constructor"
|| n.startsWith("support.function"))
return hl::Role::Function;
if (n == "method" || n == "method.builtin")
return hl::Role::Method;
@@ -220,7 +172,8 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
return hl::Role::Macro;
if (n.startsWith("preproc"))
return hl::Role::Preproc;
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator."))
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")
|| n.startsWith("punctuation"))
return hl::Role::Operator;
if (n == "property" || n == "field" || n.startsWith("property."))
return hl::Role::Property;
@@ -228,6 +181,13 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
return hl::Role::Label;
if (n.startsWith("attribute") || n == "annotation")
return hl::Role::Attribute;
// HTML tag names, CSS variables and friends.
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
return hl::Role::Keyword;
if (n.startsWith("variable"))
return hl::Role::Constant;
if (n.startsWith("support"))
return hl::Role::Function;
return hl::Role::None;
}
@@ -235,15 +195,45 @@ const char* CodeHighlighter::roleName(uint8_t role) {
return hl::roleName(static_cast<hl::Role>(role));
}
QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const {
void CodeHighlighter::highlight(
const QString& code, const QString& language, QObject* target, int token) {
QThreadPool::globalInstance()->start([this, target, token, code, language]() {
const QVariantList spans = doHighlight(code, language);
// The target item may be long gone by now (delegates are
// recreated constantly while chats load); a destroyed target is
// simply skipped. Deliver through the app instance (never
// destroyed) and re-check there: posting to `target` from the
// pool thread would race with its destruction.
QPointer<QObject> guard(target);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, token, spans]() {
if (!guard)
return;
// QML functions are only invokable by their generic
// QVariant overload, so pass untyped arguments.
QMetaObject::invokeMethod(
guard, "onHighlightSpans",
Q_ARG(QVariant, token), Q_ARG(QVariant, spans));
},
Qt::QueuedConnection);
});
}
QVariantList CodeHighlighter::doHighlight(
const QString& code, const QString& language) const {
QVariantList spans;
if (code.isEmpty())
return spans;
const QString id = aliases().value(language.trimmed().toLower());
if (id.isEmpty())
return spans;
const QString tag = language.trimmed().toLower();
const QString id = [&] {
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
}();
const Grammar& grammar = hl::grammars().value(id);
if (grammar.libs.empty())
return spans;
// Guard against pathological blocks; highlighting is best-effort.
static constexpr size_t kMaxBytes = 512 * 1024;
@@ -251,59 +241,72 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
if (static_cast<size_t>(utf8.size()) > kMaxBytes)
return spans;
State& state = m_states[id];
if (state.bad)
return spans;
if (!state.lang) {
// Missing library is retriable (it may be installed while the
// shell runs); ABI/query failures below are not.
state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL);
if (!state.lib)
return spans;
auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlsym(state.lib, grammar.symbol.toUtf8().constData()));
if (!symbol) {
dlclose(state.lib);
state.lib = nullptr;
return spans;
const TSLanguage* lang = nullptr;
TSQuery* query = nullptr;
{
QMutexLocker locker(&m_stateMutex);
auto& state = m_states[id];
// A missing library is retriable (it may be installed while the
// shell runs); an ABI mismatch on every candidate is not. Cache
// successes and permanent failures; leave retriable misses out.
if (!state || (!state->lang && !state->bad)) {
std::shared_ptr<State> fresh = std::make_shared<State>();
bool abiMismatch = false;
for (size_t i = 0; i < grammar.libs.size(); ++i) {
void* lib = dlopen(grammar.libs[i].c_str(),
RTLD_NOW | RTLD_LOCAL);
if (!lib)
continue;
auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlsym(lib, grammar.symbols[i].c_str()));
if (!symbol) {
dlclose(lib);
continue;
}
const TSLanguage* candidate = symbol();
const uint32_t version = ts_language_abi_version(candidate);
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
version > TREE_SITTER_LANGUAGE_VERSION) {
dlclose(lib);
abiMismatch = true;
continue;
}
fresh->lib = lib;
fresh->lang = candidate;
break;
}
if (fresh->lang) {
// Candidates in priority order; first that compiles wins.
for (const char* source : grammar.queries) {
TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0;
TSQuery* candidate = ts_query_new(
static_cast<const TSLanguage*>(fresh->lang),
source,
static_cast<uint32_t>(std::strlen(source)),
&errorOffset,
&errorType);
if (!candidate)
continue;
fresh->query = candidate;
break;
}
if (!fresh->query)
fresh->bad = true;
} else if (abiMismatch) {
fresh->bad = true;
}
if (fresh->lang || fresh->bad)
state = std::move(fresh);
}
const TSLanguage* lang = symbol();
const uint32_t version = ts_language_abi_version(lang);
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
version > TREE_SITTER_LANGUAGE_VERSION) {
state.bad = true;
if (!state || state->bad || !state->lang)
return spans;
}
state.lang = lang;
}
if (!state.query) {
// Candidates in priority order; first that compiles wins.
for (const int candidate : grammar.queries) {
const std::string& source =
querySources()[static_cast<size_t>(candidate)];
TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0;
TSQuery* query = ts_query_new(
static_cast<const TSLanguage*>(state.lang),
source.data(),
static_cast<uint32_t>(source.size()),
&errorOffset,
&errorType);
if (!query)
continue;
state.query = query;
break;
}
if (!state.query) {
state.bad = true;
return spans;
}
lang = static_cast<const TSLanguage*>(state->lang);
query = static_cast<TSQuery*>(state->query);
}
TSParser* parser = ts_parser_new();
ts_parser_set_language(parser, static_cast<const TSLanguage*>(state.lang));
ts_parser_set_language(parser, lang);
TSTree* tree = ts_parser_parse_string(
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
if (!tree) {
@@ -311,7 +314,6 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
return spans;
}
TSQuery* query = static_cast<TSQuery*>(state.query);
TSQueryCursor* cursor = ts_query_cursor_new();
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
+36 -16
View File
@@ -1,11 +1,13 @@
#pragma once
#include <QMutex>
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QtQml>
#include <cstdint>
#include <memory>
#include <vector>
class QQmlEngine;
@@ -15,37 +17,51 @@ namespace ZShell::llm {
// Syntax highlighting for LLM code blocks via tree-sitter.
//
// The tree-sitter runtime is linked. Grammar libraries
// (libtree-sitter-<lang>.so) are dlopen()'d lazily, so a missing
// grammar package degrades that language to plain text instead of
// breaking the build or the app. Highlight queries are embedded at
// build time (highlight-queries/*.scm, vendored from the grammar
// repos, MIT).
// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d
// lazily, so a missing grammar degrades that language to plain text
// instead of breaking the build or the app. At configure time CMake
// discovers installed grammars (system packages plus the parsers
// Neovim's nvim-treesitter installs) and pairs each with highlight
// queries (vendored, Neovim's, or fetched from
// tree-sitter/highlighting); both are embedded in the generated
// highlight-queries.hpp.
//
// For each grammar the first loadable (ABI-compatible) library wins
// and the first query that compiles against it wins, so a version
// skew between a library and its query degrades gracefully.
//
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
// to a grammar through an alias table.
// to a grammar through an alias table; tags not in the table are used
// as grammar ids as-is.
//
// highlight() returns a list of span maps:
// highlight() parses the code off the GUI thread and delivers a list of
// span maps by calling target's "onHighlightSpans(token, spans)" method
// (on the GUI thread):
// { "start": int, "length": int, "kind": QString }
// where kind is a semantic role (keyword, string, comment, number,
// function, type, ...) that QML maps to theme colors. An empty list
// means "no highlighting" (unknown language or grammar not installed).
// token is passed back unchanged so the caller can drop results for
// superseded code; a destroyed target is simply skipped.
class CodeHighlighter : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const;
Q_INVOKABLE void highlight(
const QString& code, const QString& language, QObject* target, int token);
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar {
QString lib; // soname to dlopen
QString symbol; // tree_sitter_<lang>() entry point
// Candidate query sources, first wins. Lets typescript reuse
// the javascript query when it compiles against the grammar.
std::vector<int> queries; // indices into querySources()
// Library candidates in priority order (system package, then
// Neovim copies); libs[i] pairs with symbols[i].
std::vector<std::string> libs;
std::vector<std::string> symbols;
// Candidate query sources in priority order; first that
// compiles against the loaded grammar wins.
std::vector<const char*> queries;
};
private:
@@ -57,12 +73,16 @@ class CodeHighlighter : public QObject {
};
[[nodiscard]] static const QHash<QString, QString>& aliases();
[[nodiscard]] const std::vector<std::string>& querySources() const;
// Maps a tree-sitter capture name to a role index (0 = unstyled).
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role);
// The parsing work; runs on worker threads, so the per-language
// state must be initialized under m_stateMutex and is shared as an
// immutable object afterwards.
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const;
mutable QHash<QString, State> m_states;
mutable QHash<QString, std::shared_ptr<const State>> m_states;
mutable QMutex m_stateMutex;
static CodeHighlighter* s_instance;
};
+122 -48
View File
@@ -5,9 +5,13 @@
#include <jkqtmathtext/jkqtmathtext.h>
#include <QBuffer>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFontDatabase>
#include <QHash>
#include <QPointer>
#include <QThreadPool>
namespace ZShell::llm {
@@ -15,6 +19,9 @@ namespace {
// A few px of breathing room around the equation.
constexpr int kRenderMargin = 2;
constexpr unsigned int kResolutionDpi = 96;
// The render cache is unbounded between clears; cap it so a very long
// session with many distinct equations cannot grow it forever.
constexpr int kCacheLimit = 512;
// Registers the embedded Latin Modern faces with the font database
// (once per process) and reports which families became available.
@@ -65,20 +72,23 @@ const LatinModern& loadLatinModern() {
} // namespace
LlmMathText::LlmMathText(QObject* parent)
: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) {
// No parent: the renderer is used from pool threads, and a parented
// QObject would taint children it creates there.
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>(
nullptr, /* useFontsForGUI */ true)) {
// Latin Modern is the default font of modern LaTeX; use the embedded
// faces instead of whatever the system happens to have installed.
const LatinModern& fonts = loadLatinModern();
if (fonts.roman)
m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"),
JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) {
// Same pattern as JKQTMathText's useXITS(): the OpenType math
// font supplies the math alphabet and operators from its MATH
// table.
m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer.setFallbackFontSymbols(
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
}
@@ -112,7 +122,28 @@ void LlmMathText::setDevicePixelRatio(qreal value) {
reRender();
}
namespace {
// Process-wide render cache; the key is the full render state, so
// re-opening a chat reuses already-rendered equations. Accessed on the
// GUI thread only.
struct MathRender {
bool ok = false;
QImage image;
QUrl url;
qreal width = 0;
qreal height = 0;
};
QHash<QString, MathRender>& mathCache() {
static QHash<QString, MathRender> cache;
return cache;
}
} // namespace
void LlmMathText::reRender() {
++m_requestId;
if (m_latex.trimmed().isEmpty()) {
m_image = QImage();
m_imageUrl = QUrl();
@@ -123,54 +154,97 @@ void LlmMathText::reRender() {
return;
}
m_renderer.setFontPointSize(m_fontPointSize);
m_renderer.setFontColor(m_color);
const bool ok = m_renderer.parse(
m_latex,
JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions);
if (!ok) {
m_image = QImage();
m_imageUrl = QUrl();
m_width = 0;
m_height = 0;
m_ok = false;
const QString key = m_latex
+ QLatin1Char(0x1f) + m_color.name()
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize)
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image;
m_imageUrl = it->url;
m_width = it->width;
m_height = it->height;
m_ok = it->ok;
Q_EMIT changed();
return;
}
const QImage image = m_renderer.drawIntoImage(
/* drawBoxes */ false,
QColor(Qt::transparent),
kRenderMargin,
m_devicePixelRatio,
kResolutionDpi);
if (image.isNull()) {
m_image = QImage();
m_imageUrl = QUrl();
m_width = 0;
m_height = 0;
m_ok = false;
Q_EMIT changed();
// A render is already running; it re-renders the latest state when
// it completes (id mismatch), so there is nothing to do here.
if (m_inFlight)
return;
}
m_inFlight = true;
m_image = image;
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
m_imageUrl = QUrl(
QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64()));
// drawIntoImage renders at devicePixelRatio; convert back to
// logical pixels.
m_width = image.width() / m_devicePixelRatio;
m_height = image.height() / m_devicePixelRatio;
m_ok = true;
Q_EMIT changed();
const QString latex = m_latex;
const QColor color = m_color;
const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio;
// The worker captures the renderer by value (shared_ptr) so it
// stays alive even if this object is destroyed mid-render; it is
// only ever used by the single in-flight worker (m_inFlight),
// never concurrently.
auto renderer = m_renderer;
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
MathRender render;
renderer->setFontPointSize(pointSize);
renderer->setFontColor(color);
if (renderer->parse(
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) {
const QImage image = renderer->drawIntoImage(
/* drawBoxes */ false,
QColor(Qt::transparent),
kRenderMargin,
dpr,
kResolutionDpi);
if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,")
+ QString::fromLatin1(png.toBase64()));
// drawIntoImage renders at devicePixelRatio; convert
// back to logical pixels.
render.width = image.width() / dpr;
render.height = image.height() / dpr;
render.ok = true;
}
}
// Deliver through the app instance (never destroyed) and
// re-check the pointer on the GUI thread: posting to `this`
// from the pool thread would race with its destruction.
QPointer<LlmMathText> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, id, key, render = std::move(render)]() mutable {
LlmMathText* self = guard;
if (!self)
return;
self->m_inFlight = false;
if (id != self->m_requestId) {
// Superseded while the worker ran; render the
// latest state.
self->reRender();
return;
}
if (render.ok) {
auto& cache = mathCache();
if (cache.size() >= kCacheLimit)
cache.clear();
cache.insert(key, render);
}
self->m_image = render.image;
self->m_imageUrl = render.url;
self->m_width = render.width;
self->m_height = render.height;
self->m_ok = render.ok;
Q_EMIT self->changed();
},
Qt::QueuedConnection);
});
}
} // namespace ZShell::llm
+12 -2
View File
@@ -7,6 +7,8 @@
#include <QUrl>
#include <QtQml>
#include <memory>
#include <jkqtmathtext/jkqtmathtext.h>
namespace ZShell::llm {
@@ -14,7 +16,9 @@ namespace ZShell::llm {
// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer).
//
// Parses a display-math string and renders it into a transparent
// QImage at the given device pixel ratio. QML displays the image
// QImage at the given device pixel ratio, off the GUI thread (with a
// process-wide cache keyed on the full render state, so re-opening a
// chat does not re-render the same equations). QML displays the image
// (scaling it to the bubble width when needed) and falls back to the
// raw LaTeX when parsing fails.
class LlmMathText : public QObject {
@@ -57,7 +61,9 @@ class LlmMathText : public QObject {
private:
void reRender();
JKQTMathText m_renderer;
// Shared so an in-flight worker render keeps the renderer alive if
// this object (and its QML item) is destroyed mid-render.
std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex;
QColor m_color;
double m_fontPointSize = 12.0;
@@ -67,6 +73,10 @@ class LlmMathText : public QObject {
qreal m_width = 0;
qreal m_height = 0;
bool m_ok = false;
// Bumps on every reRender; a delivery carrying an older id was
// superseded and is dropped.
int m_requestId = 0;
bool m_inFlight = false;
};
} // namespace ZShell::llm
+8 -1
View File
@@ -2,6 +2,8 @@
#include "session.hpp"
#include <algorithm>
namespace ZShell::llm {
ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent)
@@ -98,7 +100,12 @@ void ChatMessageModel::clear() {
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel();
qDeleteAll(m_messages);
// The new list may share rows with the current one (live rows kept
// in front of fetched rows); delete only what is truly gone.
for (ChatMessage* message : m_messages)
if (std::find(messages.begin(), messages.end(), message)
== messages.end())
delete message;
m_messages = std::move(messages);
endResetModel();
+36 -6
View File
@@ -2,7 +2,10 @@
#include "markdownparser.hpp"
#include <QCoreApplication>
#include <QDateTime>
#include <QPointer>
#include <QThreadPool>
namespace ZShell::llm {
@@ -19,8 +22,10 @@ LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty)
return;
m_markdownDirty = false;
parseMarkdown();
// A parse may still be running; leave the dirty flag set so
// it re-parses the newest text when that one completes.
if (parseMarkdown())
m_markdownDirty = false;
});
}
@@ -53,7 +58,8 @@ void LlmSegment::close() {
// out the debounce.
if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop();
parseMarkdown();
if (parseMarkdown())
m_markdownDirty = false;
}
}
@@ -130,9 +136,33 @@ void LlmSegment::scheduleMarkdown() {
m_markdownTimer.start();
}
void LlmSegment::parseMarkdown() {
m_markdown = MarkdownParser::parse(m_text);
Q_EMIT markdownChanged();
bool LlmSegment::parseMarkdown() {
if (m_parseInFlight)
return false;
m_parseInFlight = true;
const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(text);
// Deliver through qApp (never destroyed) and re-check the
// pointer on the GUI thread: posting to `this` from the pool
// thread would race with its destruction.
QPointer<LlmSegment> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, blocks]() {
LlmSegment* seg = guard;
if (!seg)
return;
seg->m_parseInFlight = false;
seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged();
// Text arrived while the worker was running; parse it.
if (seg->m_markdownDirty)
seg->parseMarkdown();
},
Qt::QueuedConnection);
});
return true;
}
} // namespace ZShell::llm
+4 -1
View File
@@ -92,7 +92,9 @@ class LlmSegment : public QObject {
private:
void scheduleMarkdown();
void parseMarkdown();
// Kicks off an off-thread parse; returns false when one is already
// in flight (the pending change is picked up when it completes).
bool parseMarkdown();
Type m_type;
qint64 m_timestamp;
@@ -108,6 +110,7 @@ class LlmSegment : public QObject {
QVariantList m_markdown;
QTimer m_markdownTimer;
bool m_markdownDirty = false;
bool m_parseInFlight = false;
};
} // namespace ZShell::llm
+32 -4
View File
@@ -94,8 +94,8 @@ LlmClient* ChatSession::client() const {
}
void ChatSession::ensureLoaded() {
if (m_loaded) return;
m_loaded = true;
if (m_loadRequested) return;
m_loadRequested = true;
if (auto* store = qobject_cast<ChatStore*>(parent()))
store->loadMessagesInto(this);
}
@@ -105,6 +105,18 @@ ChatMessageModel* ChatSession::messagesModel() {
return m_model;
}
void ChatSession::markLoaded() {
if (m_loaded) return;
m_loaded = true;
Q_EMIT loaded();
}
bool ChatSession::takeClearPending() {
const bool pending = m_clearPending;
m_clearPending = false;
return pending;
}
void ChatSession::adoptMessages(QList<ChatMessage*> messages) {
m_model->loadMessages(std::move(messages));
}
@@ -127,9 +139,20 @@ void ChatSession::clearMessages() {
}
void ChatSession::startGeneration(ChatMessage* target) {
if (auto* generation = target->activeGeneration()) {
if (auto* clientObject = client())
auto* generation = target->activeGeneration();
auto* clientObject = client();
if (generation && clientObject) {
if (isLoaded()) {
clientObject->startGeneration(this, generation);
} else {
// The store load is still in flight; the request needs the
// full history, so start once it lands.
connect(
this, &ChatSession::loaded, clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
}
}
persist();
}
@@ -209,6 +232,11 @@ void ChatSession::clear() {
return;
}
}
if (!isLoaded()) {
// The load lands shortly; drop everything once it does.
m_clearPending = true;
return;
}
m_model->clear();
persist();
}
+12 -1
View File
@@ -43,10 +43,17 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
// Loads the messages from the store on first access.
// The messages model; the first access starts the (async) load
// from the store.
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
// True once the async load from the store has finished.
[[nodiscard]] bool isLoaded() const { return m_loaded; }
// The model without triggering a load (ChatStore use during the
// load itself).
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded();
[[nodiscard]] bool takeClearPending();
[[nodiscard]] LlmClient* client() const;
[[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; }
@@ -80,6 +87,8 @@ class ChatSession : public QObject {
void updatedAtChanged();
void pinnedChanged();
void messageCountChanged();
// The messages finished loading from the store.
void loaded();
private:
void onModelRowsChanged();
@@ -96,7 +105,9 @@ class ChatSession : public QObject {
bool m_pinned = false;
int m_messageCount = 0;
ChatMessageModel* m_model = nullptr;
bool m_loadRequested = false;
bool m_loaded = false;
bool m_clearPending = false;
int m_lastTokenCount = 0;
};