From 042d7f6999205012ec862ee96684ae136ecaba91 Mon Sep 17 00:00:00 2001 From: WHC Date: Sun, 11 Oct 2026 19:26:36 +0800 Subject: [PATCH] v0.9.7: a turn that cannot hang, a session list that reads nothing twice, and PDFs - consent: on_permission / on_ask_user wait at most two / five minutes, and in the background (a phone off screen, a window that is gone) the answer is refused at once with a reason -- a dialog nobody can see blocked the worker for good, which is what "the app hangs while the agent works" looked like. - finishTurn() no longer joins the thread it is called from; the event loop reaps the Turn instead. - AndroidTools: the hop to the GUI thread pings first and gives up after 3 s instead of waiting forever on a busy GUI thread. - the session list stopped reading every transcript: cst_agent_store_info() counts newlines in 64 KiB blocks (first_user only within the first 256 KiB), and AgentCore::sessions() reuses a row while (mtime, size) is unchanged. - session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines over 64 KiB, and says when the budget stopped the walk. - the header shows the phase ("shell: make -j8"), so a long turn no longer looks like a stuck one. - md_to_pdf: the front end's own Qt tool (owner app:qt, every platform): markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins, paginated by hand, written into the workspace and delivered as a file card. No PrintSupport, so the phone gets it too. - --uitest runs md_to_pdf through the registry with Chinese text and checks the bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the standalone engine build, which the window needs to render markdown. Version 0.9.7. --- README.md | 3 +- app/CMakeLists.txt | 7 +- app/README.md | 20 ++ app/qml/Main.qml | 6 + app/src/core/AgentCore.cpp | 160 +++++++++-- app/src/core/AgentCore.h | 19 ++ app/src/core/PdfTools.cpp | 315 ++++++++++++++++++++++ app/src/core/ToolProvider.cpp | 3 + app/src/core/ToolProvider.h | 4 + app/src/platform/android/AndroidTools.cpp | 26 +- app/src/ui/UiTest.cpp | 47 ++++ include/cst_version.h | 2 +- src/agent_store.c | 92 ++++--- src/tools/registry.c | 7 +- src/tools/session_tools.c | 55 +++- 15 files changed, 699 insertions(+), 67 deletions(-) create mode 100644 app/src/core/PdfTools.cpp diff --git a/README.md b/README.md index a8b5d80..9f256e9 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,8 @@ cst-agent --headless --prompt 'List files' | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | | `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 | | `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 | -| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) | +| `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 | +| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 | | `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) | 插件工具和 MCP 工具(`mcp____`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 9482359..46e2a12 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -43,8 +43,10 @@ if(NOT TARGET cstcore) list(REMOVE_ITEM CST_APP_CORE_SRC "${CST_APP_ROOT}/src/net.c" "${CST_APP_ROOT}/src/http_srv.c" - "${CST_APP_ROOT}/src/webui.c" - "${CST_APP_ROOT}/src/web_md.c") + "${CST_APP_ROOT}/src/webui.c") + # web_md.c stays in: the window renders markdown with it (AgentCore:: + # renderMarkdown) and the md_to_html tool needs it. Only the HTTP server + # is left out. if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc") include_directories("${CST_APP_ROOT}/.gen") endif() @@ -100,6 +102,7 @@ if(CST_APP_GUI) src/main.cpp src/core/AgentCore.cpp src/core/ToolProvider.cpp + src/core/PdfTools.cpp src/ui/UiTest.cpp src/ui/ThemeIcons.cpp src/ui/SessionImages.cpp diff --git a/app/README.md b/app/README.md index 478e54e..50cdde9 100644 --- a/app/README.md +++ b/app/README.md @@ -296,6 +296,26 @@ and two buttons. buttons, clicks Open (the header must say `opened report.md`), clicks Share and checks a name the session does not own is refused. +### Making one: `md_to_pdf` + +The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`, +owner `app:qt`, `platforms = NULL` so every platform the window runs on gets +it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm +margins in the workspace, and goes through the same +`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a +file card the person can open, print or share, and its bytes never enter a +request. The markdown is rendered by the engine (`cst_web_md_document()`, the +same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out +(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here, +page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a +phone has no print subsystem. The tool runs on the turn's worker thread, and +`--uitest` runs it through the registry and checks the file starts with +`%PDF-`. + +The card row on the phone is the same one a picture uses: the store keeps the +PDF next to the session, the image provider refuses it (it is not an image), +and the file card carries it. + ## Transcript, tool calls, streaming text - A tool call is a card of its own, and the call **id** is what pairs it with diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 5a5c20f..0fe8f60 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -650,6 +650,12 @@ ApplicationWindow { ask.open() } + /* Nobody answered (the app was in the background, or the dialog sat + * there too long): the turn went on without it, so put the dialog + * away instead of leaving a question that is no longer being asked. */ + function onPermissionExpired(tool) { consent.close() } + function onQuestionExpired() { ask.close() } + function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) } function onRunningChanged() { diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 129f691..89d8216 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -189,6 +189,12 @@ void AgentCore::close() { /* --- turns --------------------------------------------------------------- */ namespace { +/* How long the worker waits for a person. Long enough to read a file diff and + * decide, short enough that a missed dialog (or a screen that went dark) does + * not leave the turn waiting for good. */ +constexpr int kConsentTimeoutMs = 2 * 60 * 1000; +constexpr int kQuestionTimeoutMs = 5 * 60 * 1000; + /* The first line of a prompt, cut short: the line the phone's "working" * notification carries while the turn runs (app::holdForeground). */ QString firstLine(const QString &s) { @@ -224,9 +230,28 @@ void AgentCore::cancel() { cst_agent_request_cancel(m_agent); } +/* Qt suspends the application when the phone puts it away, and a desktop + * window can be gone too (--uitest runs headless). Either way a consent + * dialog would be invisible. */ +bool AgentCore::backgrounded() const { + QGuiApplication *gui = + qobject_cast(QCoreApplication::instance()); + if (!gui) + return false; + if (gui->applicationState() == Qt::ApplicationSuspended) + return true; + return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty(); +} + void AgentCore::finishTurn(int rc, const QString &err) { Turn *t = static_cast(sender()); + if (t) { + /* run() is the caller: waiting for the thread here would be waiting for + * this very call to return. Let the event loop reap it instead. */ + connect(t, &QThread::finished, t, &QObject::deleteLater); + } post([this, rc, err] { + m_turn = nullptr; setRunning(false); app::releaseForeground(); /* the turn is over: let the phone sleep again */ /* The prompt carried whatever was pending; the engine dropped it. */ @@ -234,11 +259,6 @@ void AgentCore::finishTurn(int rc, const QString &err) { emit pendingChanged(); emit turnFinished(rc, err); }); - if (t) { - t->wait(); - t->deleteLater(); - } - m_turn = nullptr; } void AgentCore::setRunning(bool on) { @@ -275,16 +295,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail, return CST_PERM_DENY; if (self->m_autoApprove) return CST_PERM_ONCE; + const QString t = QString::fromUtf8(tool ? tool : ""); + /* The turn survives the app leaving the screen (v0.9.4), so a question + * asked there would block the worker on a dialog nobody can see -- which is + * what "the app hangs while the agent works" looks like. In the background + * the answer is no, with a line the person finds in the transcript. */ + if (self->backgrounded()) { + self->post([self, t] { + emit self->permissionExpired(t); + emit self->statusChanged( + QStringLiteral("%1: refused — the app was in the background").arg(t)); + }); + return CST_PERM_DENY; + } QMutexLocker lock(&self->m_askMu); self->m_permAnswer = CST_PERM_DENY; self->m_permPending = true; - const QString t = QString::fromUtf8(tool ? tool : ""); const QString d = QString::fromUtf8(detail ? detail : ""); const QString p = QString::fromUtf8(preview ? preview : ""); self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); }); - while (self->m_permPending && !self->m_closing) - self->m_askCv.wait(&self->m_askMu); + /* And even in the foreground nobody may hang forever: a dialog can be + * missed, a screen can go dark. Two minutes, then the answer is no. */ + bool expired = false; + while (self->m_permPending && !self->m_closing) { + if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) { + expired = true; + break; + } + } self->m_permPending = false; + if (expired) { + self->post([self, t] { + emit self->permissionExpired(t); + emit self->statusChanged( + QStringLiteral("%1: no answer for two minutes — refused").arg(t)); + }); + return CST_PERM_DENY; + } return self->m_permAnswer; } @@ -295,16 +342,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans, return -1; if (self->m_autoApprove) return -1; + if (self->backgrounded()) { + self->post([self] { + emit self->questionExpired(); + emit self->statusChanged( + QStringLiteral("the question was dropped — the app was in the " + "background")); + }); + return -1; + } QMutexLocker lock(&self->m_askMu); self->m_questionPending = true; self->m_answer.clear(); const QString q = QString::fromUtf8(question ? question : ""); const QString o = QString::fromUtf8(options ? options : ""); self->post([self, q, o] { emit self->questionAsked(q, o); }); - while (self->m_questionPending && !self->m_closing) - self->m_askCv.wait(&self->m_askMu); + bool expired = false; + while (self->m_questionPending && !self->m_closing) { + if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) { + expired = true; + break; + } + } if (self->m_closing) return -1; + self->m_questionPending = false; + if (expired) { + self->post([self] { + emit self->questionExpired(); + emit self->statusChanged( + QStringLiteral("no answer for five minutes — the question was " + "dropped")); + }); + return -1; + } snprintf(ans, ans_sz, "%s", self->m_answer.constData()); return 0; } @@ -530,29 +601,56 @@ QString AgentCore::shareArtifact(const QString &rel) { QVariantList AgentCore::sessions() const { QVariantList out; - if (m_workspace.isEmpty()) + if (m_workspace.isEmpty()) { + m_sessionCache.clear(); return out; + } const QByteArray ws = m_workspace.toUtf8(); const int kMax = 200; char(*ids)[128] = new char[kMax][128]; const int n = cst_agent_store_list(ws.constData(), ids, kMax); const QString current = sessionId(); + QHash fresh; + fresh.reserve(n); for (int i = 0; i < n; i++) { - cst_session_info_t in; - memset(&in, 0, sizeof(in)); - if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0) - continue; - const QString id = utf8(in.id[0] ? in.id : ids[i]); - QVariantMap m; - m["id"] = id; - m["title"] = utf8(in.title); - m["firstUser"] = utf8(in.first_user); - m["messages"] = in.messages; - m["mtime"] = static_cast(in.mtime) * 1000; /* ms, for JS Date */ + const QString id = utf8(ids[i]); + /* Filling one row walks that session's whole transcript, and the sidebar + * asks for the list on every store change: the transcript's own stat is + * what tells "nothing happened to this one" from "it grew", so an + * unchanged session costs a stat instead of a read. */ + char path[4096] = ""; + const bool have_path = cst_agent_store_path_for_id( + ws.constData(), ids[i], path, sizeof(path)) == 0; + const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString()); + const qint64 mtime = fi.lastModified().toSecsSinceEpoch(); + const qint64 size = fi.size(); + SessionCache entry; + const auto cached = m_sessionCache.constFind(id); + if (cached != m_sessionCache.constEnd() && cached->mtime == mtime && + cached->size == size) { + entry = *cached; + } else { + cst_session_info_t in; + memset(&in, 0, sizeof(in)); + if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0) + continue; + entry.mtime = mtime; + entry.size = size; + QVariantMap m; + m["id"] = id; + m["title"] = utf8(in.title); + m["firstUser"] = utf8(in.first_user); + m["messages"] = in.messages; + m["mtime"] = static_cast(in.mtime) * 1000; /* ms, for JS Date */ + entry.row = m; + } + QVariantMap m = entry.row; m["current"] = (id == current); out.append(m); + fresh.insert(id, entry); } delete[] ids; + m_sessionCache = fresh; /* and the ids that are gone are forgotten */ return out; } @@ -710,6 +808,9 @@ bool AgentCore::deleteSession(const QString &id) { bool AgentCore::renameSession(const QString &id, const QString &title) { if (id.isEmpty() || m_workspace.isEmpty()) return false; + /* A title is not in the transcript, so the cached rows would not notice the + * rename: drop them and let the next list read them again. */ + m_sessionCache.clear(); const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(), t = title.toUtf8(); if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) != @@ -1138,7 +1239,20 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call, const QString id = QString::fromUtf8(call && call->id ? call->id : ""); const QString n = QString::fromUtf8(call ? call->name : ""); const QString d = QString::fromUtf8(detail ? detail : ""); - self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); }); + self->post([self, p, id, n, d] { + emit self->toolEvent(p, id, n, d); + /* The header says what the turn is doing right now ("shell: make -j8"), + * not just that something is happening: a long turn is otherwise + * indistinguishable from a stuck one. */ + if (p == QLatin1String("done")) { + emit self->statusChanged(QString()); + return; + } + QString what = d; + if (what.size() > 80) + what = what.left(79) + QStringLiteral("…"); + emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what)); + }); } void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out, diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index 49fd5b4..763f68c 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -9,6 +9,7 @@ #define CST_APP_AGENTCORE_H #include +#include #include #include #include @@ -204,6 +205,11 @@ class AgentCore : public QObject { void permissionRequested(const QString &tool, const QString &detail, const QString &preview); void questionAsked(const QString &question, const QString &options); + /* The worker asked and nobody answered in time (or the app was in the + * background, where the dialog cannot be seen): the turn went on with a + * refusal, and the window puts its dialog away again. */ + void permissionExpired(const QString &tool); + void questionExpired(); void turnFinished(int rc, const QString &err); void runningChanged(); void workspaceChanged(); @@ -218,6 +224,10 @@ class AgentCore : public QObject { void syncConfig(); /* Re-read what a session switch changed (id, todo) and say so. */ void afterSessionChange(); + /* True when the app is not on screen: a phone in the background (Qt + * suspends it), or a window that is not there at all. A dialog opened now + * would be invisible, so consent is refused instead of waited for. */ + bool backgrounded() const; /* Hook trampolines (C, run on the worker thread). */ static void hookStatus(const char *msg, void *ud); @@ -255,6 +265,15 @@ class AgentCore : public QObject { bool m_permPending = false; QString m_question; QString m_todo; + /* The session list rebuilt only when a transcript moved: filling one row + * walks the whole file, and the sidebar asks for the list on every store + * change. Keyed by id, dropped when the id is gone. */ + struct SessionCache { + qint64 mtime = 0; + qint64 size = 0; + QVariantMap row; + }; + mutable QHash m_sessionCache; /* 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; diff --git a/app/src/core/PdfTools.cpp b/app/src/core/PdfTools.cpp new file mode 100644 index 0000000..8015417 --- /dev/null +++ b/app/src/core/PdfTools.cpp @@ -0,0 +1,315 @@ +/* The window's own tools, in Qt rather than in the engine: a document the + * person asked for, laid out as a PDF. + * + * Why here and not in the C engine: laying text out on pages is exactly what + * Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already + * links), while the engine has no layout code at all -- and md_to_html, its + * sibling, lives in the engine because rendering markdown is a shared + * capability. This one is a front-end capability: it is registered under + * owner "app:qt" and is available on every platform the app runs on. + * + * The path of a document is the one md_to_html walks: markdown (or a + * workspace .md/.html file) -> cst_web_md_document() -> a file inside the + * workspace -> cst_attach_import_artifact(), which is what turns it into a + * file card the person can open or share. The bytes never reach the model. + * + * Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only + * module (a phone has no print subsystem), and a round of + * pageSize + translate + drawContents per page is all a paginated PDF needs. + * The tool runs on the turn's worker thread, so a long document does not + * freeze the window. */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "core/AgentCore.h" +#include "core/ToolProvider.h" +#include "cst_agent.h" +#include "cst_attach.h" +#include "cst_tools.h" +#include "cst_web_md.h" + +namespace app { +namespace { + +/* md_to_html caps its input the same way: a document nobody could read is + * not worth laying out. */ +constexpr int kMaxInputBytes = 8 * 1024 * 1024; +/* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the + * default 300 (a 40-page document is laid out and written in a moment). */ +constexpr int kPdfDpi = 150; + +const char *argOf(const ctl_tool_call_t *call, const char *key) { + for (int i = 0; i < call->nargs; i++) + if (strcmp(call->args[i].key, key) == 0) + return call->args[i].value; + return nullptr; +} + +int setOut(cst_tool_result_t *r, const QString &text, int rc) { + const QByteArray b = text.toUtf8(); + r->out = strdup(b.constData()); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = rc; + return rc; +} + +/* "docs/notes.md" -> "notes". */ +QString stemOf(const QString &path) { + QString base = path; + const int slash = qMax(base.lastIndexOf(QLatin1Char('/')), + base.lastIndexOf(QLatin1Char('\\'))); + if (slash >= 0) + base = base.mid(slash + 1); + const int dot = base.lastIndexOf(QLatin1Char('.')); + if (dot > 0) + base = base.left(dot); + return base.isEmpty() ? QStringLiteral("document") : base; +} + +/* What the file is called: `out` when given (a directory takes the source's + * name inside it), otherwise the source's stem, or the title. */ +QString outPathOf(const ctl_tool_call_t *c, const QString &stem) { + const char *out = argOf(c, "out"); + QString name = out && out[0] ? QString::fromUtf8(out) : QString(); + if (name.isEmpty()) + return stem + QStringLiteral(".pdf"); + if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\'))) + return name + stem + QStringLiteral(".pdf"); + if (!QFileInfo(name).fileName().contains(QLatin1Char('.'))) + name += QStringLiteral(".pdf"); + return name; +} + +/* The HTML that goes into the page: an .html file is already a document, a + * .md file (or the `markdown` argument) goes through the engine's renderer, + * which is the same one the WebUI and md_to_html use. */ +bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + QString *html, QString *out_name, QString *err) { + const char *md = argOf(c, "markdown"); + const char *file = argOf(c, "file"); + const char *given = argOf(c, "title"); + QString title = given && given[0] ? QString::fromUtf8(given) : QString(); + QByteArray text; + QString stem; + if (file && file[0]) { + char rel[4096] = ""; + if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) { + *err = QStringLiteral("error: path outside the workspace or missing " + "parent: %1") + .arg(QString::fromUtf8(file)); + return false; + } + QFile in(QString::fromUtf8(rel)); + if (!in.open(QIODevice::ReadOnly)) { + *err = QStringLiteral("error: cannot read %1") + .arg(QString::fromUtf8(file)); + return false; + } + if (in.size() > kMaxInputBytes) { + *err = QStringLiteral("error: %1 is larger than %2 MB") + .arg(QString::fromUtf8(file)) + .arg(kMaxInputBytes >> 20); + return false; + } + text = in.readAll(); + stem = stemOf(QString::fromUtf8(file)); + const QString lower = QString::fromUtf8(file).toLower(); + if (lower.endsWith(QLatin1String(".html")) || + lower.endsWith(QLatin1String(".htm"))) { + /* already a document: what md_to_html wrote, or anything else */ + *html = QString::fromUtf8(text); + if (title.isEmpty()) + title = stem; + *out_name = outPathOf(c, stem); + return true; + } + } else if (md && md[0]) { + text = QByteArray(md); + if (stem.isEmpty()) + stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title); + } else { + *err = QStringLiteral("error: give markdown, or a workspace .md or .html " + "file to render"); + return false; + } + if (title.isEmpty()) + title = stem; + char *doc = + cst_web_md_document(text.constData(), static_cast(text.size()), + title.toUtf8().constData()); + if (!doc) { + *err = QStringLiteral("error: cannot render the markdown"); + return false; + } + *html = QString::fromUtf8(doc); + free(doc); + *out_name = outPathOf(c, stem); + return true; +} + +/* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect, + * translated down per page, and the document is asked for exactly that much + * text at a time -- which is what makes Qt lay out pages instead of one long + * strip. */ +bool writePdf(const QString &html, const QString &path, const QString &title, + QString *err) { + QTextDocument doc; + doc.setHtml(html); + if (!title.isEmpty()) + doc.setMetaInformation(QTextDocument::DocumentTitle, title); + + QPdfWriter writer(path); + writer.setPageSize(QPageSize(QPageSize::A4)); + writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter); + writer.setResolution(kPdfDpi); + if (!title.isEmpty()) + writer.setTitle(title); + + QPainter painter(&writer); + if (!painter.isActive()) { + *err = QStringLiteral("error: cannot draw into %1").arg(path); + return false; + } + const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution()); + doc.setPageSize(page.size()); + const int pages = qMax(1, doc.pageCount()); + for (int i = 0; i < pages; i++) { + if (i) + writer.newPage(); + painter.save(); + painter.translate(page.topLeft()); + painter.translate(0, -i * page.height()); + doc.drawContents(&painter, + QRectF(0, 0, page.width(), page.height()) + .translated(0, i * page.height())); + painter.restore(); + } + painter.end(); + /* The painter says nothing about the file: the only proof the PDF landed is + * that there is one with bytes in it. */ + if (QFileInfo(path).size() <= 0) { + *err = QStringLiteral("error: nothing was written to %1").arg(path); + return false; + } + return true; +} + +int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + char **out) { + (void)ctx; + const char *md = argOf(c, "markdown"); + const char *file = argOf(c, "file"); + if ((!md || !md[0]) && (!file || !file[0])) { + *out = strdup("error: give markdown, or a .md or .html file to render"); + return -1; + } + const QString stem = + file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document"); + const QString name = outPathOf(c, stem); + QByteArray line; + if (file && file[0]) + line = QStringLiteral("lay out %1 → %2 as a PDF\n") + .arg(QString::fromUtf8(file), name) + .toUtf8(); + else + line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n") + .arg(md ? qstrlen(md) : 0) + .arg(name) + .toUtf8(); + *out = strdup(line.constData()); + return 0; +} + +int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r, void *ud) { + (void)ud; + QString html, out_name, err; + if (!sourceHtml(ctx, c, &html, &out_name, &err)) + return setOut(r, err, 1); + + char full[4096] = ""; + if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(), + full, sizeof(full)) != 0) + return setOut(r, QStringLiteral("error: path outside the workspace: %1") + .arg(out_name), + 1); + const QString target = QString::fromUtf8(full); + /* A directory in the way of the name is what the engine's write tools call + * "missing parent": make it, the same way md_to_html does. */ + QDir().mkpath(QFileInfo(target).absolutePath()); + + const char *given = argOf(c, "title"); + const QString title = given && given[0] ? QString::fromUtf8(given) : QString(); + if (!writePdf(html, target, title, &err)) + return setOut(r, err, 1); + + const qint64 bytes = QFileInfo(target).size(); + const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr; + cst_attachment_t *a = nullptr; + char aerr[256] = ""; + if (session && session[0]) + a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr)); + + QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n") + .arg(out_name) + .arg(bytes / 1024); + if (a) + text += QStringLiteral("(the user sees it as a file card they can open or " + "share; you will not be shown its bytes again)\n"); + else if (aerr[0]) + text += QStringLiteral("(the file is in the workspace; handing it over " + "failed: %1)\n") + .arg(QString::fromUtf8(aerr)); + const int rc = setOut(r, text, 0); + if (a) { + r->attach = a; + r->nattach = 1; + } + return rc; +} + +} // namespace + +int registerQtTools(AgentCore *core) { + if (!core) + return 0; + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"markdown", "string", 0, "markdown text to render"}, + {"file", "string", 0, + "workspace .md file to render, or an .html one to lay out as it is"}, + {"out", "string", 0, + "output path in the workspace (default: the source's name + .pdf)"}, + {"title", "string", 0, "document title (default: the file name)"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + static cst_tool_def_t def; + memset(&def, 0, sizeof(def)); + setMeta(&def, "md_to_pdf", + "Write a document as a PDF: markdown (or a workspace .md/.html " + "file) in, a laid-out A4 PDF out, saved in the workspace and " + "handed to the person as a card they can open, print or share. " + "Use it when the answer should be something to keep or send.", + CST_TP_WRITE, "pdf", nullptr, nullptr); + memcpy(def.params, params, sizeof(params)); + def.run_ex = mdToPdfRun; + def.preview = mdToPdfPreview; + def.owner = "app:qt"; + return core->registerTool(&def) ? 1 : 0; +} + +} // namespace app diff --git a/app/src/core/ToolProvider.cpp b/app/src/core/ToolProvider.cpp index 7ab53ed..be5381c 100644 --- a/app/src/core/ToolProvider.cpp +++ b/app/src/core/ToolProvider.cpp @@ -22,6 +22,8 @@ int registerPlatformTools(AgentCore *core) { #elif defined(__linux__) n += platformRegisterLinux(core); #endif + /* Qt is not a platform API: these exist wherever the window does. */ + n += registerQtTools(core); /* A service may have appeared or gone since the last probe (a desktop * session starting, the notification daemon coming up). */ cst_tool_meta_reload(); @@ -35,6 +37,7 @@ void unregisterPlatformTools(AgentCore *core) { core->unregisterOwner(QStringLiteral("app:windows")); core->unregisterOwner(QStringLiteral("app:macos")); core->unregisterOwner(QStringLiteral("app:android")); + core->unregisterOwner(QStringLiteral("app:qt")); } } // namespace app diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h index ab2f23d..08a6f1f 100644 --- a/app/src/core/ToolProvider.h +++ b/app/src/core/ToolProvider.h @@ -44,6 +44,10 @@ extern const char *const kDesktopOnly[]; /* The platform tools this app provides. Called once the engine exists; * returns how many were registered. */ int registerPlatformTools(AgentCore *core); +/* The tools this front end provides that are the same on every platform + * (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt". + * Called next to registerPlatformTools(). */ +int registerQtTools(AgentCore *core); /* Drop them again (they belong to the owners "app:*"). */ void unregisterPlatformTools(AgentCore *core); diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp index 716fafa..96c109b 100644 --- a/app/src/platform/android/AndroidTools.cpp +++ b/app/src/platform/android/AndroidTools.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +37,7 @@ #include #include +#include #include #include "core/AgentCore.h" @@ -59,16 +61,34 @@ void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) { r->exit_code = rc; } +/* How long the worker is willing to wait for the GUI thread to pick its work + * up. The hop itself is always waited for to the end (the closure writes into + * the caller's stack), so what this bounds is the case that makes a turn look + * stuck: a GUI thread that is not getting to the event loop at all. */ +constexpr int kGuiStallMs = 3000; + /* Anything that touches a Qt GUI object (the clipboard) or an Activity has to - * run on the GUI thread; the turn's worker thread waits for it. */ + * run on the GUI thread; the turn's worker thread waits for it. + * + * The wait is bounded: a blocking queued call to a GUI thread that is busy + * (the session list reading transcripts used to do exactly that) would park + * the worker for as long as it lasts. A cheap ping first tells whether the + * GUI thread is there at all; only then is the real work handed over and + * waited for, which keeps the closure valid for its whole life. */ template -void onGuiThread(Fn &&fn) { +bool onGuiThread(Fn &&fn) { if (!qApp || QThread::currentThread() == qApp->thread()) { fn(); - return; + return true; } + auto pinged = std::make_shared(0); + QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); }, + Qt::QueuedConnection); + if (!pinged->tryAcquire(1, kGuiStallMs)) + return false; /* a GUI thread that is not answering: do not wait on it */ QMetaObject::invokeMethod(qApp, std::forward(fn), Qt::BlockingQueuedConnection); + return true; } jint sdkInt() { diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index c66d1b6..5349969 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -416,6 +416,53 @@ int runUiTest(AgentCore &core, const QString &size) { QStringLiteral("and the line is where the card is")); } + /* --- md_to_pdf: the front end's own document writer ------------------- */ + { + const cst_tool_def_t *def = cst_tool_find("md_to_pdf"); + check(def != nullptr && def->owner != nullptr && + strncmp(def->owner, "app:qt", 6) == 0, + QStringLiteral("md_to_pdf is registered by the front end")); + if (def && def->run_ex) { + /* Run it the way the agent's turn would: through the registry, with a + * call and the engine's context. */ + const QByteArray ws = core.workspace().toUtf8(); + cst_tool_ctx_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.workspace_root = ws.constData(); + ctx.agent = core.agent(); + ctl_tool_call_t call; + memset(&call, 0, sizeof(call)); + snprintf(call.id, sizeof(call.id), "pdf1"); + snprintf(call.name, sizeof(call.name), "md_to_pdf"); + static const char *kMd = + "# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n" + "- 项目一\n- 项目二\n\n```\ncode block\n```\n"; + static const char *kOut = "selftest.pdf"; + snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown"); + call.args[0].value = const_cast(kMd); + call.args[0].value_len = strlen(kMd); + snprintf(call.args[1].key, sizeof(call.args[1].key), "out"); + call.args[1].value = const_cast(kOut); + call.args[1].value_len = strlen(kOut); + call.nargs = 2; + cst_tool_result_t res; + memset(&res, 0, sizeof(res)); + const int rc = cst_tool_run(&ctx, &call, &res); + check(res.exit_code == 0, + QStringLiteral("md_to_pdf writes the document (%1)") + .arg(QString::fromUtf8(res.out ? res.out : ""))); + check(rc == 0 && res.nattach == 1, + QStringLiteral("and hands it over as a file for the user")); + QFile f(core.workspace() + QStringLiteral("/selftest.pdf")); + const bool opened = f.open(QIODevice::ReadOnly); + const QByteArray head = opened ? f.read(5) : QByteArray(); + check(head == QByteArrayLiteral("%PDF-"), + QStringLiteral("the bytes are a PDF (%1)") + .arg(QString::fromLatin1(head))); + cst_tool_result_free(&res); + } + } + /* new sessions: the engine moves, the view empties, the store grows */ const int before = core.sessions().size(); clickMouse(win, QStringLiteral("newSessionButton")); diff --git a/include/cst_version.h b/include/cst_version.h index 780f391..fccf7ba 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 6 +#define CST_VERSION_PATCH 7 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/agent_store.c b/src/agent_store.c index a199ba8..6c42f7b 100644 --- a/src/agent_store.c +++ b/src/agent_store.c @@ -444,38 +444,68 @@ int cst_agent_store_info(const char *workspace, const char *id, FILE *fp = fopen(p, "rb"); if (!fp) return 0; - size_t cap = 1 << 16; - char *line = malloc(cap); - int have_first = 0; - while (line && fgets(line, (int)cap, fp)) { - size_t n = strlen(line); - if (n == cap - 1 && line[n - 1] != '\n') { - /* very long line: skip the rest, it is not the first user line */ - int ch; - while ((ch = fgetc(fp)) != EOF && ch != '\n') - ; - info->messages++; - continue; - } - if (n <= 1) - continue; - info->messages++; - if (have_first) - continue; - cJSON *o = cJSON_Parse(line); - const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role"); - const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content"); - const cJSON *k = cJSON_GetObjectItemCaseSensitive( - cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind"); - int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0; - if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note && - cJSON_IsString(c) && c->valuestring[0]) { - clip_line(info->first_user, sizeof(info->first_user), c->valuestring); - have_first = 1; - } - cJSON_Delete(o); + /* Reading a transcript must stay cheap: the session list asks for this for + * every stored session, on the GUI thread, every time the store changes. + * So a line is only ever *parsed* while the first user message is still + * missing and only within the first kFirstUserBytes; everything else is + * counted by looking for newlines, which costs a memchr per block instead + * of a cJSON tree per line. */ + enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 }; + char *buf = malloc(kBlock); + if (!buf) { + fclose(fp); + return 0; } - free(line); + size_t have = 0; /* bytes in buf that belong to the current line */ + size_t seen = 0; /* bytes already consumed by finished lines */ + int slice = 0; /* the current line did not fit: its tail is dropped */ + int want_first = 1; /* still looking for the first user message */ + for (;;) { + size_t got = fread(buf + have, 1, kBlock - have, fp); + size_t total = have + got; + size_t at = 0; + for (;;) { + char *nl = memchr(buf + at, '\n', total - at); + if (!nl) + break; + const size_t len = (size_t)(nl - (buf + at)); + if (len > 0) + info->messages++; + if (!slice && len > 0 && want_first && seen < kFirstUserBytes) { + *nl = 0; /* the parse reads a C string; the newline is ours */ + cJSON *o = cJSON_Parse(buf + at); + const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role"); + const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content"); + const cJSON *k = cJSON_GetObjectItemCaseSensitive( + cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind"); + const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0; + if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note && + cJSON_IsString(c) && c->valuestring[0]) { + clip_line(info->first_user, sizeof(info->first_user), c->valuestring); + want_first = 0; + } + cJSON_Delete(o); + } + seen += len + 1; + at += len + 1; + slice = 0; + } + have = total - at; + if (got == 0) + break; /* EOF */ + if (have == kBlock) { + /* a line longer than one block: count it, drop its tail, keep going */ + info->messages++; + seen += have; + have = 0; + slice = 1; + } else if (have > 0) { + memmove(buf, buf + at, have); + } + } + if (have > 0 && !slice) + info->messages++; /* the last line, without a newline */ + free(buf); fclose(fp); return 0; } diff --git a/src/tools/registry.c b/src/tools/registry.c index 527c1e5..a1348ab 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -204,7 +204,12 @@ static const cst_tool_def_t TOOLS[] = { "Search earlier and current sessions for text.", {{"query", S, 1, "text to look for"}, {"max_matches", I, 0, "stop after this many matches (default 20)"}, - {"ignore_case", B, 0, "case-insensitive (default true)"}}, + {"ignore_case", B, 0, "case-insensitive (default true)"}, + {"max_bytes", I, 0, + "stop after this many bytes of transcripts (default 32 MB, newest " + "sessions first); 0 or unset means the default"}, + {"max_seconds", I, 0, + "stop after this many seconds of searching (default 5)"}}, CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META}, {"device_info", "Where this agent is running: system and CPU count, workspace, sandbox, " diff --git a/src/tools/session_tools.c b/src/tools/session_tools.c index 220999a..2c42556 100644 --- a/src/tools/session_tools.c +++ b/src/tools/session_tools.c @@ -83,12 +83,30 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, if (want > 100) want = 100; int icase = tool_arg_bool(c, "ignore_case", 1); + /* A store grows without bound and this walks all of it, so it gets a + * budget: the walk stops when it is spent and says so, instead of making a + * turn sit for minutes on a workspace with a few hundred sessions. */ + long budget_bytes = tool_arg_long(c, "max_bytes", 32L << 20); + if (budget_bytes <= 0) + budget_bytes = 32L << 20; + long budget_ms = tool_arg_long(c, "max_seconds", 5); + if (budget_ms <= 0) + budget_ms = 5; + budget_ms *= 1000; + const double t_start = os_now_ms(); + long scanned_bytes = 0; + int cut = 0; /* the budget stopped the walk */ static char ids[200][128]; int n = cst_agent_store_list(ctx->workspace_root, ids, 200); cst_buf_t hits = {0}; int matches = 0, in_sessions = 0, scanned_sessions = 0; for (int i = 0; i < n && matches < (int)want; i++) { + if (scanned_bytes >= budget_bytes || + os_now_ms() - t_start > (double)budget_ms) { + cut = 1; + break; + } char path[OS_PATH_MAX]; if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path, sizeof(path)) != 0) @@ -106,8 +124,24 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, ssize_t got; while ((got = getline(&line, &cap, fp)) > 0) { lineno++; - if (got <= 1) + scanned_bytes += got; + /* A tool output line can be megabytes (a whole file read back): such a + * line is not scanned -- one cJSON tree per block of it would be the + * slowest thing in the turn. */ + if (got <= 1 || got > (1 << 16)) { + if (scanned_bytes >= budget_bytes) { + cut = 1; + break; + } continue; + } + /* the clock is asked every so often: a clock_gettime per line would be + * the next slowest thing after the parse */ + if ((lineno & 0xFF) == 0 && ((scanned_bytes >= budget_bytes || + os_now_ms() - t_start > (double)budget_ms))) { + cut = 1; + break; + } cJSON *o = cJSON_Parse(line); if (!o) continue; @@ -159,10 +193,14 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, if (matches == 0) { cst_buf_free(&hits); return tool_set_resultf(r, 0, - "no match for \"%.80s\" in %d stored session%s " - "(the store holds %d)\n", - query, scanned_sessions, - scanned_sessions == 1 ? "" : "s", n); + "no match for \"%.80s\" in %d of %d stored session%s" + " (%.1f MB scanned%s)\n", + query, scanned_sessions, n, + scanned_sessions == 1 ? "" : "s", + (double)scanned_bytes / (1024.0 * 1024.0), + cut ? "; the budget stopped the walk -- raise " + "max_bytes or max_seconds" + : ""); } cst_buf_t out = {0}; cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n", @@ -172,6 +210,13 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, if (matches >= (int)want) cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the " "query)\n"); + if (cut) + cst_buf_printf(&out, + "\n(the walk stopped after %.1f MB / %.1f s of a %d MB store: " + "older sessions were not looked at -- raise max_bytes or " + "max_seconds to go further)\n", + (double)scanned_bytes / (1024.0 * 1024.0), + (os_now_ms() - t_start) / 1000.0, n); cst_buf_free(&hits); return tool_take_buf(r, &out, 0); }