forked from WHC/Hermit
A prompt can carry files now, and they come in the three ways a desktop user expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files dropped anywhere on the window (a DropArea over it, with an accent border and "drop to attach" while something is dragged), and an image on the clipboard (Ctrl+V). Text keeps the editor's own paste: the composer asks AgentCore::clipboardHasImage() first and only takes the keystroke when there is an image. AgentCore (app/src/core/AgentCore.*): - pending is a property now (QStringList) fed by cst_agent_pending_describe(); attachFile() and attachClipboard() wrap cst_agent_attach_file() / cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and seesImages() reports whether the model is shown images at all. - the engine's description says what the model gets ("png 12.0 KB"), not where it came from, so the window remembers the names it attached and puts them in front: "note.txt — text 21 B". - pendingChanged() fires on attach, on clear and when a turn ends (the prompt consumed the list), which is what makes the chips move. Engine (src/attach.c): - cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB" now, and test_attach still passes. QML: the composer shows one chip per pending file with "clear all"; the hint line mentions 📎/Ctrl+V and says when the model is text-only (images then travel as notes); Main.qml owns the DropArea and the drag feedback. Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a missing file is refused, one chip in the composer, clear all empties it, the clipboard image is seen and attached), --selftest, make check rc=0 (test_attach included), and screenshots of the chip row. Version 0.8.12.
216 lines
9.4 KiB
C++
216 lines
9.4 KiB
C++
/* AgentCore: the Qt wrapper around one cst_agent_t.
|
|
*
|
|
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
|
|
* is turned into a Qt signal, and the two hooks that need an answer
|
|
* (on_permission, on_ask_user) block the worker until the GUI replies. No
|
|
* HTTP, no socket: the GUI owns the engine in its own process. */
|
|
|
|
#ifndef CST_APP_AGENTCORE_H
|
|
#define CST_APP_AGENTCORE_H
|
|
|
|
#include <QByteArray>
|
|
#include <QMutex>
|
|
#include <QObject>
|
|
#include <QString>
|
|
#include <QVariantList>
|
|
#include <QVariantMap>
|
|
#include <QWaitCondition>
|
|
|
|
#include "cst_agent.h"
|
|
#include "cst_tools.h"
|
|
|
|
class QThread;
|
|
|
|
class AgentCore : public QObject {
|
|
Q_OBJECT
|
|
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
|
|
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
|
|
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
|
|
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
|
|
Q_PROPERTY(QString model READ model NOTIFY configChanged)
|
|
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
|
|
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
|
|
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
|
|
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
|
|
|
|
public:
|
|
explicit AgentCore(QObject *parent = nullptr);
|
|
~AgentCore() override;
|
|
|
|
/* Start the engine on a workspace. provider/model/apiKey may be empty to
|
|
* fall back to the saved credentials and the provider's default model. */
|
|
bool open(const QString &workspace, const QString &provider,
|
|
const QString &model, const QString &apiKey, QString *err);
|
|
void close();
|
|
|
|
bool running() const { return m_running; }
|
|
QString workspace() const { return m_workspace; }
|
|
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
|
|
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
|
|
QString model() const { return QString::fromUtf8(m_cfg.model); }
|
|
int approval() const { return m_cfg.approval; }
|
|
bool planMode() const { return m_cfg.plan_mode != 0; }
|
|
QString todo() const { return m_todo; }
|
|
QString sessionId() const;
|
|
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
|
* message looks the same here as it does in the WebUI. */
|
|
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
|
|
|
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
|
|
|
|
/* Stored sessions, newest first; one map each: id, title (a rename, else
|
|
* empty), firstUser, messages, mtime (ms since the epoch), current. */
|
|
Q_INVOKABLE QVariantList sessions() const;
|
|
/* The open session as transcript rows, in exactly the shape the live
|
|
* signals build (kind, role, text, name, state, detail, output, exitCode,
|
|
* complete), so a reopened session renders like the turn that wrote it. */
|
|
Q_INVOKABLE QVariantList history() const;
|
|
/* None of these run during a turn; all return false when one does. */
|
|
Q_INVOKABLE bool newSession();
|
|
Q_INVOKABLE bool switchSession(const QString &id);
|
|
Q_INVOKABLE bool deleteSession(const QString &id);
|
|
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
|
|
|
|
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
|
|
|
|
/* The presets: name, base, model (remembered, else the preset's), hasKey
|
|
* and keyFrom ("saved", else the environment variable), keyEnv, source and
|
|
* current. Never a key: the engine keeps those out of the UI's reach. */
|
|
Q_INVOKABLE QVariantList providers() const;
|
|
/* Model candidates for a provider: its own list, what was fetched into the
|
|
* cache, and the remembered pick. No network. */
|
|
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
|
|
/* "1M · vision" for the model picker, "" when nothing is known. */
|
|
Q_INVOKABLE QString modelInfo(const QString &model) const;
|
|
/* Switch provider, model and key in one call. The engine's own config moves
|
|
* (its session does not), the choice is remembered, and an empty return
|
|
* means it worked; anything else is a reason to show. A key is verified
|
|
* against the provider's /models when one is given. */
|
|
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
|
|
const QString &key);
|
|
/* Tokens of this session and of the last turn, plus the cost when priced. */
|
|
Q_INVOKABLE QVariantMap usage() const;
|
|
|
|
/* --- attachments (see cst_attach.h) ------------------------------------- */
|
|
|
|
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
|
|
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
|
|
QStringList pendingAttachments() const;
|
|
/* Copy a file next to the session and give it to the next prompt. "" when
|
|
* it worked, else why not. */
|
|
Q_INVOKABLE QString attachFile(const QString &path);
|
|
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
|
|
* reason -- "no image on the clipboard" -- when the editor should have the
|
|
* keystroke instead. */
|
|
Q_INVOKABLE QString attachClipboard();
|
|
/* Whether Ctrl+V should become an attachment at all (an image is on the
|
|
* clipboard) or stay with the editor (text). */
|
|
Q_INVOKABLE bool clipboardHasImage() const;
|
|
Q_INVOKABLE void clearPending();
|
|
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
|
|
Q_INVOKABLE int seesImages() const;
|
|
|
|
/* The engine, for the parts that need it (tool registration). */
|
|
cst_agent_t *agent() const { return m_agent; }
|
|
const cst_agent_config_t *config() const { return &m_cfg; }
|
|
|
|
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
|
Q_INVOKABLE void send(const QString &prompt);
|
|
Q_INVOKABLE void cancel();
|
|
|
|
/* Consent: answers map to CST_PERM_*. */
|
|
Q_INVOKABLE void answerPermission(int choice);
|
|
Q_INVOKABLE void answerQuestion(const QString &answer);
|
|
|
|
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
|
|
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
|
|
Q_INVOKABLE void setPlanMode(bool on);
|
|
|
|
/* Tools the front end implements itself. The engine copies the definition
|
|
* (strings included), so a stack value is fine; ud must outlive it.
|
|
* owner is what unregisterOwner() later matches. */
|
|
bool registerTool(const cst_tool_def_t *def);
|
|
void unregisterOwner(const QString &owner);
|
|
/* Re-probe platform tools after a service appeared or went away. */
|
|
void reloadToolMeta() { cst_tool_meta_reload(); }
|
|
|
|
/* Answer every consent question with "once" (the C smoke test, headless
|
|
* runs, a --yes flag). */
|
|
void setAutoApprove(bool on) { m_autoApprove = on; }
|
|
|
|
signals:
|
|
void configChanged();
|
|
void approvalChanged();
|
|
void planModeChanged();
|
|
void sessionChanged(); /* another session is open now */
|
|
void sessionListChanged(); /* the store gained, lost or renamed one */
|
|
void pendingChanged(); /* attachments for the next prompt changed */
|
|
void statusChanged(const QString &text);
|
|
void assistantDelta(const QString &text);
|
|
void thinkDelta(const QString &text);
|
|
void toolEvent(const QString &phase, const QString &name,
|
|
const QString &detail);
|
|
void toolResult(const QString &name, int exitCode, const QString &output);
|
|
void todoChanged(const QString &text);
|
|
void permissionRequested(const QString &tool, const QString &detail,
|
|
const QString &preview);
|
|
void questionAsked(const QString &question, const QString &options);
|
|
void turnFinished(int rc, const QString &err);
|
|
void runningChanged();
|
|
void workspaceChanged();
|
|
void modeChanged();
|
|
|
|
private:
|
|
friend class Turn;
|
|
void finishTurn(int rc, const QString &err);
|
|
void setRunning(bool on);
|
|
/* Take the engine's config over m_cfg again (a switch changed it) and say
|
|
* so. */
|
|
void syncConfig();
|
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
|
void afterSessionChange();
|
|
|
|
/* Hook trampolines (C, run on the worker thread). */
|
|
static void hookStatus(const char *msg, void *ud);
|
|
static void hookAssistant(const char *text, size_t len, void *ud);
|
|
static void hookThink(const char *text, size_t len, void *ud);
|
|
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
|
const char *detail, void *ud);
|
|
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
|
|
size_t len, int exit_code, void *ud);
|
|
static void hookTodo(const char *text, void *ud);
|
|
static int hookPermission(const char *tool, const char *detail,
|
|
const char *preview, void *ud);
|
|
static int hookAskUser(const char *question, const char *options, char *ans,
|
|
size_t ans_sz, void *ud);
|
|
/* Post a lambda onto the GUI thread. */
|
|
template <typename F>
|
|
void post(F f) {
|
|
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
|
|
}
|
|
|
|
cst_agent_t *m_agent = nullptr;
|
|
cst_agent_config_t m_cfg;
|
|
cst_agent_hooks_t m_hooks;
|
|
QString m_workspace;
|
|
QThread *m_turn = nullptr;
|
|
bool m_running = false;
|
|
bool m_closing = false;
|
|
|
|
/* consent handshake between the worker and the GUI thread */
|
|
QMutex m_askMu;
|
|
QWaitCondition m_askCv;
|
|
int m_permAnswer = CST_PERM_DENY;
|
|
bool m_permPending = false;
|
|
QString m_question;
|
|
QString m_todo;
|
|
/* Names of the files this window attached, in the engine's pending order:
|
|
* the engine's own description says what it is, not where it came from. */
|
|
QStringList m_attachNames;
|
|
QByteArray m_answer;
|
|
bool m_questionPending = false;
|
|
bool m_autoApprove = false;
|
|
};
|
|
|
|
#endif /* CST_APP_AGENTCORE_H */
|