async parsing + tex caching
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user