diff --git a/.gitignore b/.gitignore index 30c46c0..0b2a4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ third_party/busybox-src/ third_party/prefix-mingw/ third_party/prefix-linux-*/ third_party/build-linux-*/ +# the Windows app package (scripts/package_windows_app.sh): MSYS2's Qt and the +# host tools it is built with, both fetched/unpacked by the script +third_party/msys-qt6/ +third_party/prefix-qt6-host third_party/prefix-android-*/ third_party/build-android-*/ third_party/src/ diff --git a/README.md b/README.md index 9f256e9..592560a 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,8 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。 |---|---|---|---| | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | +| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` | +| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` | @@ -240,7 +242,11 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。 | 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` | “上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。 -推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。 +推理强度会按 provider 适配:DeepSeek/OpenAI 使用 `reasoning_effort`,Kimi K3 使用同名字段,Kimi K2 使用 `thinking` 并保留 `reasoning_content`。Kimi 默认开启 1 小时隐式 prompt cache,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭提示。 + +Kimi 与 DeepSeek 还提供官方联网工具:`web_search` 调用 provider 的 Search API,`web_fetch` 调用 provider 的 Fetch API;其它 provider 继续使用通用 HTTP 抓取。 + +`kimi` 是按量计费的 Kimi 开放平台,`kimi-code` 是会员订阅的 Kimi Code API;两者的 Base URL 和 API Key 不通用。 详见 [docs/env_openai.md](docs/env_openai.md)。 @@ -315,6 +321,7 @@ cst-agent --headless --prompt 'List files' | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | +| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 | | `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 | | `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB | | `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) | diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 38dc124..0fc3e34 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -91,10 +91,12 @@ if(CST_APP_GUI) qt_policy(SET QTP0004 NEW) # The front end's platform tools: one file per platform, the same split # core/ToolProvider.cpp makes. Android brings its own (JNI, owner - # "app:android"); the other platforms still use the Linux file, whose - # registration entry point is simply not called there. + # "app:android"), Windows its own (PowerShell popup, owner "app:windows"); + # the rest use the Linux file. if(ANDROID) set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp) + elseif(WIN32) + set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp) else() set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp) endif() diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h index 679cbab..ba787a4 100644 --- a/app/src/core/ToolProvider.h +++ b/app/src/core/ToolProvider.h @@ -104,6 +104,16 @@ bool hasAllFilesAccess(); * dialog for it). A desktop, a phone that already has it or one too old to * know about it returns "" and nothing opens. */ QString requestAllFilesAccess(); +/* Take the whole screen. A phone's status bar -- and a tablet's taskbar, which + * is a status bar with icons in it -- is drawn *over* the window, and the + * window here draws from its own first pixel, so the bars sit on the app's top + * row and the controls there cannot be touched. Hiding them is what "full + * screen" means on a phone; a desktop has no such bars to ask about and + * returns "". Asking is repeatable and cheap: the bars come back with a swipe + * from the edge or a switch to another app, so the front end asks again every + * time the window comes to the front. Returns "" once they are gone, a reason + * otherwise (which the front end logs). */ +QString requestFullscreen(); /* The arguments the Android activity was started with (the * "applicationArguments" intent extra): the way * `cst-agent-app --uitest --workspace DIR` is reachable on a device, since diff --git a/app/src/main.cpp b/app/src/main.cpp index 7eb1fe6..f6c2200 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -353,5 +354,25 @@ int main(int argc, char **argv) { /* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */ app::installBackKey(engine.rootObjects().value(0), engine.rootObjects().value(0)); + /* The system bars are drawn over the window (see requestFullscreen): ask for + * the whole screen, and ask again whenever the app comes back to the front, + * because a swipe from the edge or a switch to another app brings them back. + * A refusal is not fatal -- it is logged and the window keeps working with + * the bars over its top row. */ + { + const QString why = app::requestFullscreen(); + if (!why.isEmpty()) + qWarning("fullscreen: %s", qUtf8Printable(why)); + QQuickWindow *qwin = + qobject_cast(engine.rootObjects().value(0)); + if (qwin) + QObject::connect(qwin, &QQuickWindow::sceneGraphInitialized, qwin, + [] { app::requestFullscreen(); }); + QObject::connect(&app, &QGuiApplication::applicationStateChanged, &app, + [](Qt::ApplicationState state) { + if (state == Qt::ApplicationActive) + app::requestFullscreen(); + }); + } return app.exec(); } diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp index ea5631a..20a78f8 100644 --- a/app/src/platform/android/AndroidTools.cpp +++ b/app/src/platform/android/AndroidTools.cpp @@ -846,6 +846,65 @@ QString requestAllFilesAccess() { return err; } +/* The status bar -- and on a tablet in desktop mode the taskbar, which is a + * status bar full of icons -- is drawn over the window, and this window draws + * from its own first pixel, so the bars land on the app's top row and the + * controls there cannot be touched. Which call hides them depends on the API + * level: WindowInsetsController from Android 11 (API 30) on, the older + * systemUiVisibility flags before it. Measured on the tablet: the app's own + * title row was under the taskbar, this puts the window on the whole screen + * and the row is touchable again. + * + * Nothing here is fatal: an invalid context, a missing class or a null insets + * controller comes back as a reason (which main.cpp logs) and the window keeps + * working with the bars over it. */ +QString requestFullscreen() { + QString err; + onGuiThread([&] { + QJniObject activity = appContext(); + if (!activity.isValid()) { + err = QStringLiteral("no activity"); + return; + } + QJniObject window = + activity.callObjectMethod("getWindow", "()Landroid/view/Window;"); + if (!window.isValid()) { + err = QStringLiteral("no window"); + return; + } + if (sdkInt() >= 30) { + /* Edge to edge plus the bars hidden: the window is told to fill the + * screen itself rather than lay out inside the bars. */ + window.callMethod("setDecorFitsSystemWindows", "(Z)V", false); + QJniObject control = window.callObjectMethod( + "getInsetsController", "()Landroid/view/WindowInsetsController;"); + if (!control.isValid()) { + err = QStringLiteral("no insets controller"); + return; + } + const jint bars = QJniObject::callStaticMethod( + "android/view/WindowInsets$Type", "systemBars", "()I"); + control.callMethod("hide", "(I)V", bars); + /* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the edge brings + * the bars back for a moment and they leave again by themselves. */ + control.callMethod("setSystemBarsBehavior", "(I)V", 2); + return; + } + QJniObject decor = + window.callObjectMethod("getDecorView", "()Landroid/view/View;"); + if (!decor.isValid()) { + err = QStringLiteral("no decor view"); + return; + } + /* SYSTEM_UI_FLAG_HIDE_NAVIGATION | FULLSCREEN | LAYOUT_STABLE | + * LAYOUT_HIDE_NAVIGATION | LAYOUT_FULLSCREEN | IMMERSIVE_STICKY */ + const jint flags = 0x00000002 | 0x00000004 | 0x00000100 | 0x00000200 | + 0x00000400 | 0x00001000; + decor.callMethod("setSystemUiVisibility", "(I)V", flags); + }); + return err; +} + int platformRegisterAndroid(AgentCore *core) { if (!core) return 0; diff --git a/app/src/platform/linux/NotifyTool.cpp b/app/src/platform/linux/NotifyTool.cpp index b920c92..b983737 100644 --- a/app/src/platform/linux/NotifyTool.cpp +++ b/app/src/platform/linux/NotifyTool.cpp @@ -157,6 +157,10 @@ QString materializeAttachment(const QString &pathOrUri, QString *err) { bool hasAllFilesAccess() { return true; } QString requestAllFilesAccess() { return QString(); } +/* A desktop window is not drawn under anything of the desktop's own: the bars + * a panel draws are the panel's, and nothing here can be asked for them. */ +QString requestFullscreen() { return QString(); } + int platformRegisterLinux(AgentCore *core) { static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { {"message", "string", 1, "text of the notification"}, diff --git a/app/src/platform/windows/NotifyTool.cpp b/app/src/platform/windows/NotifyTool.cpp new file mode 100644 index 0000000..2453ada --- /dev/null +++ b/app/src/platform/windows/NotifyTool.cpp @@ -0,0 +1,196 @@ +/* Platform tools on Windows, implemented by the app (not by the engine): they + * exist exactly where the desktop service does, and the engine only ever sees + * the capability -- platforms + probe decide whether the tool is offered. + * + * The Linux file next door is the same shape; what differs is the service + * behind each call. A notification is the scripting host's popup (Wscript.Shell + * through PowerShell) because Windows has no notify-send and a toast needs the + * WinRT API; it carries the same two arguments and answers the same way. */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/AgentCore.h" +#include "core/ToolProvider.h" + +namespace app { +namespace { + +/* whereis-style check without a shell: PATH is split on the Windows separator + * and a name may need one of the executable suffixes. */ +bool haveProgram(const char *name) { + const QByteArray path = qgetenv("PATH"); + const QList dirs = path.split(';'); + const QList exts = {QByteArray(), QByteArray(".exe"), + QByteArray(".cmd"), QByteArray(".bat")}; + for (const QByteArray &d : dirs) { + if (d.isEmpty()) + continue; + for (const QByteArray &ext : exts) { + const QString cand = QFileInfo(QString::fromLocal8Bit(d + "\\" + name + + ext)) + .absoluteFilePath(); + if (QFileInfo::exists(cand) && QFileInfo(cand).isExecutable()) + return true; + } + } + return false; +} + +/* 0 when notifications can be shown; -1 with the reason otherwise. */ +int notifyProbe(char *why, size_t why_sz) { + if (haveProgram("powershell") || haveProgram("pwsh")) + return 0; + snprintf(why, why_sz, "no powershell on PATH to show a popup with"); + return -1; +} + +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; +} + +/* Runs on the turn's worker thread: no GUI objects here. The popup is started + * detached -- it waits for its own timeout, and the turn must not. */ +int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)ud; + const char *title = argOf(call, "title"); + const char *message = argOf(call, "message"); + if (!message || !message[0]) { + const char *msg = "error: notification_send needs a message"; + r->out = strdup(msg); + r->out_len = r->out ? strlen(msg) : 0; + r->exit_code = 1; + return 0; + } + const QString shown = + title && title[0] ? QString::fromUtf8(title) : QStringLiteral("CST-agent"); + /* The popup's own text is single-quoted for PowerShell; a quote inside it is + * doubled, which is how PowerShell escapes one. */ + QString text = QString::fromUtf8(message); + text.replace(QLatin1Char('\''), QLatin1String("''")); + QString heading = shown; + heading.replace(QLatin1Char('\''), QLatin1String("''")); + const QString script = + QStringLiteral("$w = New-Object -ComObject Wscript.Shell; " + "$w.Popup('%1', 8, '%2', 64) | Out-Null") + .arg(text, heading); + const QString exe = haveProgram("pwsh") ? QStringLiteral("pwsh") + : QStringLiteral("powershell"); + QStringList args; + args << QStringLiteral("-NoProfile") << QStringLiteral("-NonInteractive") + << QStringLiteral("-Command") << script; + const bool started = QProcess::startDetached(exe, args); + const QByteArray out = started + ? QByteArrayLiteral("notification shown") + : QByteArrayLiteral("could not start ") + + exe.toLocal8Bit(); + r->out = strdup(out.constData()); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = started ? 0 : 1; + return 0; +} + +/* Registered once; the engine keeps a copy until the owner is unregistered. */ +cst_tool_def_t g_notify; + +} // namespace + +/* A file card's "open": ShellExecute through QDesktopServices, so the file's + * own application comes up. */ +QString openLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("opened %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path))) + return QStringLiteral("no application on this machine opens %1").arg(name); + if (status) + *status = QStringLiteral("opened %1").arg(name); + return QString(); +} + +/* No share sheet here: the path is what another program needs, so it goes to + * the clipboard. */ +QString shareLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("shared %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + QGuiApplication::clipboard()->setText(path); + if (status) + *status = QStringLiteral("%1: the path is on the clipboard").arg(name); + return QString(); +} + +/* Windows does not freeze a process whose window is open, and the app's own + * window is what keeps it alive: nothing to hold. */ +QString holdForeground(const QString &text) { + (void)text; + return QString(); +} +void releaseForeground() {} + +/* A native file dialog hands over a path, so there is nothing to turn into + * one: a file:// URL loses its scheme and anything else is taken as a path. */ +QString materializeAttachment(const QString &pathOrUri, QString *err) { + (void)err; + QString s = pathOrUri; + if (s.startsWith(QLatin1String("file://"))) + s = s.mid(7); + return s; +} + +/* Every process on Windows reads the files its user can. */ +bool hasAllFilesAccess() { return true; } +QString requestAllFilesAccess() { return QString(); } + +/* A Windows window has its own frame; there is no system bar drawn over it. */ +QString requestFullscreen() { return QString(); } + +int platformRegisterWindows(AgentCore *core) { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"message", "string", 1, "text of the notification"}, + {"title", "string", 0, "title line (default: CST-agent)"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_notify, 0, sizeof(g_notify)); + setMeta(&g_notify, "notification_send", + "Show a notification outside the window (a popup that goes away by " + "itself).", + CST_TP_ASK, "notify", kDesktopOnly, notifyProbe); + memcpy(g_notify.params, params, sizeof(params)); + g_notify.run = nullptr; + g_notify.run_ex = notifyRun; + g_notify.ud = nullptr; + g_notify.owner = "app:windows"; + return core->registerTool(&g_notify) ? 1 : 0; +} + +} // namespace app diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 8c088d6..af8a838 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -128,11 +130,20 @@ int countNamed(QQuickItem *root, const QString &prefix) { /* An item by objectName. The QObject tree is tried first (it holds the named * controls); a view's delegates can be owned by the model instead, so the * visual tree -- what a finger can actually reach -- is walked as well. */ +bool onScreen(QQuickItem *it); /* defined below: isVisible() is not enough */ + QQuickItem *item(QQuickWindow *win, const QString &name) { QList hits; collectNamed(win->contentItem(), name, &hits); - /* What a finger can reach is the one the test means: a delegate on its way - * out is still in the tree but invisible. */ + /* What a finger can reach is the one the test means. Two things say + * "visible" without being reachable: a delegate on its way out, and a + * second copy of a control that lives in a closed drawer (the sidebar is + * drawn from the window and, when narrow, from a drawer off to the left). + * Which copy comes first in the tree is not the same on every platform, so + * the one whose centre is really on screen wins. */ + for (QQuickItem *it : hits) + if (onScreen(it)) + return it; for (QQuickItem *it : hits) if (it->isVisible()) return it; @@ -168,12 +179,66 @@ bool onScreen(QQuickItem *it) { return true; } +/* An event lands on whatever is at that point, and isVisible() does not know + * about a scrolling ancestor's clipping (see onScreen): an item the view has + * scrolled past still says it is visible, and the click goes to the row that + * happens to be at that pixel -- which is a different row on a window whose + * font makes the rows taller (another platform, another Qt). So a click + * scrolls its target in first, which is what a person does. The nearest + * ancestor that both clips and scrolls (a ListView's own item) is the one to + * move; anchored to its middle, so a row at either edge still gets its whole + * centre inside the viewport. */ +void scrollIntoView(QQuickItem *it, bool middle = false) { + if (!it || (!middle && onScreen(it))) + return; + for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) { + const QVariant contentY = a->property("contentY"); + if (!a->clip() || !contentY.isValid() || + !a->property("contentHeight").isValid()) + continue; + const double y = it->mapToItem(a, QPointF(0, 0)).y() + contentY.toDouble(); + const double lowest = a->property("contentHeight").toDouble() - a->height(); + note(QStringLiteral("%1 is out of the viewport (contentY %2, at %3 of %4): " + "scrolling to it") + .arg(it->objectName()) + .arg(contentY.toDouble()) + .arg(y) + .arg(a->property("contentHeight").toDouble())); + a->setProperty("contentY", qBound(0.0, y - a->height() / 2, lowest)); + QTest::qWait(150); + return; + } +} + +/* One rendered frame: items are laid out, and a view's delegates are created, + * in the render loop's polish step. A platform whose window is never exposed + * -- offscreen on Windows, which is how this is run on a machine that has no + * Windows -- only runs that step when something asks for a picture, so + * without this the window can be a whole pass behind: a row still 0 wide, a + * card not created yet. A person looking at the window would see it settled, + * which is what the checks below are about. */ +void frame(QQuickWindow *win) { + if (win) + win->grabWindow(); + QTest::qWait(50); +} + +void clickItem(QQuickWindow *win, QQuickItem *it); /* defined below */ +void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it); + void clickMouse(QQuickWindow *win, const QString &name) { QQuickItem *it = item(win, name); if (!it || !it->isVisible()) { check(false, QStringLiteral("mouse click %1: not there").arg(name)); return; } + clickItem(win, it); +} + +/* The same click, on an item the caller already has: a name can match more + * than one item (a view keeps a delegate from the session before), and the + * one the test means is not always the first in the tree. */ +void clickItem(QQuickWindow *win, QQuickItem *it) { QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it)); } @@ -183,6 +248,10 @@ void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) { check(false, QStringLiteral("touch tap %1: not there").arg(name)); return; } + tapItem(win, dev, it); +} + +void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) { const QPoint p = centre(it); QTest::touchEvent(win, dev).press(0, p, win); QTest::touchEvent(win, dev).release(0, p, win); @@ -417,6 +486,10 @@ int runUiTest(AgentCore &core, const QString &size) { QStringLiteral("a session fixture with a tool call is in the store")); QMetaObject::invokeMethod(win, "reloadSessions"); QTest::qWait(150); + /* A row is not clickable until the layout pass that gives it its width has + * run (see frame()): a click on a row that is still 0 wide goes to whatever + * is behind it. */ + frame(win); int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:")); if (qEnvironmentVariableIsSet("CST_APP_DUMP")) std::printf("note: %d session rows in the visual tree\n", rowsSeen); @@ -465,61 +538,89 @@ int runUiTest(AgentCore &core, const QString &size) { /* A click goes to whatever is at that point, and the transcript replays * from its bottom: on a phone-sized window the card can be above the * visible part, where isVisible() still says yes (it does not know about - * the view's clipping) and the click lands on the row that happens to be - * there. Scroll it in first, which is what a person does -- and stop the - * view following its tail (ChatView.stickBottom), or the scroll is undone - * by the next content change. */ - if (list && !onScreen(item(win, QStringLiteral("fileOpen0")))) { - QQuickItem *button = item(win, QStringLiteral("fileOpen0")); - QQuickItem *chat = item(win, QStringLiteral("chatView")); - if (chat) + * the view's clipping). scrollIntoView puts it in the middle of the view -- + * and stop the view following its tail (ChatView.stickBottom) first, or + * the scroll is undone by the next content change. */ + if (list) { + /* Stop the view following its tail (ChatView.stickBottom) first: the + * scroll to the card below would be undone by the next content + * change. */ + if (QQuickItem *chat = item(win, QStringLiteral("chatView"))) chat->setProperty("stickBottom", false); - const double contentY = list->property("contentY").toDouble(); - const double y = button->mapToItem(list, QPointF(0, 0)).y() + contentY; - const double lowest = - list->property("contentHeight").toDouble() - list->height(); - note(QStringLiteral("the card is out of the viewport (contentY %1, card " - "at %2 of %3): scrolling to it") - .arg(contentY) - .arg(y) - .arg(list->property("contentHeight").toDouble())); - list->setProperty("contentY", qBound(0.0, y - list->height() / 2, lowest)); - QTest::qWait(150); } - clickMouse(win, QStringLiteral("fileOpen0")); - /* What the card itself hands the engine ("rel" is the name the store keeps - * the file under), and whether it is really on screen: a card outside the - * viewport takes the events meant for it, and the card's own line is where - * the engine puts its refusal. */ QStringList rels; QString cardSays; QList strips; collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &strips); + /* Which card is the session's own. The tree still holds the delegate of + * the session before -- a view keeps a row or two around -- and its card + * names a file of *that* session, so the first "fileOpen0" in the tree is + * not necessarily the one the check means. A rel this session can open is + * one that is really in its attachment directory. */ + QQuickItem *openButton = nullptr; + QQuickItem *shareButton = nullptr; + const QString attachDir = core.attachmentDir(); for (QQuickItem *s : strips) { const QVariantList files = s->property("files").toList(); - for (const QVariant &f : files) + for (int i = 0; i < files.size(); i++) { + const QVariant &f = files.at(i); rels << f.toMap().value(QStringLiteral("rel")).toString(); + if (openButton) { + continue; + } + const QString rel = + f.toMap().value(QStringLiteral("rel")).toString(); + if (rel.isEmpty() || + !QFileInfo::exists(attachDir + QLatin1Char('/') + rel)) + continue; + QList hits; + collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits); + if (!hits.isEmpty()) { + openButton = hits.first(); + collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits); + shareButton = hits.isEmpty() ? nullptr : hits.first(); + } + } if (cardSays.isEmpty()) cardSays = s->property("errorText").toString(); } + if (!openButton) + openButton = item(win, QStringLiteral("fileOpen0")); + if (!shareButton) + shareButton = item(win, QStringLiteral("fileShare0")); + /* What the card itself hands the engine ("rel" is the name the store keeps + * the file under), and whether it is really on screen: a card outside the + * viewport takes the events meant for it, and the card's own line is where + * the engine puts its refusal. */ + if (openButton) { + scrollIntoView(openButton, true); + frame(win); + clickItem(win, openButton); + } else { + check(false, QStringLiteral("the delivered file has a card to open")); + } const bool byMouse = !win->property("statusText").toString().isEmpty(); - if (!byMouse) - tapTouch(win, touch, QStringLiteral("fileOpen0")); + if (!byMouse && openButton) + tapItem(win, touch, openButton); const QString opened = win->property("statusText").toString(); check(opened.startsWith(QLatin1String("opened ")), QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', " "%4, card says '%5')") .arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"), rels.join(QLatin1String(", ")), - onScreen(item(win, QStringLiteral("fileOpen0"))) + onScreen(openButton) ? QStringLiteral("on screen") : QStringLiteral("out of the viewport"), cardSays)); win->setProperty("statusText", QString()); - clickMouse(win, QStringLiteral("fileShare0")); + if (shareButton) { + scrollIntoView(shareButton, true); + frame(win); + clickItem(win, shareButton); + } const bool shareByMouse = !win->property("statusText").toString().isEmpty(); - if (!shareByMouse) - tapTouch(win, touch, QStringLiteral("fileShare0")); + if (!shareByMouse && shareButton) + tapItem(win, touch, shareButton); const QString shared = win->property("statusText").toString(); check(shared.contains(QLatin1String("report.md")), QStringLiteral("Share names the file (%1%2; card says '%3')") @@ -709,27 +810,46 @@ int runUiTest(AgentCore &core, const QString &size) { /* and back to the fixture, which still has its six rows */ QTest::qWait(150); + /* The sidebar holds two more sessions by now, so the row may sit below the + * fold: brought into view first, or the click goes to the row that happens + * to be there (see scrollIntoView). */ + if (QQuickItem *row = item(win, QStringLiteral("sessionRow:") + fixture)) { + scrollIntoView(row); + frame(win); + } clickMouse(win, QStringLiteral("sessionRow:") + fixture); check(core.sessionId() == fixture && list && list->property("count").toInt() == 6, QStringLiteral("switching back replays it again")); { bool promptImage = false; + int promptImageRow = -1; const QVariantList rows = core.history(); - for (const QVariant &v : rows) { + for (int i = 0; i < rows.size(); i++) { + const QVariant &v = rows.at(i); const QVariantMap r = v.toMap(); if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") && r.value(QStringLiteral("images")).toString().contains( - QLatin1String("|"))) + QLatin1String("|"))) { promptImage = true; + if (promptImageRow < 0) + promptImageRow = i; + } } check(promptImage, QStringLiteral("a stored prompt image comes back with its row")); + /* The view follows the tail (ChatView.stickBottom) and the picture is in + * the first prompt: on a window that fits fewer rows than this one it is + * above the visible part, where a delegate is not created at all. Put it + * on screen, which is what a person does before looking at it. */ + if (list && promptImageRow >= 0) + QMetaObject::invokeMethod(list, "positionViewAtIndex", + Q_ARG(int, promptImageRow), Q_ARG(int, 0)); /* The rows are in the model by now; the cards -- and the thumbnails in * them -- are built on the view's next layout pass. A frame forces it, * and that frame is also the picture a person looks at. */ maybeShot(win, QStringLiteral("-images")); - QTest::qWait(150); + frame(win); if (qEnvironmentVariableIsSet("CST_APP_DUMP")) { QVariant v; QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v), @@ -1057,9 +1177,13 @@ int runUiTest(AgentCore &core, const QString &size) { /* --- the image provider: only the session's own files ----------------- */ { const QString dir = core.attachmentDir(); - /* one directory per session since v0.9.5: //attachments */ - check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) && - dir.contains(QLatin1String("/sessions/")), + /* one directory per session since v0.9.5: //attachments. + * The engine builds the path with the separator of the platform it runs + * on, so what arrives here is "…/sessions\\attachments" on Windows: + * asked with forward slashes, which is what the check means. */ + const QString shown = QDir::fromNativeSeparators(dir); + check(!shown.isEmpty() && shown.endsWith(QLatin1String("/attachments")) && + shown.contains(QLatin1String("/sessions/")), QStringLiteral("the agent names its image directory (%1)").arg(dir)); QImage pic(32, 16, QImage::Format_RGB32); pic.fill(Qt::darkGreen); @@ -1100,6 +1224,14 @@ int runUiTest(AgentCore &core, const QString &size) { const int before = list->property("count").toInt(); const int thumbsBefore = countNamed(win->contentItem(), QStringLiteral("imageThumb")); + if (qEnvironmentVariableIsSet("CST_APP_DUMP") && list) + std::printf("note: tool card: model %d, contentY %.1f of %.1f (view %.1f), " + "%d strips, %d thumbs, %d cards\n", before, + list->property("contentY").toDouble(), + list->property("contentHeight").toDouble(), list->height(), + countNamed(win->contentItem(), QStringLiteral("imageStrip")), + thumbsBefore, + countNamed(win->contentItem(), QStringLiteral("card:"))); event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"), QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}")); const QString imgs = @@ -1108,7 +1240,7 @@ int runUiTest(AgentCore &core, const QString &size) { Q_ARG(QVariant, QStringLiteral("call-img")), Q_ARG(QVariant, imgs)), QStringLiteral("images a tool made reach the view")); - QTest::qWait(150); + frame(win); const QVariantMap card = rowAt(before); check(card.value(QStringLiteral("images")) .toString() @@ -1118,7 +1250,9 @@ int runUiTest(AgentCore &core, const QString &size) { QStringLiteral("the card of that call carries the image")); check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) > thumbsBefore, - QStringLiteral("its thumbnail is in the card")); + QStringLiteral("its thumbnail is in the card (%1 -> %2)").arg(thumbsBefore) + .arg(countNamed(win->contentItem(), + QStringLiteral("imageThumb")))); maybeShot(win, QStringLiteral("-toolimage")); } @@ -1144,7 +1278,7 @@ int runUiTest(AgentCore &core, const QString &size) { Q_ARG(QVariant, QStringLiteral("call-file")), Q_ARG(QVariant, files)), QStringLiteral("the files a tool delivered reach the view")); - QTest::qWait(150); + frame(win); const QVariantMap card = rowAt(before); check(card.value(QStringLiteral("images")) .toString() diff --git a/docs/env_openai.md b/docs/env_openai.md index 0b23b8c..977bd34 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -12,6 +12,8 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi |---|---|---|---| | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | +| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` | +| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` | @@ -26,6 +28,12 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi | Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 | | 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) | +Kimi 的 K3 请求使用 `reasoning_effort`;K2.6/K2.7 使用 `thinking`,并在后续请求中回传 `reasoning_content`。默认发送 Kimi 1 小时隐式 prompt cache 提示,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭。 + +当 provider 为 Kimi 或 DeepSeek 时,`web_search` 和 `web_fetch` 使用对应的官方联网 Search/Fetch API;其它 provider 使用通用 URL 抓取。 + +`kimi-code` 使用 Kimi Code 的 OpenAI-compatible API。它和按量计费的 `kimi` 开放平台使用不同的 Base URL 与 API Key,不能混用。 + key 按 provider 保存在 credentials 文件(权限 0600),每行 `.api_key=...`; TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显): - Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`) @@ -42,6 +50,20 @@ cst-agent --provider deepseek # 首次会询问 key cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought ``` +### Kimi + +```bash +cst-agent --provider kimi # 默认 kimi-k3 +cst-agent --provider kimi --model kimi-k2.6 # K2 thinking 模式 +``` + +### Kimi Code + +```bash +cst-agent --provider kimi-code # 默认 k3 +cst-agent --provider kimi-code --model k3-256k +``` + ### OpenAI 官方 ```bash diff --git a/include/cst_version.h b/include/cst_version.h index fccf7ba..59df8b1 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 7 +#define CST_VERSION_PATCH 8 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/package_windows_app.sh b/scripts/package_windows_app.sh new file mode 100755 index 0000000..63f1a03 --- /dev/null +++ b/scripts/package_windows_app.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Build and pack the Windows GUI (app/cst-agent-app.exe) from Linux. +# +# scripts/package_windows_app.sh (run it from the repo root) +# nix-shell shell-win.nix --run scripts/package_windows_app.sh +# --run works from anywhere; the script re-enters the shell itself when the +# mingw toolchain is not on PATH. +# +# The toolchain is the same one the TUI cross build uses (shell-win.nix: mingw +# UCRT, x86_64-w64-mingw32). Qt comes from MSYS2, prebuilt for exactly that +# target, and the host (Linux) tools come from nixpkgs' native Qt6: both are +# 6.11.2, which is what Qt's own version check wants. +# +# Why not nixpkgs' mingw Qt: it does not build in this nixpkgs. bash 5.3 fails +# to cross-compile for mingw (`sigset_t` is not in the mingw headers), which +# takes tcl down, which sqlite needs, which qtbase needs; before that, giflib's +# mingw patch no longer applies to its own source. Three separate overlays got +# as far as qtbase before that wall. MSYS2 has the same Qt prebuilt. +# +# MSYS2's Qt ships *Windows* tools (moc.exe, rcc.exe, qmlcachegen.exe, ...); +# the build runs on Linux, so each one is replaced by a one-line shim that +# calls the host tool. windeployqt cannot run here either, so the deployment +# half of this script copies the DLLs, plugins and QML modules by hand: the +# DLLs by walking the exe's imports, the rest from the layout Qt expects. +# +# Output: dist/windows-x86_64-app/ and +# dist/cst-agent-app-VERSION-windows-x86_64.zip (+ .sha256) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +MIRROR="${CST_MSYS_MIRROR:-https://mirror.msys2.org/mingw/ucrt64}" +PKGS="$ROOT/third_party/msys-qt6" # MSYS2 packages, extracted +PREFIX="$PKGS/ucrt64" # what CMAKE_PREFIX_PATH points at +HOST="${CST_QT6_HOST:-$ROOT/third_party/prefix-qt6-host}" +BUILD="$ROOT/build-win-app" +OUT="$ROOT/dist/windows-x86_64-app" + +if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] && + command -v nix-shell >/dev/null; then + exec nix-shell "$ROOT/shell-win.nix" --run "$(printf '%q ' "$0" "$@")" +fi +command -v x86_64-w64-mingw32-gcc >/dev/null || { + echo "need the mingw UCRT toolchain (run through shell-win.nix)" >&2 + exit 1 +} +OBJDUMP="$(command -v x86_64-w64-mingw32-objdump || echo objdump)" +STRIP="$(command -v x86_64-w64-mingw32-strip || echo strip)" + +# --- the MSYS2 packages ---------------------------------------------------- +# The Qt modules the app links, plus every package that provides a DLL those +# modules load (found with x86_64-w64-mingw32-objdump -p, then against +# ucrt64.files from the same mirror). Versions are taken from the mirror +# listing, so a new Qt6 release only means a new run of this script. +MSYS_PKGS=( + mingw-w64-ucrt-x86_64-qt6-base + mingw-w64-ucrt-x86_64-qt6-declarative + mingw-w64-ucrt-x86_64-qt6-svg + mingw-w64-ucrt-x86_64-libgcc + mingw-w64-ucrt-x86_64-libstdc++ + mingw-w64-ucrt-x86_64-libwinpthread + mingw-w64-ucrt-x86_64-libb2 + mingw-w64-ucrt-x86_64-double-conversion + mingw-w64-ucrt-x86_64-icu + mingw-w64-ucrt-x86_64-pcre2 + mingw-w64-ucrt-x86_64-zlib + mingw-w64-ucrt-x86_64-zstd + mingw-w64-ucrt-x86_64-freetype + mingw-w64-ucrt-x86_64-harfbuzz + mingw-w64-ucrt-x86_64-md4c + mingw-w64-ucrt-x86_64-libpng + mingw-w64-ucrt-x86_64-brotli + mingw-w64-ucrt-x86_64-bzip2 + # harfbuzz reaches for these (and glib2 for libffi/gettext below): they are + # not what the app links directly, but the shaper the font engine loads. + mingw-w64-ucrt-x86_64-glib2 + mingw-w64-ucrt-x86_64-graphite2 + mingw-w64-ucrt-x86_64-libffi + mingw-w64-ucrt-x86_64-gettext-runtime + mingw-w64-ucrt-x86_64-libiconv +) + +listing="$PKGS/listing.html" +mkdir -p "$PKGS" +if [[ ! -s "$listing" || -n "${CST_MSYS_REFRESH:-}" ]]; then + echo "==> mirror listing" + curl -fsSL "$MIRROR/" -o "$listing" +fi + +# The newest file of a package (no -debug, no .sig): the mirror keeps every +# revision, and the listing is one HTTP page, so this is the whole lookup. The +# names come out of the links (the text of a link is elided with "..") and are +# matched literally, because a package name may hold regex characters +# ("libstdc++", which the listing writes as %2B and the link as a plus). +allPkgs="$(grep -oE 'href="[^"]+\.pkg\.tar\.zst"' "$listing" | + sed 's/^href="//; s/"$//; s/%2[bB]/+/g' | grep -v -- '-debug-' | sort -V -u)" +newest() { # newest PKGNAME + # ... and the name has to end there: "harfbuzz-" also prefixes + # "harfbuzz-utils", "brotli-" prefixes "brotli-testdata", "zlib-" prefixes + # "zlib-ng-compat". What follows the name is a version, which starts with a + # digit. + printf '%s\n' "$allPkgs" | awk -v pre="$1-" ' + index($0, pre) == 1 && substr($0, length(pre) + 1) ~ /^[0-9]/' | + sort -V | tail -1 +} + +for p in "${MSYS_PKGS[@]}"; do + f="$(newest "$p")" + [[ -n "$f" ]] || { echo "no package $p in the mirror listing" >&2; exit 1; } + if [[ ! -f "$PKGS/$f" ]]; then + echo "==> $f" + curl -fsSL "$MIRROR/$f" -o "$PKGS/$f" + fi + # A package that is already extracted leaves its files in place: tar is + # happy to overwrite, so this just refreshes whatever changed. + tar --zstd -xf "$PKGS/$f" -C "$PKGS" +done +[[ -f "$PREFIX/lib/cmake/Qt6/Qt6Config.cmake" ]] || { + echo "$PREFIX does not look like a Qt6 install" >&2 + exit 1 +} + +# --- the host tools -------------------------------------------------------- +# nixpkgs' native Qt 6, in one prefix: moc, rcc, qmlcachegen, qmltyperegistrar, +# qmlimportscanner, qtpaths and their CMake packages (see scripts/qt6-host.nix). +if [[ ! -x "$HOST/libexec/moc" ]]; then + echo "==> host Qt6 tools" + nix build --impure -f "$ROOT/scripts/qt6-host.nix" --out-link "$HOST" +fi + +# Every .exe MSYS2 ships is a Windows program: replace each with a shim that +# runs the host tool of the same name. Done in the extracted tree, so the +# Qt6*Tools CMake packages keep pointing at the paths they record. +shim() { # shim FILE HOSTTOOL + printf '#!/bin/sh\nexec %s "$@"\n' "$2" >"$1" + chmod +x "$1" +} +shimmed=0 +for f in "$PREFIX"/share/qt6/bin/*.exe "$PREFIX"/bin/*.exe; do + [[ -e "$f" ]] || continue + n="$(basename "$f" .exe)" + for cand in "$HOST/libexec/$n" "$HOST/bin/$n"; do + if [[ -x "$cand" ]]; then + shim "$f" "$cand" + shimmed=$((shimmed + 1)) + break + fi + done +done +echo "==> $shimmed Qt tools point at the host ones" + +# --- build ----------------------------------------------------------------- +echo "==> cmake (Windows, GUI)" +cmake -S "$ROOT" -B "$BUILD" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/mingw-w64-x86_64.cmake" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCST_BUILD_TUI=OFF -DCST_BUILD_APP=ON \ + -DCMAKE_PREFIX_PATH="$PREFIX" \ + -DQT_HOST_PATH="$HOST" +# Only the app: the Win32 test targets are host-unrunnable and some of them are +# POSIX (tests/test_tools_ext.c calls mkdir with two arguments), exactly as in +# the TUI cross build. +cmake --build "$BUILD" -j"$(nproc)" --target cst-agent-app +EXE="$BUILD/app/cst-agent-app.exe" +[[ -f "$EXE" ]] || { echo "no cst-agent-app.exe" >&2; exit 1; } + +# --- the package ----------------------------------------------------------- +ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' \ + "$ROOT/include/cst_version.h" | paste -sd.)" +rm -rf "$OUT" +mkdir -p "$OUT" +cp -f "$EXE" "$OUT/cst-agent-app.exe" +"$STRIP" "$OUT/cst-agent-app.exe" 2>/dev/null || true + +# Which DLLs the app needs: the exe's imports, then theirs, and so on. What +# Windows itself provides is skipped; anything else has to come out of the +# extracted packages, which is what decided the list of packages above. +missing=0 +isSystem() { # isSystem DLLNAME + case "${1,,}" in + api-ms-win-*|kernel32.dll|user32.dll|advapi32.dll|shell32.dll|ws2_32.dll|\ + iphlpapi.dll|bcrypt.dll|crypt32.dll|secur32.dll|ole32.dll|oleaut32.dll|\ + gdi32.dll|comdlg32.dll|dwmapi.dll|uxtheme.dll|winmm.dll|dnsapi.dll|\ + version.dll|shlwapi.dll|netapi32.dll|userenv.dll|mpr.dll|ntdll.dll|\ + authz.dll|d3d11.dll|d3d12.dll|dwrite.dll|dxgi.dll|winhttp.dll|\ + rpcrt4.dll|usp10.dll) + return 0 ;; + esac + return 1 +} +copyDeps() { # copyDeps FILE... + local queue=("$@") f n src + while ((${#queue[@]})); do + f="${queue[0]}" + queue=("${queue[@]:1}") + while read -r n; do + [[ -n "$n" ]] || continue + isSystem "$n" && continue + [[ -e "$OUT/$n" ]] && continue + src="" + for c in "$PREFIX/bin/$n" "$PREFIX/lib/$n"; do + [[ -e "$c" ]] && src="$c" && break + done + if [[ -z "$src" ]]; then + echo "missing DLL: $n (needed by $(basename "$f"))" >&2 + missing=1 + continue + fi + cp -f "$src" "$OUT/$n" + queue+=("$OUT/$n") + done < <("$OBJDUMP" -p "$f" | awk '/DLL Name/{print $3}') + done +} +copyDeps "$OUT/cst-agent-app.exe" + +# The plugins Qt loads by name: the window itself (and the offscreen one, so a +# headless run is possible), the image formats a transcript can carry, the SVG +# icon engine (the app's icons are .svg through QIcon). +mkdir -p "$OUT/plugins" +for p in platforms/qwindows.dll platforms/qoffscreen.dll \ + iconengines/qsvgicon.dll; do + [[ -f "$PREFIX/share/qt6/plugins/$p" ]] || { + echo "no plugin $p" >&2 + missing=1 + continue + } + mkdir -p "$OUT/plugins/$(dirname "$p")" + cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/$p" +done +for p in imageformats/*.dll; do + [[ -e "$PREFIX/share/qt6/plugins/$p" ]] || continue + mkdir -p "$OUT/plugins/imageformats" + cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/imageformats/" +done +# The QML modules the window imports (QtQuick, QtQuick.Controls and its Basic +# style, Layouts, Window, ...): the whole tree, which is what a deployed Qt app +# has next to its exe, and what the QML import path picks up from /qml. +echo "==> QML modules" +cp -r "$PREFIX/share/qt6/qml" "$OUT/qml" +# ... and the plugins those QML modules load (a style, a helper, an image +# provider: each is a DLL with its own imports). +while IFS= read -r d; do + copyDeps "$d" +done < <(find "$OUT/qml" -name '*.dll') + +# What the engine's shell tool wants beside it, exactly as the TUI package +# ships it: the static busybox for Windows is the shell the engine runs. +if [[ -f "$ROOT/third_party/busybox/busybox.exe" ]]; then + cp -f "$ROOT/third_party/busybox/busybox.exe" "$OUT/busybox.exe" + cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$OUT/" 2>/dev/null || true +fi + +# Qt finds its plugins and QML imports relative to the exe through this file. +cat >"$OUT/qt.conf" <<'EOF' +[Paths] +Prefix = . +Plugins = plugins +Qml2Imports = qml +EOF +echo "$ver" >"$OUT/VERSION" + +if [[ "$missing" != 0 ]]; then + echo "the package is incomplete (see the missing entries above)" >&2 + exit 1 +fi + +zip="$ROOT/dist/cst-agent-app-$ver-windows-x86_64.zip" +rm -f "$zip" +(cd "$ROOT/dist" && zip -qr "$zip" windows-x86_64-app) +(cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256") +du -sh "$OUT" +ls -l "$zip" +echo "$zip" diff --git a/scripts/qt6-host.nix b/scripts/qt6-host.nix new file mode 100644 index 0000000..0e48f6d --- /dev/null +++ b/scripts/qt6-host.nix @@ -0,0 +1,38 @@ +/* The host (Linux) side of the Windows GUI cross build: nixpkgs' native Qt 6 + * tools in one prefix, for QT_HOST_PATH. + * + * MSYS2's Qt for Windows ships moc.exe, rcc.exe, qmlcachegen.exe and the rest + * as Windows programs, which a Linux build cannot run; Qt wants the host tools + * to be the *same version* as the target Qt (6.11.2 here, which is what + * nixpkgs' Qt 6 is), so they come from nixpkgs rather than from MSYS2. + * + * scripts/package_windows_app.sh builds this and points the cross build at it. + * Not a package: nothing installs it, it is a prefix assembled from the store + * paths of qtbase/qtdeclarative/qtsvg (bin, libexec, lib/cmake, share/qt6). + */ +with import {}; + +let + qtPkgs = [ + qt6.qtbase + qt6.qtbase.dev + qt6.qtdeclarative + qt6.qtdeclarative.dev + qt6.qtsvg + qt6.qtsvg.dev + ]; + script = '' + set -eu + mkdir -p $out + for p in ${builtins.concatStringsSep " " (map toString qtPkgs)}; do + [ -d "$p" ] || continue + for d in bin lib/cmake libexec share/qt6; do + if [ -d "$p/$d" ]; then + mkdir -p "$out/$d" + cp -rs "$p/$d/." "$out/$d/" 2>/dev/null || true + fi + done + done + ''; +in +runCommand "qt6-host" { } script diff --git a/shell-win.nix b/shell-win.nix index 5338871..ab26b1d 100644 --- a/shell-win.nix +++ b/shell-win.nix @@ -16,6 +16,9 @@ win.mkShell { zip curl nsis + # The Windows GUI package unpacks MSYS2's prebuilt Qt6 (.pkg.tar.zst) -- + # zstd is what tar needs for that (scripts/package_windows_app.sh). + zstd ]; buildInputs = [ win.windows.pthreads ]; # mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly. diff --git a/src/llm_openai.c b/src/llm_openai.c index e77063b..d6a0579 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -22,6 +22,25 @@ #define ERRBODY_MAX 4096 #define LLM_MAX_RETRIES 3 +static int api_host_has(const char *base, const char *needle) { + return base && needle && strstr(base, needle) != NULL; +} + +static int is_kimi_api(const char *base) { + return api_host_has(base, "api.moonshot.ai") || + api_host_has(base, "api.moonshot.cn") || + api_host_has(base, "api.kimi.ai") || + api_host_has(base, "api.kimi.com"); +} + +static int is_kimi_k2(const char *model) { + return model && strncmp(model, "kimi-k2", 7) == 0; +} + +static int is_kimi_k27(const char *model) { + return model && strncmp(model, "kimi-k2.7-code", 14) == 0; +} + static int ca_file_ok(const char *path) { if (!path || !path[0]) return 0; @@ -452,8 +471,31 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { return NULL; cJSON *root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "model", req->model); - if (req->reasoning_effort && req->reasoning_effort[0]) + const int kimi = is_kimi_api(req->api_base); + const int kimi_k2 = kimi && is_kimi_k2(req->model); + if (req->reasoning_effort && req->reasoning_effort[0] && !kimi_k2) cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort); + if (kimi_k2) { + /* K2.6/K2.7 do not understand K3's top-level reasoning_effort. They + * expose thinking as an object and require preserved reasoning to be + * sent back on the next turn. K2.7-code is always enabled/kept. */ + cJSON *thinking = cJSON_AddObjectToObject(root, "thinking"); + cJSON_AddStringToObject(thinking, "type", "enabled"); + if (req->send_reasoning || is_kimi_k27(req->model)) + cJSON_AddStringToObject(thinking, "keep", "all"); + } + if (kimi && (!getenv("CST_NO_CACHE_HINTS") || + !getenv("CST_NO_CACHE_HINTS")[0] || + getenv("CST_NO_CACHE_HINTS")[0] == '0')) { + /* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause + * longer than that, so opt into the 1h tier; users can select 5m/1h. */ + const char *ttl = getenv("CST_KIMI_CACHE_TTL"); + if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0)) + ttl = "1h"; + cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options"); + cJSON_AddStringToObject(cache, "mode", "implicit"); + cJSON_AddStringToObject(cache, "ttl", ttl); + } cJSON_AddTrueToObject(root, "stream"); const char *nousage = getenv("CST_NO_STREAM_USAGE"); if (!nousage || !nousage[0] || nousage[0] == '0') { @@ -586,8 +628,10 @@ char *cst_llm_build_body(const cst_llm_request_t *req) { if (req->send_reasoning && m->reasoning && m->reasoning[0]) cJSON_AddItemToObject(o, "reasoning_content", clean_string(m->reasoning, 0)); - if (m->prefix) - cJSON_AddTrueToObject(o, "prefix"); + if (m->prefix) { + /* Kimi calls the OpenAI-style assistant prefill flag `partial`. */ + cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix"); + } } cJSON_AddItemToArray(msgs, o); if (role == CST_ROLE_TOOL && n_att) diff --git a/src/model_registry.c b/src/model_registry.c index e48823f..15647c0 100644 --- a/src/model_registry.c +++ b/src/model_registry.c @@ -876,6 +876,11 @@ const char *cst_effort_wire(const char *provider, const char *model, pthread_mutex_unlock(&g_mu); if (strcmp(mode, "passthrough") == 0) return effort; + if (strcmp(mode, "kimi") == 0) { + /* Kimi K3 accepts low/high/max; the older K2 thinking models use the + * separate `thinking` object and only need a valid effort selection. */ + return strcmp(effort, "medium") == 0 ? "low" : effort; + } if (strcmp(mode, "openai") == 0) { cst_model_info_t mi; cst_model_info(provider, model, &mi); diff --git a/src/models_builtin.json b/src/models_builtin.json index 2ea41f4..0a0eeea 100644 --- a/src/models_builtin.json +++ b/src/models_builtin.json @@ -15,6 +15,23 @@ "prefix_completion": true, "effort": "passthrough" }, + "kimi": { + "api_base": "https://api.moonshot.ai/v1", + "key_env": "MOONSHOT_API_KEY", + "default_model": "kimi-k3", + "models": ["kimi-k3", "kimi-k2.6", "kimi-k2.7-code"], + "reasoning_passback": true, + "effort": "kimi" + }, + "kimi-code": { + "api_base": "https://api.kimi.ai/coding/v1", + "key_env": "KIMI_API_KEY", + "default_model": "k3", + "models": ["k3", "k3-256k", "kimi-for-coding", + "kimi-for-coding-highspeed"], + "reasoning_passback": true, + "effort": "kimi" + }, "openai": { "api_base": "https://api.openai.com/v1", "key_env": "OPENAI_API_KEY", @@ -65,6 +82,16 @@ {"match": "glm", "context": 128000}, {"match": "glm-*v*", "vision": true}, {"match": "kimi", "context": 131072}, + {"match": "kimi-k3", "context": 1048576, "vision": true, + "reasoning": true}, + {"match": "kimi-k2.6", "context": 262144, "vision": true, + "reasoning": true}, + {"match": "kimi-k2.7-code", "context": 262144, "reasoning": true}, + {"match": "k3", "context": 1048576, "vision": true, "reasoning": true}, + {"match": "k3-256k", "context": 262144, "reasoning": true}, + {"match": "kimi-for-coding", "context": 1048576, "reasoning": true}, + {"match": "kimi-for-coding-highspeed", "context": 262144, + "reasoning": true}, {"match": "pixtral", "vision": true}, {"match": "llava", "vision": true}, {"match": "*-vl*", "vision": true}, diff --git a/src/tools/registry.c b/src/tools/registry.c index a1348ab..c9f2a6c 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -159,6 +159,14 @@ static const cst_tool_def_t TOOLS[] = { NULL, {{"todos", S, 1, "the whole list, one item per line"}}, CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META}, + {"web_search", + "Search the live web through the current Kimi or DeepSeek provider and " + "return titles, URLs and snippets. Use web_fetch to read a result.", + NULL, + {{"query", S, 1, "search query"}, + {"count", I, 0, "number of results (default 5, max 20)"}, + {"include_content", B, 0, "include provider-returned page content"}}, + CST_TP_NET, 0, cst_tool_web_search, NULL, NO_DYN, NO_META}, {"web_fetch", "Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML " "is reduced to readable text.", diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index ee9ac94..93c2108 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -55,6 +55,8 @@ int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, char **out); int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); +int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, diff --git a/src/tools/web.c b/src/tools/web.c index ea92bbf..8871d53 100644 --- a/src/tools/web.c +++ b/src/tools/web.c @@ -6,6 +6,7 @@ #include +#include "cJSON.h" #include "cst_agent.h" #include "cst_attach.h" #include "cst_tools.h" @@ -23,6 +24,211 @@ typedef struct { int overflow; } fetch_t; +static size_t on_body(char *p, size_t sz, size_t n, void *ud); + +static const cst_agent_config_t *web_cfg(const cst_tool_ctx_t *ctx) { + return ctx && ctx->agent ? cst_agent_config(ctx->agent) : NULL; +} + +static int provider_api(const cst_tool_ctx_t *ctx, const char **kind, + const char **base, const char **key) { + const cst_agent_config_t *cfg = web_cfg(ctx); + if (!cfg || !cfg->api_key[0]) + return 0; + if (strcmp(cfg->provider, "deepseek") == 0 || + (cfg->api_base[0] && strstr(cfg->api_base, "api.deepseek.com"))) { + *kind = "deepseek"; + } else if (strcmp(cfg->provider, "kimi") == 0 || + (cfg->api_base[0] && strstr(cfg->api_base, "moonshot"))) { + *kind = "kimi"; + } else { + return 0; + } + *base = cfg->api_base; + *key = cfg->api_key; + return 1; +} + +static int api_post_json(const char *url, const char *key, const char *body, + cst_buf_t *out, long *http) { + CURL *curl = curl_easy_init(); + if (!curl) + return -1; + char auth[700]; + snprintf(auth, sizeof(auth), "Authorization: Bearer %s", key); + struct curl_slist *hdrs = NULL; + hdrs = curl_slist_append(hdrs, "Content-Type: application/json"); + hdrs = curl_slist_append(hdrs, "Accept: application/json"); + hdrs = curl_slist_append(hdrs, auth); + fetch_t f = {0}; + cst_curl_apply_ca(curl); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 90L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &f); + CURLcode rc = curl_easy_perform(curl); + *http = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http); + if (f.body.buf) + cst_buf_append(out, f.body.buf, f.body.len); + cst_buf_free(&f.body); + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + return rc == CURLE_OK ? 0 : -1; +} + +static void api_path(char *out, size_t n, const char *base, const char *path) { + size_t len = strlen(base); + while (len && base[len - 1] == '/') + len--; + snprintf(out, n, "%.*s/%s", (int)len, base, path); +} + +static int api_error(cst_tool_result_t *r, long http, const cst_buf_t *raw) { + cJSON *j = cJSON_Parse(raw->buf ? raw->buf : ""); + const cJSON *e = j ? cJSON_GetObjectItemCaseSensitive(j, "error") : NULL; + const cJSON *m = e ? cJSON_GetObjectItemCaseSensitive(e, "message") : NULL; + int rc; + if (cJSON_IsString(m)) + rc = tool_set_resultf(r, 1, "error: HTTP %ld: %s", http, m->valuestring); + else + rc = tool_set_resultf(r, 1, "error: HTTP %ld: %.400s", http, + raw->buf ? raw->buf : "no response"); + cJSON_Delete(j); + return rc; +} + +static int online_fetch(const cst_tool_ctx_t *ctx, const char *url, + long max_out, cst_tool_result_t *r) { + const char *kind, *base, *key; + if (!provider_api(ctx, &kind, &base, &key)) + return 0; + if (max_out <= 0) + max_out = FETCH_DEFAULT_OUT; + if (max_out > FETCH_MAX_OUT) + max_out = FETCH_MAX_OUT; + char endpoint[1024]; + api_path(endpoint, sizeof(endpoint), base, "tools/fetch"); + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "url", url); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + cst_buf_t raw = {0}; + long http = 0; + int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1; + free(json); + if (rc != 0) { + cst_buf_free(&raw); + return tool_set_result(r, "error: online fetch request failed", 1); + } + if (http >= 400) { + int e = api_error(r, http, &raw); + cst_buf_free(&raw); + return e; + } + cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : ""); + if (!doc) { + cst_buf_free(&raw); + return tool_set_result(r, "error: online fetch returned invalid JSON", 1); + } + const cJSON *text = cJSON_GetObjectItemCaseSensitive( + doc, strcmp(kind, "kimi") == 0 ? "markdown" : "content"); + const cJSON *title = cJSON_GetObjectItemCaseSensitive(doc, "title"); + if (!cJSON_IsString(text)) { + cJSON_Delete(doc); + cst_buf_free(&raw); + return tool_set_result(r, "error: online fetch returned no readable content", 1); + } + cst_buf_t out = {0}; + cst_buf_printf(&out, "%s\n\n%s", cJSON_IsString(title) ? title->valuestring : url, + cJSON_IsString(text) ? text->valuestring : ""); + size_t keep = out.len > (size_t)max_out ? cst_utf8_floor(out.buf, (size_t)max_out) + : out.len; + cst_buf_t clipped = {0}; + cst_buf_append(&clipped, out.buf ? out.buf : "", keep); + if (keep < out.len) + cst_buf_puts(&clipped, "\n...(truncated)\n"); + int result = tool_take_buf(r, &clipped, 0); + cst_buf_free(&out); + cJSON_Delete(doc); + cst_buf_free(&raw); + return result; +} + +int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *query = tool_arg(c, "query"); + const char *kind, *base, *key; + if (!query || !query[0]) + return tool_set_result(r, "error: missing query", 1); + if (!provider_api(ctx, &kind, &base, &key)) + return tool_set_result( + r, "error: web_search needs the kimi or deepseek provider", 1); + long count = tool_arg_long(c, "count", 5); + if (count < 1) count = 1; + if (count > 20) count = 20; + int content = tool_arg_bool(c, "include_content", 0); + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, strcmp(kind, "kimi") == 0 ? "text_query" : "query", query); + cJSON_AddNumberToObject(body, strcmp(kind, "kimi") == 0 ? "limit" : "count", count); + if (strcmp(kind, "kimi") == 0) + cJSON_AddBoolToObject(body, "include_content", content); + else + cJSON_AddBoolToObject(body, "need_content", content); + char *json = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + char endpoint[1024]; + api_path(endpoint, sizeof(endpoint), base, "tools/search"); + cst_buf_t raw = {0}; + long http = 0; + int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1; + free(json); + if (rc != 0) { + cst_buf_free(&raw); + return tool_set_result(r, "error: web search request failed", 1); + } + if (http >= 400) { + int e = api_error(r, http, &raw); + cst_buf_free(&raw); + return e; + } + cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : ""); + if (!doc) { + cst_buf_free(&raw); + return tool_set_result(r, "error: web search returned invalid JSON", 1); + } + const cJSON *arr = cJSON_GetObjectItemCaseSensitive( + doc, strcmp(kind, "kimi") == 0 ? "search_results" : "results"); + if (!cJSON_IsArray(arr)) { + cJSON_Delete(doc); + cst_buf_free(&raw); + return tool_set_result(r, "error: web search returned no result list", 1); + } + cst_buf_t out = {0}; + int n = cJSON_GetArraySize(arr); + cst_buf_printf(&out, "web search (%s): %d result%s\n", kind, n, n == 1 ? "" : "s"); + for (int i = 0; i < n; i++) { + const cJSON *item = cJSON_GetArrayItem(arr, i); + const cJSON *title = cJSON_GetObjectItemCaseSensitive(item, "title"); + const cJSON *url = cJSON_GetObjectItemCaseSensitive(item, "url"); + const cJSON *snippet = cJSON_GetObjectItemCaseSensitive( + item, strcmp(kind, "kimi") == 0 ? (content ? "text" : "snippet") : + (content ? "summary" : "snippet")); + cst_buf_printf(&out, "\n[%d] %s\n%s\n%s\n", i + 1, + cJSON_IsString(title) ? title->valuestring : "(untitled)", + cJSON_IsString(url) ? url->valuestring : "", + cJSON_IsString(snippet) ? snippet->valuestring : ""); + } + int result = tool_take_buf(r, &out, 0); + cJSON_Delete(doc); + cst_buf_free(&raw); + return result; +} + static size_t on_body(char *p, size_t sz, size_t n, void *ud) { fetch_t *f = ud; size_t len = sz * n; @@ -206,6 +412,9 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, if (strncasecmp(url, "http://", 7) != 0 && strncasecmp(url, "https://", 8) != 0) return tool_set_result(r, "error: only http:// and https:// URLs", 1); + const char *online_kind, *online_base, *online_key; + if (provider_api(ctx, &online_kind, &online_base, &online_key)) + return online_fetch(ctx, url, tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT), r); long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT); if (max_out <= 0) max_out = FETCH_DEFAULT_OUT; diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index afcb11d..6159f87 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -298,6 +298,77 @@ static void test_cache_hints(void) { cJSON_Delete(j); } +static void test_kimi_wire_format(void) { + const char *old_no_cache = getenv("CST_NO_CACHE_HINTS"); + const char *old_ttl = getenv("CST_KIMI_CACHE_TTL"); + char *saved_no_cache = old_no_cache ? strdup(old_no_cache) : NULL; + char *saved_ttl = old_ttl ? strdup(old_ttl) : NULL; + setenv("CST_NO_CACHE_HINTS", "0", 1); + unsetenv("CST_KIMI_CACHE_TTL"); + + cst_message_t m[3]; + m[0] = msg(CST_ROLE_USER, "think"); + m[1] = msg(CST_ROLE_ASSISTANT, "answer"); + m[1].reasoning = "private reasoning"; + m[2] = msg(CST_ROLE_ASSISTANT, "continue"); + m[2].prefix = 1; + cst_llm_request_t r; + memset(&r, 0, sizeof(r)); + r.api_base = "https://api.moonshot.ai/v1"; + r.model = "kimi-k2.6"; + r.msgs = m; + r.nmsgs = 3; + r.send_reasoning = 1; + cJSON *j = build_req(&r); + const cJSON *thinking = cJSON_GetObjectItemCaseSensitive(j, "thinking"); + EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "type")) && + strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "type")->valuestring, + "enabled") == 0, + "Kimi K2 thinking enabled"); + EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "keep")) && + strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "keep")->valuestring, + "all") == 0, + "Kimi K2 keeps reasoning"); + EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort"), + "Kimi K2 omits reasoning_effort"); + const cJSON *cache = + cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_options"); + EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(cache, "ttl")) && + strcmp(cJSON_GetObjectItemCaseSensitive(cache, "ttl")->valuestring, + "1h") == 0, + "Kimi implicit cache uses the long TTL by default"); + const cJSON *partial = + cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "partial"); + EXPECT(cJSON_IsTrue(partial) && + !cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"), + "Kimi assistant prefill uses partial"); + cJSON_Delete(j); + + r.model = "kimi-k3"; + r.reasoning_effort = "high"; + j = build_req(&r); + EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")) && + strcmp(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")->valuestring, + "high") == 0, + "Kimi K3 uses reasoning_effort"); + EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "thinking"), + "Kimi K3 omits K2 thinking object"); + cJSON_Delete(j); + + if (saved_no_cache) { + setenv("CST_NO_CACHE_HINTS", saved_no_cache, 1); + free(saved_no_cache); + } else { + unsetenv("CST_NO_CACHE_HINTS"); + } + if (saved_ttl) { + setenv("CST_KIMI_CACHE_TTL", saved_ttl, 1); + free(saved_ttl); + } else { + unsetenv("CST_KIMI_CACHE_TTL"); + } +} + static void test_tools_snapshot(void) { cst_message_t m[1] = {msg(CST_ROLE_USER, "go")}; cst_llm_request_t r; @@ -559,6 +630,7 @@ int main(void) { test_document_parts(); test_reasoning_and_prefix(); test_cache_hints(); + test_kimi_wire_format(); test_tools_snapshot(); test_gen_params(); test_escaping();