mirror of
https://github.com/nomic-ai/gpt4all.git
synced 2025-09-24 04:53:02 +00:00
2025 is too soon to use C++ features from 2020 without running into bugs in every build tool that touches the project.
1255 lines
44 KiB
C++
1255 lines
44 KiB
C++
#ifndef CHATMODEL_H
|
|
#define CHATMODEL_H
|
|
|
|
#include "database.h"
|
|
#include "tool.h"
|
|
#include "toolcallparser.h"
|
|
#include "xlsxtomd.h"
|
|
|
|
#include <fmt/format.h>
|
|
#include <gpt4all-backend/formatters.h>
|
|
|
|
#include <QAbstractListModel>
|
|
#include <QBuffer>
|
|
#include <QByteArray>
|
|
#include <QClipboard>
|
|
#include <QDataStream>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QIODevice>
|
|
#include <QHash>
|
|
#include <QList>
|
|
#include <QMutex>
|
|
#include <QMutexLocker> // IWYU pragma: keep
|
|
#include <QObject>
|
|
#include <QPair> // IWYU pragma: keep
|
|
#include <QString>
|
|
#include <QStringList> // IWYU pragma: keep
|
|
#include <QUrl>
|
|
#include <QVariant>
|
|
#include <Qt>
|
|
#include <QtAssert>
|
|
#include <QtPreprocessorSupport>
|
|
#include <QtTypes>
|
|
|
|
#include <algorithm>
|
|
#include <iterator>
|
|
#include <list>
|
|
#include <optional>
|
|
#include <ranges>
|
|
#include <span>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using namespace Qt::Literals::StringLiterals;
|
|
namespace ranges = std::ranges;
|
|
namespace views = std::views;
|
|
|
|
|
|
struct PromptAttachment {
|
|
Q_GADGET
|
|
Q_PROPERTY(QUrl url MEMBER url)
|
|
Q_PROPERTY(QByteArray content MEMBER content)
|
|
Q_PROPERTY(QString file READ file)
|
|
Q_PROPERTY(QString processedContent READ processedContent)
|
|
|
|
public:
|
|
QUrl url;
|
|
QByteArray content;
|
|
|
|
QString file() const
|
|
{
|
|
if (!url.isLocalFile())
|
|
return QString();
|
|
const QString localFilePath = url.toLocalFile();
|
|
const QFileInfo info(localFilePath);
|
|
return info.fileName();
|
|
}
|
|
|
|
QString processedContent() const
|
|
{
|
|
const QString localFilePath = url.toLocalFile();
|
|
const QFileInfo info(localFilePath);
|
|
if (info.suffix().toLower() != "xlsx")
|
|
return u"## Attached: %1\n\n%2"_s.arg(file(), content);
|
|
|
|
QBuffer buffer;
|
|
buffer.setData(content);
|
|
buffer.open(QIODevice::ReadOnly);
|
|
const QString md = XLSXToMD::toMarkdown(&buffer);
|
|
buffer.close();
|
|
return u"## Attached: %1\n\n%2"_s.arg(file(), md);
|
|
}
|
|
|
|
bool operator==(const PromptAttachment &other) const { return url == other.url; }
|
|
};
|
|
Q_DECLARE_METATYPE(PromptAttachment)
|
|
|
|
// Used by Server to represent a message from the client.
|
|
struct MessageInput
|
|
{
|
|
enum class Type { System, Prompt, Response };
|
|
Type type;
|
|
QString content;
|
|
};
|
|
|
|
class MessageItem
|
|
{
|
|
Q_GADGET
|
|
Q_PROPERTY(Type type READ type CONSTANT)
|
|
Q_PROPERTY(QString content READ content CONSTANT)
|
|
|
|
public:
|
|
enum class Type { System, Prompt, Response, ToolResponse };
|
|
|
|
struct system_tag_t { explicit system_tag_t() = default; };
|
|
static inline constexpr system_tag_t system_tag = system_tag_t{};
|
|
|
|
MessageItem(qsizetype index, Type type, QString content)
|
|
: m_index(index), m_type(type), m_content(std::move(content))
|
|
{
|
|
Q_ASSERT(type != Type::System); // use system_tag constructor
|
|
}
|
|
|
|
// Construct a system message with no index, since they are never stored in the chat
|
|
MessageItem(system_tag_t, QString content)
|
|
: m_type(Type::System), m_content(std::move(content)) {}
|
|
|
|
MessageItem(qsizetype index, Type type, QString content, const QList<ResultInfo> &sources, const QList<PromptAttachment> &promptAttachments)
|
|
: m_index(index)
|
|
, m_type(type)
|
|
, m_content(std::move(content))
|
|
, m_sources(sources)
|
|
, m_promptAttachments(promptAttachments) {}
|
|
|
|
// index of the parent ChatItem (system, prompt, response) in its container
|
|
std::optional<qsizetype> index() const { return m_index; }
|
|
|
|
Type type() const { return m_type; }
|
|
const QString &content() const { return m_content; }
|
|
|
|
QList<ResultInfo> sources() const { return m_sources; }
|
|
QList<PromptAttachment> promptAttachments() const { return m_promptAttachments; }
|
|
|
|
// used with version 0 Jinja templates
|
|
QString bakedPrompt() const
|
|
{
|
|
if (type() != Type::Prompt)
|
|
throw std::logic_error("bakedPrompt() called on non-prompt item");
|
|
QStringList parts;
|
|
if (!m_sources.isEmpty()) {
|
|
parts << u"### Context:\n"_s;
|
|
for (auto &source : std::as_const(m_sources))
|
|
parts << u"Collection: "_s << source.collection
|
|
<< u"\nPath: "_s << source.path
|
|
<< u"\nExcerpt: "_s << source.text << u"\n\n"_s;
|
|
}
|
|
for (auto &attached : std::as_const(m_promptAttachments))
|
|
parts << attached.processedContent() << u"\n\n"_s;
|
|
parts << m_content;
|
|
return parts.join(QString());
|
|
}
|
|
|
|
private:
|
|
std::optional<qsizetype> m_index;
|
|
Type m_type;
|
|
QString m_content;
|
|
QList<ResultInfo> m_sources;
|
|
QList<PromptAttachment> m_promptAttachments;
|
|
};
|
|
Q_DECLARE_METATYPE(MessageItem)
|
|
|
|
class ChatItem : public QObject
|
|
{
|
|
Q_OBJECT
|
|
Q_PROPERTY(QString name MEMBER name )
|
|
Q_PROPERTY(QString value MEMBER value)
|
|
|
|
// prompts and responses
|
|
Q_PROPERTY(QString content READ content NOTIFY contentChanged)
|
|
|
|
// prompts
|
|
Q_PROPERTY(QList<PromptAttachment> promptAttachments MEMBER promptAttachments)
|
|
|
|
// responses
|
|
Q_PROPERTY(bool isCurrentResponse MEMBER isCurrentResponse NOTIFY isCurrentResponseChanged)
|
|
Q_PROPERTY(bool isError MEMBER isError )
|
|
Q_PROPERTY(QList<ChatItem *> childItems READ childItems )
|
|
|
|
// toolcall
|
|
Q_PROPERTY(bool isToolCallError READ isToolCallError NOTIFY isTooCallErrorChanged)
|
|
|
|
// responses (DataLake)
|
|
Q_PROPERTY(QString newResponse MEMBER newResponse )
|
|
Q_PROPERTY(bool stopped MEMBER stopped )
|
|
Q_PROPERTY(bool thumbsUpState MEMBER thumbsUpState )
|
|
Q_PROPERTY(bool thumbsDownState MEMBER thumbsDownState)
|
|
|
|
// thinking
|
|
Q_PROPERTY(int thinkingTime MEMBER thinkingTime NOTIFY thinkingTimeChanged)
|
|
|
|
public:
|
|
enum class Type { System, Prompt, Response, Text, ToolCall, ToolResponse, Think };
|
|
|
|
// tags for constructing ChatItems
|
|
struct prompt_tag_t { explicit prompt_tag_t () = default; };
|
|
struct response_tag_t { explicit response_tag_t () = default; };
|
|
struct system_tag_t { explicit system_tag_t () = default; };
|
|
struct text_tag_t { explicit text_tag_t () = default; };
|
|
struct tool_call_tag_t { explicit tool_call_tag_t () = default; };
|
|
struct tool_response_tag_t { explicit tool_response_tag_t() = default; };
|
|
struct think_tag_t { explicit think_tag_t () = default; };
|
|
static inline constexpr prompt_tag_t prompt_tag = prompt_tag_t {};
|
|
static inline constexpr response_tag_t response_tag = response_tag_t {};
|
|
static inline constexpr system_tag_t system_tag = system_tag_t {};
|
|
static inline constexpr text_tag_t text_tag = text_tag_t {};
|
|
static inline constexpr tool_call_tag_t tool_call_tag = tool_call_tag_t {};
|
|
static inline constexpr tool_response_tag_t tool_response_tag = tool_response_tag_t {};
|
|
static inline constexpr think_tag_t think_tag = think_tag_t {};
|
|
|
|
public:
|
|
ChatItem(QObject *parent)
|
|
: QObject(nullptr)
|
|
{
|
|
moveToThread(parent->thread());
|
|
// setParent must be called from the thread the object lives in
|
|
QMetaObject::invokeMethod(this, [this, parent]() { this->setParent(parent); });
|
|
}
|
|
|
|
// NOTE: System messages are currently never serialized and only *stored* by the local server.
|
|
// ChatLLM prepends a system MessageItem on-the-fly.
|
|
ChatItem(QObject *parent, system_tag_t, const QString &value)
|
|
: ChatItem(parent)
|
|
{ this->name = u"System: "_s; this->value = value; }
|
|
|
|
ChatItem(QObject *parent, prompt_tag_t, const QString &value, const QList<PromptAttachment> &attachments = {})
|
|
: ChatItem(parent)
|
|
{ this->name = u"Prompt: "_s; this->value = value; this->promptAttachments = attachments; }
|
|
|
|
private:
|
|
ChatItem(QObject *parent, response_tag_t, bool isCurrentResponse, const QString &value = {})
|
|
: ChatItem(parent)
|
|
{ this->name = u"Response: "_s; this->value = value; this->isCurrentResponse = isCurrentResponse; }
|
|
|
|
public:
|
|
// A new response, to be filled in
|
|
ChatItem(QObject *parent, response_tag_t)
|
|
: ChatItem(parent, response_tag, true) {}
|
|
|
|
// An existing response, from Server
|
|
ChatItem(QObject *parent, response_tag_t, const QString &value)
|
|
: ChatItem(parent, response_tag, false, value) {}
|
|
|
|
ChatItem(QObject *parent, text_tag_t, const QString &value)
|
|
: ChatItem(parent)
|
|
{ this->name = u"Text: "_s; this->value = value; }
|
|
|
|
ChatItem(QObject *parent, tool_call_tag_t, const QString &value)
|
|
: ChatItem(parent)
|
|
{ this->name = u"ToolCall: "_s; this->value = value; }
|
|
|
|
ChatItem(QObject *parent, tool_response_tag_t, const QString &value)
|
|
: ChatItem(parent)
|
|
{ this->name = u"ToolResponse: "_s; this->value = value; }
|
|
|
|
ChatItem(QObject *parent, think_tag_t, const QString &value)
|
|
: ChatItem(parent)
|
|
{ this->name = u"Think: "_s; this->value = value; }
|
|
|
|
Type type() const
|
|
{
|
|
if (name == u"System: "_s)
|
|
return Type::System;
|
|
if (name == u"Prompt: "_s)
|
|
return Type::Prompt;
|
|
if (name == u"Response: "_s)
|
|
return Type::Response;
|
|
if (name == u"Text: "_s)
|
|
return Type::Text;
|
|
if (name == u"ToolCall: "_s)
|
|
return Type::ToolCall;
|
|
if (name == u"ToolResponse: "_s)
|
|
return Type::ToolResponse;
|
|
if (name == u"Think: "_s)
|
|
return Type::Think;
|
|
throw std::invalid_argument(fmt::format("Chat item has unknown label: {:?}", name));
|
|
}
|
|
|
|
QString flattenedContent() const
|
|
{
|
|
if (subItems.empty())
|
|
return value;
|
|
|
|
// We only flatten one level
|
|
QString content;
|
|
for (ChatItem *item : subItems)
|
|
content += item->value;
|
|
return content;
|
|
}
|
|
|
|
QString content() const
|
|
{
|
|
if (type() == Type::Response) {
|
|
// We parse if this contains any part of a partial toolcall
|
|
ToolCallParser parser;
|
|
parser.update(value.toUtf8());
|
|
|
|
// If no tool call is detected, return the original value
|
|
if (parser.startIndex() < 0)
|
|
return value;
|
|
|
|
// Otherwise we only return the text before and any partial tool call
|
|
const QString beforeToolCall = value.left(parser.startIndex());
|
|
return beforeToolCall;
|
|
}
|
|
|
|
if (type() == Type::Think)
|
|
return thinkContent(value);
|
|
|
|
if (type() == Type::ToolCall)
|
|
return toolCallContent(value);
|
|
|
|
// We don't show any of content from the tool response in the GUI
|
|
if (type() == Type::ToolResponse)
|
|
return QString();
|
|
|
|
return value;
|
|
}
|
|
|
|
QString thinkContent(const QString &value) const
|
|
{
|
|
ToolCallParser parser;
|
|
parser.update(value.toUtf8());
|
|
|
|
// Extract the content
|
|
QString content = parser.toolCall();
|
|
content = content.trimmed();
|
|
return content;
|
|
}
|
|
|
|
QString toolCallContent(const QString &value) const
|
|
{
|
|
ToolCallParser parser;
|
|
parser.update(value.toUtf8());
|
|
|
|
// Extract the code
|
|
QString code = parser.toolCall();
|
|
code = code.trimmed();
|
|
|
|
QString result;
|
|
|
|
// If we've finished the tool call then extract the result from meta information
|
|
if (toolCallInfo.name == ToolCallConstants::CodeInterpreterFunction)
|
|
result = "```\n" + toolCallInfo.result + "```";
|
|
|
|
// Return the formatted code and the result if available
|
|
return code + result;
|
|
}
|
|
|
|
QString clipboardContent() const
|
|
{
|
|
QStringList clipContent;
|
|
for (const ChatItem *item : subItems)
|
|
clipContent << item->clipboardContent();
|
|
clipContent << content();
|
|
return clipContent.join("");
|
|
}
|
|
|
|
QList<ChatItem *> childItems() const
|
|
{
|
|
// We currently have leaf nodes at depth 3 with nodes at depth 2 as mere containers we don't
|
|
// care about in GUI
|
|
QList<ChatItem *> items;
|
|
for (const ChatItem *item : subItems) {
|
|
items.reserve(items.size() + item->subItems.size());
|
|
ranges::copy(item->subItems, std::back_inserter(items));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
QString possibleToolCall() const
|
|
{
|
|
if (!subItems.empty())
|
|
return subItems.back()->possibleToolCall();
|
|
if (type() == Type::ToolCall)
|
|
return value;
|
|
else
|
|
return QString();
|
|
}
|
|
|
|
void setCurrentResponse(bool b)
|
|
{
|
|
if (!subItems.empty())
|
|
subItems.back()->setCurrentResponse(b);
|
|
isCurrentResponse = b;
|
|
emit isCurrentResponseChanged();
|
|
}
|
|
|
|
void setValue(const QString &v)
|
|
{
|
|
if (!subItems.empty() && subItems.back()->isCurrentResponse) {
|
|
subItems.back()->setValue(v);
|
|
return;
|
|
}
|
|
|
|
value = v;
|
|
emit contentChanged();
|
|
}
|
|
|
|
void setToolCallInfo(const ToolCallInfo &info)
|
|
{
|
|
toolCallInfo = info;
|
|
emit contentChanged();
|
|
emit isTooCallErrorChanged();
|
|
}
|
|
|
|
bool isToolCallError() const
|
|
{
|
|
return toolCallInfo.error != ToolEnums::Error::NoError;
|
|
}
|
|
|
|
void setThinkingTime(int t)
|
|
{
|
|
thinkingTime = t;
|
|
emit thinkingTimeChanged();
|
|
}
|
|
|
|
// NB: Assumes response is not current.
|
|
static ChatItem *fromMessageInput(QObject *parent, const MessageInput &message)
|
|
{
|
|
switch (message.type) {
|
|
using enum MessageInput::Type;
|
|
case Prompt: return new ChatItem(parent, prompt_tag, message.content);
|
|
case Response: return new ChatItem(parent, response_tag, message.content);
|
|
case System: return new ChatItem(parent, system_tag, message.content);
|
|
}
|
|
Q_UNREACHABLE();
|
|
}
|
|
|
|
MessageItem asMessageItem(qsizetype index) const
|
|
{
|
|
MessageItem::Type msgType;
|
|
switch (auto typ = type()) {
|
|
using enum ChatItem::Type;
|
|
case System: msgType = MessageItem::Type::System; break;
|
|
case Prompt: msgType = MessageItem::Type::Prompt; break;
|
|
case Response: msgType = MessageItem::Type::Response; break;
|
|
case ToolResponse: msgType = MessageItem::Type::ToolResponse; break;
|
|
case Text:
|
|
case ToolCall:
|
|
case Think:
|
|
throw std::invalid_argument(fmt::format("cannot convert ChatItem type {} to message item", int(typ)));
|
|
}
|
|
return { index, msgType, flattenedContent(), sources, promptAttachments };
|
|
}
|
|
|
|
static QList<ResultInfo> consolidateSources(const QList<ResultInfo> &sources);
|
|
|
|
void serializeResponse(QDataStream &stream, int version);
|
|
void serializeToolCall(QDataStream &stream, int version);
|
|
void serializeToolResponse(QDataStream &stream, int version);
|
|
void serializeText(QDataStream &stream, int version);
|
|
void serializeThink(QDataStream &stream, int version);
|
|
void serializeSubItems(QDataStream &stream, int version); // recursive
|
|
void serialize(QDataStream &stream, int version);
|
|
|
|
|
|
bool deserializeResponse(QDataStream &stream, int version);
|
|
bool deserializeToolCall(QDataStream &stream, int version);
|
|
bool deserializeToolResponse(QDataStream &stream, int version);
|
|
bool deserializeText(QDataStream &stream, int version);
|
|
bool deserializeThink(QDataStream &stream, int version);
|
|
bool deserializeSubItems(QDataStream &stream, int version); // recursive
|
|
bool deserialize(QDataStream &stream, int version);
|
|
|
|
Q_SIGNALS:
|
|
void contentChanged();
|
|
void isTooCallErrorChanged();
|
|
void isCurrentResponseChanged();
|
|
void thinkingTimeChanged();
|
|
|
|
public:
|
|
|
|
// TODO: Maybe we should include the model name here as well as timestamp?
|
|
QString name;
|
|
QString value;
|
|
|
|
// prompts
|
|
QList<ResultInfo> sources;
|
|
QList<ResultInfo> consolidatedSources;
|
|
QList<PromptAttachment> promptAttachments;
|
|
|
|
// responses
|
|
bool isCurrentResponse = false;
|
|
bool isError = false;
|
|
ToolCallInfo toolCallInfo;
|
|
std::list<ChatItem *> subItems;
|
|
|
|
// responses (DataLake)
|
|
QString newResponse;
|
|
bool stopped = false;
|
|
bool thumbsUpState = false;
|
|
bool thumbsDownState = false;
|
|
|
|
// thinking time in ms
|
|
int thinkingTime = 0;
|
|
};
|
|
|
|
class ChatModel : public QAbstractListModel
|
|
{
|
|
Q_OBJECT
|
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
|
Q_PROPERTY(bool hasError READ hasError NOTIFY hasErrorChanged)
|
|
|
|
public:
|
|
explicit ChatModel(QObject *parent = nullptr)
|
|
: QAbstractListModel(parent) {}
|
|
|
|
// FIXME(jared): can't this start at Qt::UserRole (no +1)?
|
|
enum Roles {
|
|
NameRole = Qt::UserRole + 1,
|
|
ValueRole,
|
|
|
|
// prompts and responses
|
|
ContentRole,
|
|
|
|
// prompts
|
|
PromptAttachmentsRole,
|
|
|
|
// responses
|
|
// NOTE: sources are stored on the *prompts*, but in the model, they are only on the *responses*!
|
|
SourcesRole,
|
|
ConsolidatedSourcesRole,
|
|
IsCurrentResponseRole,
|
|
IsErrorRole,
|
|
ChildItemsRole,
|
|
|
|
// responses (DataLake)
|
|
NewResponseRole,
|
|
StoppedRole,
|
|
ThumbsUpStateRole,
|
|
ThumbsDownStateRole,
|
|
};
|
|
|
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
Q_UNUSED(parent)
|
|
return m_chatItems.size();
|
|
}
|
|
|
|
/* a "peer" is a bidirectional 1:1 link between a prompt and the response that would cite its LocalDocs
|
|
* sources. Return std::nullopt if there is none, which is possible for e.g. server chats. */
|
|
template <typename T>
|
|
static std::optional<qsizetype> getPeer(const T *arr, qsizetype size, qsizetype index)
|
|
{
|
|
Q_ASSERT(index >= 0);
|
|
Q_ASSERT(index < size);
|
|
return getPeerInternal(arr, size, index);
|
|
}
|
|
|
|
private:
|
|
static std::optional<qsizetype> getPeerInternal(const ChatItem * const *arr, qsizetype size, qsizetype index)
|
|
{
|
|
qsizetype peer;
|
|
ChatItem::Type expected;
|
|
switch (arr[index]->type()) {
|
|
using enum ChatItem::Type;
|
|
case Prompt: peer = index + 1; expected = Response; break;
|
|
case Response: peer = index - 1; expected = Prompt; break;
|
|
default: throw std::invalid_argument("getPeer() called on item that is not a prompt or response");
|
|
}
|
|
if (peer >= 0 && peer < size && arr[peer]->type() == expected)
|
|
return peer;
|
|
return std::nullopt;
|
|
}
|
|
|
|
// FIXME(jared): this should really be done at the parent level, not the sub-item level
|
|
static std::optional<qsizetype> getPeerInternal(const MessageItem *arr, qsizetype size, qsizetype index)
|
|
{
|
|
qsizetype peer;
|
|
MessageItem::Type expected;
|
|
switch (arr[index].type()) {
|
|
using enum MessageItem::Type;
|
|
case Prompt: peer = index + 1; expected = Response; break;
|
|
case Response: peer = index - 1; expected = Prompt; break;
|
|
default: throw std::invalid_argument("getPeer() called on item that is not a prompt or response");
|
|
}
|
|
if (peer >= 0 && peer < size && arr[peer].type() == expected)
|
|
return peer;
|
|
return std::nullopt;
|
|
}
|
|
|
|
public:
|
|
template <ranges::contiguous_range R>
|
|
static auto getPeer(R &&range, ranges::iterator_t<R> item) -> std::optional<ranges::iterator_t<R>>
|
|
{
|
|
auto begin = ranges::begin(range);
|
|
return getPeer(ranges::data(range), ranges::size(range), item - begin)
|
|
.transform([&](auto i) { return begin + i; });
|
|
}
|
|
|
|
auto getPeerUnlocked(QList<ChatItem *>::const_iterator item) const -> std::optional<QList<ChatItem *>::const_iterator>
|
|
{ return getPeer(m_chatItems, item); }
|
|
|
|
std::optional<qsizetype> getPeerUnlocked(qsizetype index) const
|
|
{ return getPeer(m_chatItems.constData(), m_chatItems.size(), index); }
|
|
|
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_chatItems.size())
|
|
return QVariant();
|
|
|
|
auto itemIt = m_chatItems.cbegin() + index.row();
|
|
auto *item = *itemIt;
|
|
switch (role) {
|
|
case NameRole:
|
|
return item->name;
|
|
case ValueRole:
|
|
return item->value;
|
|
case PromptAttachmentsRole:
|
|
return QVariant::fromValue(item->promptAttachments);
|
|
case SourcesRole:
|
|
{
|
|
QList<ResultInfo> data;
|
|
if (item->type() == ChatItem::Type::Response) {
|
|
if (auto prompt = getPeerUnlocked(itemIt))
|
|
data = (**prompt)->sources;
|
|
}
|
|
return QVariant::fromValue(data);
|
|
}
|
|
case ConsolidatedSourcesRole:
|
|
{
|
|
QList<ResultInfo> data;
|
|
if (item->type() == ChatItem::Type::Response) {
|
|
if (auto prompt = getPeerUnlocked(itemIt))
|
|
data = (**prompt)->consolidatedSources;
|
|
}
|
|
return QVariant::fromValue(data);
|
|
}
|
|
case IsCurrentResponseRole:
|
|
return item->isCurrentResponse;
|
|
case NewResponseRole:
|
|
return item->newResponse;
|
|
case StoppedRole:
|
|
return item->stopped;
|
|
case ThumbsUpStateRole:
|
|
return item->thumbsUpState;
|
|
case ThumbsDownStateRole:
|
|
return item->thumbsDownState;
|
|
case IsErrorRole:
|
|
return item->type() == ChatItem::Type::Response && item->isError;
|
|
case ContentRole:
|
|
return item->content();
|
|
case ChildItemsRole:
|
|
return QVariant::fromValue(item->childItems());
|
|
}
|
|
|
|
return QVariant();
|
|
}
|
|
|
|
QHash<int, QByteArray> roleNames() const override
|
|
{
|
|
return {
|
|
{ NameRole, "name" },
|
|
{ ValueRole, "value" },
|
|
{ PromptAttachmentsRole, "promptAttachments" },
|
|
{ SourcesRole, "sources" },
|
|
{ ConsolidatedSourcesRole, "consolidatedSources" },
|
|
{ IsCurrentResponseRole, "isCurrentResponse" },
|
|
{ IsErrorRole, "isError" },
|
|
{ NewResponseRole, "newResponse" },
|
|
{ StoppedRole, "stopped" },
|
|
{ ThumbsUpStateRole, "thumbsUpState" },
|
|
{ ThumbsDownStateRole, "thumbsDownState" },
|
|
{ ContentRole, "content" },
|
|
{ ChildItemsRole, "childItems" },
|
|
};
|
|
}
|
|
|
|
void appendPrompt(const QString &value, const QList<PromptAttachment> &attachments = {})
|
|
{
|
|
qsizetype count;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (hasErrorUnlocked())
|
|
throw std::logic_error("cannot append to a failed chat");
|
|
count = m_chatItems.count();
|
|
}
|
|
|
|
beginInsertRows(QModelIndex(), count, count);
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
m_chatItems << new ChatItem(this, ChatItem::prompt_tag, value, attachments);
|
|
}
|
|
endInsertRows();
|
|
emit countChanged();
|
|
}
|
|
|
|
void appendResponse()
|
|
{
|
|
qsizetype count;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (hasErrorUnlocked())
|
|
throw std::logic_error("cannot append to a failed chat");
|
|
count = m_chatItems.count();
|
|
}
|
|
|
|
beginInsertRows(QModelIndex(), count, count);
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
m_chatItems << new ChatItem(this, ChatItem::response_tag);
|
|
}
|
|
endInsertRows();
|
|
emit countChanged();
|
|
}
|
|
|
|
// Used by Server to append a new conversation to the chat log.
|
|
// Returns the offset of the appended items.
|
|
qsizetype appendResponseWithHistory(std::span<const MessageInput> history)
|
|
{
|
|
if (history.empty())
|
|
throw std::invalid_argument("at least one message is required");
|
|
|
|
m_mutex.lock();
|
|
qsizetype startIndex = m_chatItems.size();
|
|
m_mutex.unlock();
|
|
|
|
qsizetype nNewItems = history.size() + 1;
|
|
qsizetype endIndex = startIndex + nNewItems;
|
|
beginInsertRows(QModelIndex(), startIndex, endIndex - 1 /*inclusive*/);
|
|
bool hadError;
|
|
QList<ChatItem> newItems;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
startIndex = m_chatItems.size(); // just in case
|
|
hadError = hasErrorUnlocked();
|
|
m_chatItems.reserve(m_chatItems.count() + nNewItems);
|
|
for (auto &message : history)
|
|
m_chatItems << ChatItem::fromMessageInput(this, message);
|
|
m_chatItems << new ChatItem(this, ChatItem::response_tag);
|
|
}
|
|
endInsertRows();
|
|
emit countChanged();
|
|
// Server can add messages when there is an error because each call is a new conversation
|
|
if (hadError)
|
|
emit hasErrorChanged(false);
|
|
return startIndex;
|
|
}
|
|
|
|
void truncate(qsizetype size)
|
|
{
|
|
qsizetype oldSize;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (size >= (oldSize = m_chatItems.size()))
|
|
return;
|
|
if (size && m_chatItems.at(size - 1)->type() != ChatItem::Type::Response)
|
|
throw std::invalid_argument(
|
|
fmt::format("chat model truncated to {} items would not end in a response", size)
|
|
);
|
|
}
|
|
|
|
bool oldHasError;
|
|
beginRemoveRows(QModelIndex(), size, oldSize - 1 /*inclusive*/);
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
oldHasError = hasErrorUnlocked();
|
|
Q_ASSERT(size < m_chatItems.size());
|
|
m_chatItems.resize(size);
|
|
}
|
|
endRemoveRows();
|
|
emit countChanged();
|
|
if (oldHasError)
|
|
emit hasErrorChanged(false);
|
|
}
|
|
|
|
QString popPrompt(int index)
|
|
{
|
|
QString content;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size() || m_chatItems[index]->type() != ChatItem::Type::Prompt)
|
|
throw std::logic_error("attempt to pop a prompt, but this is not a prompt");
|
|
content = m_chatItems[index]->content();
|
|
}
|
|
truncate(index);
|
|
return content;
|
|
}
|
|
|
|
bool regenerateResponse(int index)
|
|
{
|
|
int promptIdx;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
auto items = m_chatItems; // holds lock
|
|
if (index < 1 || index >= items.size() || items[index]->type() != ChatItem::Type::Response)
|
|
return false;
|
|
promptIdx = getPeerUnlocked(index).value_or(-1);
|
|
}
|
|
|
|
truncate(index + 1);
|
|
clearSubItems(index);
|
|
setResponseValue({});
|
|
updateCurrentResponse(index, true );
|
|
updateNewResponse (index, {} );
|
|
updateStopped (index, false);
|
|
updateThumbsUpState (index, false);
|
|
updateThumbsDownState(index, false);
|
|
setError(false);
|
|
if (promptIdx >= 0)
|
|
updateSources(promptIdx, {});
|
|
return true;
|
|
}
|
|
|
|
Q_INVOKABLE void clear()
|
|
{
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty()) return;
|
|
}
|
|
|
|
bool oldHasError;
|
|
beginResetModel();
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
oldHasError = hasErrorUnlocked();
|
|
m_chatItems.clear();
|
|
}
|
|
endResetModel();
|
|
emit countChanged();
|
|
if (oldHasError)
|
|
emit hasErrorChanged(false);
|
|
}
|
|
|
|
Q_INVOKABLE QString possibleToolcall() const
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.empty()) return QString();
|
|
return m_chatItems.back()->possibleToolCall();
|
|
}
|
|
|
|
Q_INVOKABLE void updateCurrentResponse(int index, bool b)
|
|
{
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
ChatItem *item = m_chatItems[index];
|
|
item->setCurrentResponse(b);
|
|
}
|
|
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {IsCurrentResponseRole});
|
|
}
|
|
|
|
Q_INVOKABLE void updateStopped(int index, bool b)
|
|
{
|
|
bool changed = false;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
ChatItem *item = m_chatItems[index];
|
|
if (item->stopped != b) {
|
|
item->stopped = b;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) emit dataChanged(createIndex(index, 0), createIndex(index, 0), {StoppedRole});
|
|
}
|
|
|
|
Q_INVOKABLE void setResponseValue(const QString &value)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("we only set this on a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
ChatItem *item = m_chatItems.back();
|
|
item->setValue(value);
|
|
}
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ValueRole, ContentRole});
|
|
}
|
|
|
|
Q_INVOKABLE void updateSources(int index, const QList<ResultInfo> &sources)
|
|
{
|
|
int responseIndex = -1;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
auto promptItem = m_chatItems.begin() + index;
|
|
if ((*promptItem)->type() != ChatItem::Type::Prompt)
|
|
throw std::invalid_argument(fmt::format("item at index {} is not a prompt", index));
|
|
if (auto peer = getPeerUnlocked(promptItem))
|
|
responseIndex = *peer - m_chatItems.cbegin();
|
|
(*promptItem)->sources = sources;
|
|
(*promptItem)->consolidatedSources = ChatItem::consolidateSources(sources);
|
|
}
|
|
if (responseIndex >= 0) {
|
|
emit dataChanged(createIndex(responseIndex, 0), createIndex(responseIndex, 0), {SourcesRole});
|
|
emit dataChanged(createIndex(responseIndex, 0), createIndex(responseIndex, 0), {ConsolidatedSourcesRole});
|
|
}
|
|
}
|
|
|
|
Q_INVOKABLE void updateThumbsUpState(int index, bool b)
|
|
{
|
|
bool changed = false;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
ChatItem *item = m_chatItems[index];
|
|
if (item->thumbsUpState != b) {
|
|
item->thumbsUpState = b;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ThumbsUpStateRole});
|
|
}
|
|
|
|
Q_INVOKABLE void updateThumbsDownState(int index, bool b)
|
|
{
|
|
bool changed = false;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
ChatItem *item = m_chatItems[index];
|
|
if (item->thumbsDownState != b) {
|
|
item->thumbsDownState = b;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ThumbsDownStateRole});
|
|
}
|
|
|
|
Q_INVOKABLE void updateNewResponse(int index, const QString &newResponse)
|
|
{
|
|
bool changed = false;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
|
|
ChatItem *item = m_chatItems[index];
|
|
if (item->newResponse != newResponse) {
|
|
item->newResponse = newResponse;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) emit dataChanged(createIndex(index, 0), createIndex(index, 0), {NewResponseRole});
|
|
}
|
|
|
|
Q_INVOKABLE void splitThinking(const QPair<QString, QString> &split)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only set thinking on a chat that ends with a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
ChatItem *currentResponse = m_chatItems.back();
|
|
Q_ASSERT(currentResponse->isCurrentResponse);
|
|
|
|
// Create a new response container for any text and the thinking
|
|
ChatItem *newResponse = new ChatItem(this, ChatItem::response_tag);
|
|
|
|
// Add preceding text if any
|
|
if (!split.first.isEmpty()) {
|
|
ChatItem *textItem = new ChatItem(this, ChatItem::text_tag, split.first);
|
|
newResponse->subItems.push_back(textItem);
|
|
}
|
|
|
|
// Add the thinking item
|
|
Q_ASSERT(!split.second.isEmpty());
|
|
ChatItem *thinkingItem = new ChatItem(this, ChatItem::think_tag, split.second);
|
|
thinkingItem->isCurrentResponse = true;
|
|
newResponse->subItems.push_back(thinkingItem);
|
|
|
|
// Add new response and reset our value
|
|
currentResponse->subItems.push_back(newResponse);
|
|
currentResponse->value = QString();
|
|
}
|
|
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ChildItemsRole, ContentRole});
|
|
}
|
|
|
|
Q_INVOKABLE void endThinking(const QPair<QString, QString> &split, int thinkingTime)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only end thinking on a chat that ends with a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
ChatItem *currentResponse = m_chatItems.back();
|
|
Q_ASSERT(currentResponse->isCurrentResponse);
|
|
|
|
ChatItem *subResponse = currentResponse->subItems.back();
|
|
Q_ASSERT(subResponse->type() == ChatItem::Type::Response);
|
|
Q_ASSERT(subResponse->isCurrentResponse);
|
|
subResponse->setCurrentResponse(false);
|
|
|
|
ChatItem *thinkingItem = subResponse->subItems.back();
|
|
Q_ASSERT(thinkingItem->type() == ChatItem::Type::Think);
|
|
thinkingItem->setCurrentResponse(false);
|
|
thinkingItem->setValue(split.first);
|
|
thinkingItem->setThinkingTime(thinkingTime);
|
|
|
|
currentResponse->setValue(split.second);
|
|
}
|
|
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ChildItemsRole, ContentRole});
|
|
}
|
|
|
|
Q_INVOKABLE void splitToolCall(const QPair<QString, QString> &split)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only set toolcall on a chat that ends with a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
ChatItem *currentResponse = m_chatItems.back();
|
|
Q_ASSERT(currentResponse->isCurrentResponse);
|
|
|
|
// Create a new response container for any text and the tool call
|
|
ChatItem *newResponse = new ChatItem(this, ChatItem::response_tag);
|
|
|
|
// Add preceding text if any
|
|
if (!split.first.isEmpty()) {
|
|
ChatItem *textItem = new ChatItem(this, ChatItem::text_tag, split.first);
|
|
newResponse->subItems.push_back(textItem);
|
|
}
|
|
|
|
// Add the toolcall
|
|
Q_ASSERT(!split.second.isEmpty());
|
|
ChatItem *toolCallItem = new ChatItem(this, ChatItem::tool_call_tag, split.second);
|
|
toolCallItem->isCurrentResponse = true;
|
|
newResponse->subItems.push_back(toolCallItem);
|
|
|
|
// Add new response and reset our value
|
|
currentResponse->subItems.push_back(newResponse);
|
|
currentResponse->value = QString();
|
|
}
|
|
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ChildItemsRole, ContentRole});
|
|
}
|
|
|
|
Q_INVOKABLE void updateToolCall(const ToolCallInfo &toolCallInfo)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only set toolcall on a chat that ends with a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
ChatItem *currentResponse = m_chatItems.back();
|
|
Q_ASSERT(currentResponse->isCurrentResponse);
|
|
|
|
ChatItem *subResponse = currentResponse->subItems.back();
|
|
Q_ASSERT(subResponse->type() == ChatItem::Type::Response);
|
|
Q_ASSERT(subResponse->isCurrentResponse);
|
|
|
|
ChatItem *toolCallItem = subResponse->subItems.back();
|
|
Q_ASSERT(toolCallItem->type() == ChatItem::Type::ToolCall);
|
|
toolCallItem->setToolCallInfo(toolCallInfo);
|
|
toolCallItem->setCurrentResponse(false);
|
|
|
|
// Add tool response
|
|
ChatItem *toolResponseItem = new ChatItem(this, ChatItem::tool_response_tag, toolCallInfo.result);
|
|
currentResponse->subItems.push_back(toolResponseItem);
|
|
}
|
|
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ChildItemsRole, ContentRole});
|
|
}
|
|
|
|
void clearSubItems(int index)
|
|
{
|
|
bool changed = false;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size()) return;
|
|
if (m_chatItems.isEmpty() || m_chatItems[index]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only clear subitems on a chat that ends with a response");
|
|
|
|
ChatItem *item = m_chatItems.back();
|
|
if (!item->subItems.empty()) {
|
|
item->subItems.clear();
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {ChildItemsRole, ContentRole});
|
|
}
|
|
}
|
|
|
|
Q_INVOKABLE void setError(bool value = true)
|
|
{
|
|
qsizetype index;
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
|
|
if (m_chatItems.isEmpty() || m_chatItems.cend()[-1]->type() != ChatItem::Type::Response)
|
|
throw std::logic_error("can only set error on a chat that ends with a response");
|
|
|
|
index = m_chatItems.count() - 1;
|
|
auto &last = m_chatItems.back();
|
|
if (last->isError == value)
|
|
return; // already set
|
|
last->isError = value;
|
|
}
|
|
emit dataChanged(createIndex(index, 0), createIndex(index, 0), {IsErrorRole});
|
|
emit hasErrorChanged(value);
|
|
}
|
|
|
|
Q_INVOKABLE void copyToClipboard()
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
QString conversation;
|
|
for (ChatItem *item : m_chatItems) {
|
|
QString string = item->name;
|
|
string += item->clipboardContent();
|
|
string += "\n";
|
|
conversation += string;
|
|
}
|
|
QClipboard *clipboard = QGuiApplication::clipboard();
|
|
clipboard->setText(conversation, QClipboard::Clipboard);
|
|
}
|
|
|
|
Q_INVOKABLE void copyToClipboard(int index)
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
if (index < 0 || index >= m_chatItems.size())
|
|
return;
|
|
ChatItem *item = m_chatItems.at(index);
|
|
QClipboard *clipboard = QGuiApplication::clipboard();
|
|
clipboard->setText(item->clipboardContent(), QClipboard::Clipboard);
|
|
}
|
|
|
|
qsizetype count() const { QMutexLocker locker(&m_mutex); return m_chatItems.size(); }
|
|
|
|
std::vector<MessageItem> messageItems() const
|
|
{
|
|
// A flattened version of the chat item tree used by the backend and jinja
|
|
QMutexLocker locker(&m_mutex);
|
|
std::vector<MessageItem> chatItems;
|
|
for (qsizetype i : views::iota(0, m_chatItems.size())) {
|
|
auto *parent = m_chatItems.at(i);
|
|
chatItems.reserve(chatItems.size() + parent->subItems.size() + 1);
|
|
ranges::copy(parent->subItems | views::transform([&](auto *s) { return s->asMessageItem(i); }),
|
|
std::back_inserter(chatItems));
|
|
chatItems.push_back(parent->asMessageItem(i));
|
|
}
|
|
return chatItems;
|
|
}
|
|
|
|
bool hasError() const { QMutexLocker locker(&m_mutex); return hasErrorUnlocked(); }
|
|
|
|
bool serialize(QDataStream &stream, int version) const
|
|
{
|
|
// FIXME: need to serialize new chatitem tree
|
|
QMutexLocker locker(&m_mutex);
|
|
stream << int(m_chatItems.size());
|
|
for (auto itemIt = m_chatItems.cbegin(); itemIt < m_chatItems.cend(); ++itemIt) {
|
|
auto c = *itemIt; // NB: copies
|
|
if (version < 11) {
|
|
// move sources from their prompt to the next response
|
|
switch (c->type()) {
|
|
using enum ChatItem::Type;
|
|
case Prompt:
|
|
c->sources.clear();
|
|
c->consolidatedSources.clear();
|
|
break;
|
|
case Response:
|
|
// note: we drop sources for responseless prompts
|
|
if (auto peer = getPeerUnlocked(itemIt)) {
|
|
c->sources = (**peer)->sources;
|
|
c->consolidatedSources = (**peer)->consolidatedSources;
|
|
}
|
|
default:
|
|
;
|
|
}
|
|
}
|
|
|
|
c->serialize(stream, version);
|
|
}
|
|
return stream.status() == QDataStream::Ok;
|
|
}
|
|
|
|
bool deserialize(QDataStream &stream, int version)
|
|
{
|
|
clear(); // reset to known state
|
|
|
|
int size;
|
|
stream >> size;
|
|
int lastPromptIndex = -1;
|
|
QList<ChatItem*> chatItems;
|
|
for (int i = 0; i < size; ++i) {
|
|
ChatItem *c = new ChatItem(this);
|
|
if (!c->deserialize(stream, version)) {
|
|
delete c;
|
|
return false;
|
|
}
|
|
if (version < 11 && c->type() == ChatItem::Type::Response) {
|
|
// move sources from the response to their last prompt
|
|
if (lastPromptIndex >= 0) {
|
|
auto &prompt = chatItems[lastPromptIndex];
|
|
prompt->sources = std::move(c->sources );
|
|
prompt->consolidatedSources = std::move(c->consolidatedSources);
|
|
lastPromptIndex = -1;
|
|
} else {
|
|
// drop sources for promptless responses
|
|
c->sources.clear();
|
|
c->consolidatedSources.clear();
|
|
}
|
|
}
|
|
|
|
chatItems << c;
|
|
if (c->type() == ChatItem::Type::Prompt)
|
|
lastPromptIndex = chatItems.size() - 1;
|
|
}
|
|
|
|
bool hasError;
|
|
beginInsertRows(QModelIndex(), 0, chatItems.size() - 1 /*inclusive*/);
|
|
{
|
|
QMutexLocker locker(&m_mutex);
|
|
m_chatItems = chatItems;
|
|
hasError = hasErrorUnlocked();
|
|
}
|
|
endInsertRows();
|
|
emit countChanged();
|
|
if (hasError)
|
|
emit hasErrorChanged(true);
|
|
return stream.status() == QDataStream::Ok;
|
|
}
|
|
|
|
Q_SIGNALS:
|
|
void countChanged();
|
|
void hasErrorChanged(bool value);
|
|
|
|
private:
|
|
bool hasErrorUnlocked() const
|
|
{
|
|
if (m_chatItems.isEmpty())
|
|
return false;
|
|
auto &last = m_chatItems.back();
|
|
return last->type() == ChatItem::Type::Response && last->isError;
|
|
}
|
|
|
|
private:
|
|
mutable QMutex m_mutex;
|
|
QList<ChatItem *> m_chatItems;
|
|
};
|
|
|
|
#endif // CHATMODEL_H
|