Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235652604d | ||
|
|
60047ca5ce | ||
|
|
b7034baec4 | ||
|
|
db8cdd7dd1 | ||
|
|
81aeee7d1e | ||
|
|
a99f78134a | ||
|
|
8e5bdd0c9d | ||
|
|
f8030ec201 | ||
|
|
a34c74e453 | ||
|
|
9d7ad9433e | ||
|
|
4c69ef39ab | ||
|
|
da04913071 | ||
|
|
7b2e7a3dbd | ||
|
|
4e990c8e35 | ||
|
|
dae51e06c7 | ||
|
|
042d7f6999 | ||
|
|
2ffe74b37c |
@@ -1,175 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ['v*']
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Verification: TinyCC builds and tests everything in seconds. tcc warns on
|
|
||||||
# `#pragma GCC`, so no -Werror here; the gcc release builds below carry it.
|
|
||||||
tcc-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
env:
|
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
|
||||||
-o /tmp/ci_checkout.sh
|
|
||||||
bash /tmp/ci_checkout.sh
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
SUDO=$(command -v sudo || true)
|
|
||||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
|
||||||
$SUDO apt-get update
|
|
||||||
$SUDO apt-get install -y --no-install-recommends \
|
|
||||||
build-essential pkg-config python3 curl ca-certificates \
|
|
||||||
libcurl4-openssl-dev libncurses-dev
|
|
||||||
- name: Build TinyCC
|
|
||||||
run: bash scripts/ci_tcc.sh
|
|
||||||
- name: Build
|
|
||||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)"
|
|
||||||
- name: Unit tests
|
|
||||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
|
||||||
- name: Smoke test
|
|
||||||
run: |
|
|
||||||
./cst-agent --help
|
|
||||||
./cst-agent --workspace "$PWD" --list-sessions
|
|
||||||
./cst-agent --demo-ctl --workspace .
|
|
||||||
- name: WebUI end-to-end (recorded stream, no API key)
|
|
||||||
run: sh scripts/e2e_webui.sh
|
|
||||||
|
|
||||||
# Release: static musl binaries, gcc with -Werror.
|
|
||||||
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
|
|
||||||
linux:
|
|
||||||
needs: tcc-check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: x86_64
|
|
||||||
check: 1
|
|
||||||
- arch: x86
|
|
||||||
check: 1
|
|
||||||
- arch: aarch64
|
|
||||||
check: 0
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
env:
|
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
|
||||||
-o /tmp/ci_checkout.sh
|
|
||||||
bash /tmp/ci_checkout.sh
|
|
||||||
- name: Install host tools
|
|
||||||
run: |
|
|
||||||
SUDO=$(command -v sudo || true)
|
|
||||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
|
||||||
$SUDO apt-get update
|
|
||||||
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
|
|
||||||
$SUDO apt-get install -y --no-install-recommends \
|
|
||||||
build-essential cmake ninja-build pkg-config python3 perl \
|
|
||||||
curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
|
|
||||||
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
|
||||||
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
|
||||||
fi
|
|
||||||
- name: Build (static, -Werror)
|
|
||||||
env:
|
|
||||||
CST_LINUX_CHECK: ${{ matrix.check }}
|
|
||||||
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
|
||||||
- name: Smoke test
|
|
||||||
run: |
|
|
||||||
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
|
|
||||||
file "$d/cst-agent"
|
|
||||||
run=""
|
|
||||||
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
|
||||||
$run "$d/cst-agent" --version
|
|
||||||
$run "$d/cst-agent" --help >/dev/null
|
|
||||||
$run "$d/cst-agent" --workspace "$PWD" --list-sessions
|
|
||||||
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256
|
|
||||||
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
|
|
||||||
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
|
|
||||||
- name: Publish Gitea Release
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CST_GITEA: ${{ github.server_url }}
|
|
||||||
CST_REPO: ${{ github.repository }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
CI: "true"
|
|
||||||
run: |
|
|
||||||
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
|
||||||
if [[ -z "${GITEA_TOKEN}" ]]; then
|
|
||||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
bash scripts/release.sh dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
|
|
||||||
|
|
||||||
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
|
|
||||||
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
|
|
||||||
windows:
|
|
||||||
needs: tcc-check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 120
|
|
||||||
container: debian:trixie
|
|
||||||
steps:
|
|
||||||
- name: Install tools
|
|
||||||
run: |
|
|
||||||
# No CA certificates in the image yet, so the mirror is plain http.
|
|
||||||
sed -i 's#https\?://deb.debian.org#http://mirrors.tuna.tsinghua.edu.cn#g' \
|
|
||||||
/etc/apt/sources.list.d/debian.sources
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates curl git build-essential cmake ninja-build make \
|
|
||||||
pkg-config python3 perl zip unzip xz-utils patch file \
|
|
||||||
gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis
|
|
||||||
- name: Checkout
|
|
||||||
env:
|
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
run: |
|
|
||||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
|
||||||
-o /tmp/ci_checkout.sh
|
|
||||||
bash /tmp/ci_checkout.sh
|
|
||||||
- name: Cross-build zip and installer
|
|
||||||
env:
|
|
||||||
CST_WIN_CFLAGS: -Wall -Wextra -Werror
|
|
||||||
run: bash scripts/ci_windows_cross.sh
|
|
||||||
- name: Verify
|
|
||||||
run: |
|
|
||||||
ls -lah dist/windows-x86_64/
|
|
||||||
file dist/windows-x86_64/cst-agent.exe dist/cst-agent-*-windows-x86_64-setup.exe
|
|
||||||
cat dist/cst-agent-*-windows-x86_64.zip.sha256 dist/cst-agent-*-windows-x86_64-setup.exe.sha256
|
|
||||||
unzip -l dist/cst-agent-*-windows-x86_64.zip
|
|
||||||
- name: Publish Gitea Release
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CST_GITEA: ${{ github.server_url }}
|
|
||||||
CST_REPO: ${{ github.repository }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
|
||||||
CI: "true"
|
|
||||||
run: |
|
|
||||||
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
|
||||||
if [[ -z "${GITEA_TOKEN}" ]]; then
|
|
||||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
bash scripts/release.sh dist/cst-agent-*-windows-x86_64.zip \
|
|
||||||
dist/cst-agent-*-windows-x86_64-setup.exe
|
|
||||||
@@ -28,6 +28,10 @@ third_party/busybox-src/
|
|||||||
third_party/prefix-mingw/
|
third_party/prefix-mingw/
|
||||||
third_party/prefix-linux-*/
|
third_party/prefix-linux-*/
|
||||||
third_party/build-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/prefix-android-*/
|
||||||
third_party/build-android-*/
|
third_party/build-android-*/
|
||||||
third_party/src/
|
third_party/src/
|
||||||
@@ -49,3 +53,6 @@ app/android/local.properties
|
|||||||
|
|
||||||
# the engine as a static library (app/ links it; built by `make`)
|
# the engine as a static library (app/ links it; built by `make`)
|
||||||
libcstcore.a
|
libcstcore.a
|
||||||
|
|
||||||
|
# nix-build
|
||||||
|
result
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
|
|||||||
```
|
```
|
||||||
|
|
||||||
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
||||||
|
### 桌面 GUI(Qt/QML,可选)
|
||||||
|
|
||||||
|
把引擎放进同一个进程的窗口前端(无端口、无第二个进程):`app/`,细节见 [app/README.md](app/README.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NixOS / nix:装进用户 profile(PATH + 菜单项 + 图标),卸载 nix profile remove cst-agent
|
||||||
|
nix profile add --file .
|
||||||
|
|
||||||
|
# 其他发行版(需要 Qt 6 的 Quick/Qml/QuickControls2 与 libcurl)
|
||||||
|
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build-app -j && cmake --install build-app --component app --prefix ~/.local
|
||||||
|
```
|
||||||
|
|
||||||
|
菜单里启动时工作区=启动目录(即 `$HOME`);在项目目录里 `cst-agent-app` 启动则工作区就是那个目录。
|
||||||
|
|
||||||
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
||||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
||||||
@@ -213,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` |
|
| `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` |
|
| `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` |
|
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||||
|
|
||||||
@@ -225,7 +242,11 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|
|||||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
||||||
|
|
||||||
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
|
“上次使用”记在配置目录的 `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)。
|
详见 [docs/env_openai.md](docs/env_openai.md)。
|
||||||
|
|
||||||
@@ -300,6 +321,7 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
||||||
|
| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 |
|
||||||
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
||||||
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
||||||
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
||||||
@@ -310,7 +332,8 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
| `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`) |
|
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||||
|
|
||||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||||
|
|||||||
+31
-4
@@ -14,6 +14,10 @@ project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
|
|||||||
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
|
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
|
||||||
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
|
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
|
||||||
#
|
#
|
||||||
|
# Desktop install (the window, its launcher entry and the icon; the CLI has
|
||||||
|
# its own installer) -- default.nix installs exactly this component:
|
||||||
|
# cmake --install build --component app --prefix ~/.local
|
||||||
|
#
|
||||||
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
|
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
|
||||||
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
||||||
|
|
||||||
@@ -39,8 +43,10 @@ if(NOT TARGET cstcore)
|
|||||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||||
"${CST_APP_ROOT}/src/net.c"
|
"${CST_APP_ROOT}/src/net.c"
|
||||||
"${CST_APP_ROOT}/src/http_srv.c"
|
"${CST_APP_ROOT}/src/http_srv.c"
|
||||||
"${CST_APP_ROOT}/src/webui.c"
|
"${CST_APP_ROOT}/src/webui.c")
|
||||||
"${CST_APP_ROOT}/src/web_md.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")
|
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
||||||
include_directories("${CST_APP_ROOT}/.gen")
|
include_directories("${CST_APP_ROOT}/.gen")
|
||||||
endif()
|
endif()
|
||||||
@@ -85,10 +91,12 @@ if(CST_APP_GUI)
|
|||||||
qt_policy(SET QTP0004 NEW)
|
qt_policy(SET QTP0004 NEW)
|
||||||
# The front end's platform tools: one file per platform, the same split
|
# The front end's platform tools: one file per platform, the same split
|
||||||
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
||||||
# "app:android"); the other platforms still use the Linux file, whose
|
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
|
||||||
# registration entry point is simply not called there.
|
# the rest use the Linux file.
|
||||||
if(ANDROID)
|
if(ANDROID)
|
||||||
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
||||||
|
elseif(WIN32)
|
||||||
|
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
|
||||||
else()
|
else()
|
||||||
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
||||||
endif()
|
endif()
|
||||||
@@ -96,9 +104,11 @@ if(CST_APP_GUI)
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/core/AgentCore.cpp
|
src/core/AgentCore.cpp
|
||||||
src/core/ToolProvider.cpp
|
src/core/ToolProvider.cpp
|
||||||
|
src/core/PdfTools.cpp
|
||||||
src/ui/UiTest.cpp
|
src/ui/UiTest.cpp
|
||||||
src/ui/ThemeIcons.cpp
|
src/ui/ThemeIcons.cpp
|
||||||
src/ui/SessionImages.cpp
|
src/ui/SessionImages.cpp
|
||||||
|
src/ui/BackKey.cpp
|
||||||
${CST_APP_PLATFORM_SRC})
|
${CST_APP_PLATFORM_SRC})
|
||||||
target_include_directories(cst-agent-app PRIVATE
|
target_include_directories(cst-agent-app PRIVATE
|
||||||
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
||||||
@@ -119,6 +129,23 @@ if(CST_APP_GUI)
|
|||||||
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# A desktop install. The icon goes where the theme layout says, so the
|
||||||
|
# entry's Icon= name finds it, and the entry's file name is the app_id Qt
|
||||||
|
# sets (QGuiApplication::applicationName) -- rename one and the launcher
|
||||||
|
# loses the icon. All of it in the "app" component: the CLI keeps coming
|
||||||
|
# from scripts/bootstrap_linux.sh, and two `cst-agent` in PATH is how a
|
||||||
|
# version nobody meant to run gets run.
|
||||||
|
if(NOT ANDROID)
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
install(TARGETS cst-agent-app
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app)
|
||||||
|
install(FILES linux/cst-agent-app.desktop
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app)
|
||||||
|
install(FILES linux/cst-agent-app.svg
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps
|
||||||
|
COMPONENT app)
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- Android (APK) -----------------------------------------------------
|
# --- Android (APK) -----------------------------------------------------
|
||||||
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
|
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
|
||||||
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
|
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
|
||||||
|
|||||||
@@ -45,6 +45,31 @@ navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
|
|||||||
change. Point the app at a Kirigami outside the Qt prefix with
|
change. Point the app at a Kirigami outside the Qt prefix with
|
||||||
`-DCST_APP_QML_IMPORT=/path/to/qml`.
|
`-DCST_APP_QML_IMPORT=/path/to/qml`.
|
||||||
|
|
||||||
|
## Install (Linux desktop)
|
||||||
|
|
||||||
|
The window installs itself: the binary, a launcher entry and the icon the
|
||||||
|
launcher draws for it. All three are one CMake component (`app`), so the CLI
|
||||||
|
keeps coming from `scripts/bootstrap_linux.sh` and the profile never holds two
|
||||||
|
`cst-agent` of different vintages; the files live in `app/linux/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# NixOS / nix users: PATH + menu entry + icon. The Qt apps hook bakes the
|
||||||
|
# plugin and QML module paths into the wrapper, so the app runs outside a
|
||||||
|
# nix-shell and nix-collect-garbage cannot pull Qt out from under it.
|
||||||
|
nix-build # ./result/bin/cst-agent-app, run it in place
|
||||||
|
nix profile add --file . # or: ./result/bin/cst-agent-app
|
||||||
|
nix profile remove cst-agent # uninstall
|
||||||
|
|
||||||
|
# anywhere with Qt 6 (Quick/Qml/QuickControls2) and libcurl:
|
||||||
|
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build-app -j
|
||||||
|
cmake --install build-app --component app --prefix ~/.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Launched from a menu, the workspace is the directory the app starts in
|
||||||
|
(`$HOME` there), so sessions land in `~/.agent/sessions`; pass
|
||||||
|
`--workspace <dir>` to pin another one.
|
||||||
|
|
||||||
## Why in one process
|
## Why in one process
|
||||||
|
|
||||||
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
||||||
@@ -125,6 +150,13 @@ creates and deletes sessions. On a machine without a screen,
|
|||||||
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
||||||
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
||||||
|
|
||||||
|
Every line the run prints also goes to `uitest.log` in the workspace, and the
|
||||||
|
verdict is the last line of it. That is how a run *on a phone* is read back:
|
||||||
|
stdout reaches nobody there, an app started from adb cannot be handed
|
||||||
|
environment variables, and logcat does not carry this build's Qt messages --
|
||||||
|
so `--uitest --workspace /sdcard/cst-uitest` plus
|
||||||
|
`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip.
|
||||||
|
|
||||||
## Provider, model, key
|
## Provider, model, key
|
||||||
|
|
||||||
The sidebar shows where the engine points (`provider · model`); the gear in the
|
The sidebar shows where the engine points (`provider · model`); the gear in the
|
||||||
@@ -271,6 +303,26 @@ and two buttons.
|
|||||||
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
||||||
and checks a name the session does not own is refused.
|
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
|
## Transcript, tool calls, streaming text
|
||||||
|
|
||||||
- A tool call is a card of its own, and the call **id** is what pairs it with
|
- A tool call is a card of its own, and the call **id** is what pairs it with
|
||||||
@@ -408,6 +460,7 @@ so nothing about it is visible when the app is idle.
|
|||||||
file card's open/share through the app's FileProvider
|
file card's open/share through the app's FileProvider
|
||||||
- [x] a turn keeps running with the app off screen (Android foreground service
|
- [x] a turn keeps running with the app off screen (Android foreground service
|
||||||
+ wake lock; nothing to do on a desktop)
|
+ wake lock; nothing to do on a desktop)
|
||||||
|
- [x] desktop install: launcher entry + icon, `nix profile add --file .`
|
||||||
- [ ] a real turn driven through the window (needs a provider key)
|
- [ ] a real turn driven through the window (needs a provider key)
|
||||||
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
||||||
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=CST-agent
|
||||||
|
GenericName=Local coding agent
|
||||||
|
Comment=The engine in a window: workspace is the directory it is started in
|
||||||
|
Exec=cst-agent-app
|
||||||
|
TryExec=cst-agent-app
|
||||||
|
Icon=cst-agent-app
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
# Qt puts the application name in the Wayland app_id / WM_CLASS, so this has to
|
||||||
|
# stay the file's own name or the launcher draws a generic icon.
|
||||||
|
StartupWMClass=cst-agent-app
|
||||||
|
Categories=Development;Utility;
|
||||||
|
Keywords=agent;ai;assistant;code;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- The launcher icon: the prompt the CLI shows, on a tile in the window's own
|
||||||
|
palette (app/qml/Theme.qml). Installed under hicolor/scalable, so the
|
||||||
|
desktops that want raster sizes render it themselves. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<rect x="6" y="6" width="116" height="116" rx="26" ry="26"
|
||||||
|
fill="#15181a" stroke="#343b41" stroke-width="5"/>
|
||||||
|
<path d="M36 42 L56 64 L36 86" fill="none" stroke="#3daee9" stroke-width="12"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M69 88 H94" fill="none" stroke="#eff0f1" stroke-width="12"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 712 B |
+53
-3
@@ -201,18 +201,25 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- the shell -------------------------------------------------------
|
// ---- the shell -------------------------------------------------------
|
||||||
header: ToolBar {
|
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
|
||||||
|
* to a style-owned contentItem, and on this phone that item sat 35 px below
|
||||||
|
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
|
||||||
|
* was drawn, and clicked, outside the header, over the sidebar. A
|
||||||
|
* Rectangle puts its children where it is (checked with --uitest on the
|
||||||
|
* device and on both styles at home). */
|
||||||
|
header: Rectangle {
|
||||||
id: appBar
|
id: appBar
|
||||||
|
objectName: "appBar"
|
||||||
implicitHeight: Theme.headerHeight
|
implicitHeight: Theme.headerHeight
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.surface
|
color: Theme.surface
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 1
|
height: 1
|
||||||
color: Theme.border
|
color: Theme.border
|
||||||
}
|
}
|
||||||
}
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: Theme.pad
|
anchors.leftMargin: Theme.pad
|
||||||
@@ -367,6 +374,7 @@ ApplicationWindow {
|
|||||||
|
|
||||||
ChatView {
|
ChatView {
|
||||||
id: chat
|
id: chat
|
||||||
|
objectName: "chatView"
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
model: transcript
|
model: transcript
|
||||||
@@ -499,6 +507,42 @@ ApplicationWindow {
|
|||||||
win.statusText = ""
|
win.statusText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* The back key a phone has instead of a mouse: it closes what is open, one
|
||||||
|
* layer at a time -- the dialog on top, then the sliding sidebar. With
|
||||||
|
* nothing open the key goes on to the platform, and a phone leaves the
|
||||||
|
* window (Qt finishes the activity), which is right for the last layer.
|
||||||
|
*
|
||||||
|
* Which layer that is, and whether there is one, lives in one place: the
|
||||||
|
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
|
||||||
|
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
|
||||||
|
* lets through. --uitest reads the same two functions. */
|
||||||
|
function backLayer() {
|
||||||
|
if (settings.visible)
|
||||||
|
return "settings"
|
||||||
|
if (ask.visible)
|
||||||
|
return "ask"
|
||||||
|
if (consent.visible)
|
||||||
|
return "consent"
|
||||||
|
if (win.navOpen)
|
||||||
|
return "drawer"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One layer back. The dialogs are closed on the next turn of the event
|
||||||
|
* loop: closing one from inside the key event leaves its modal overlay up
|
||||||
|
* in this Qt (the dialog says it is closed, the overlay still eats every
|
||||||
|
* click after it), which --uitest walks straight into. */
|
||||||
|
function stepBack() {
|
||||||
|
if (settings.visible)
|
||||||
|
Qt.callLater(settings.close)
|
||||||
|
else if (ask.visible)
|
||||||
|
Qt.callLater(ask.close)
|
||||||
|
else if (consent.visible)
|
||||||
|
Qt.callLater(consent.close)
|
||||||
|
else if (win.navOpen)
|
||||||
|
win.navOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- engine -> UI ----------------------------------------------------
|
// ---- engine -> UI ----------------------------------------------------
|
||||||
/* The signal handlers only forward: every one of them has a function
|
/* The signal handlers only forward: every one of them has a function
|
||||||
@@ -650,6 +694,12 @@ ApplicationWindow {
|
|||||||
ask.open()
|
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 onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||||
|
|
||||||
function onRunningChanged() {
|
function onRunningChanged() {
|
||||||
|
|||||||
+51
-3
@@ -29,6 +29,55 @@ Rectangle {
|
|||||||
: state === "unfinished" ? "no result"
|
: state === "unfinished" ? "no result"
|
||||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||||
|
|
||||||
|
/* How long this call has been going. A tool that takes minutes -- a
|
||||||
|
* build, a wide search, a command nobody can predict -- must not read as
|
||||||
|
* a window that has hung: while the card runs, its chip counts. */
|
||||||
|
property double startedAt: 0
|
||||||
|
property int runningFor: 0
|
||||||
|
|
||||||
|
function elapsedText() {
|
||||||
|
const s = runningFor
|
||||||
|
if (s < 60)
|
||||||
|
return s + "s"
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m < 60)
|
||||||
|
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
|
||||||
|
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The clock starts when the card starts running -- and a card that is
|
||||||
|
* already running when it appears (a replayed transcript) starts one too,
|
||||||
|
* hence Component.onCompleted. */
|
||||||
|
function noteRunning() {
|
||||||
|
if (running && startedAt === 0)
|
||||||
|
startedAt = Date.now()
|
||||||
|
else if (!running)
|
||||||
|
startedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: noteRunning()
|
||||||
|
Component.onCompleted: noteRunning()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: card.running && card.startedAt > 0
|
||||||
|
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the chip says: the state, how long it has been running (from five
|
||||||
|
* seconds on -- before that it is noise), or the exit code once there is
|
||||||
|
* a result. "no result" is not an outcome, so no exit code belongs on
|
||||||
|
* it. */
|
||||||
|
function chipText() {
|
||||||
|
if (running)
|
||||||
|
return runningFor >= 5
|
||||||
|
? stateText + " · " + elapsedText()
|
||||||
|
: stateText
|
||||||
|
return stateText + (hasResult && state !== "unfinished"
|
||||||
|
? " · exit " + exitCode : "")
|
||||||
|
}
|
||||||
|
|
||||||
function lines() {
|
function lines() {
|
||||||
const all = output.split("\n")
|
const all = output.split("\n")
|
||||||
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
||||||
@@ -92,10 +141,9 @@ Rectangle {
|
|||||||
implicitHeight: chip.implicitHeight + 2
|
implicitHeight: chip.implicitHeight + 2
|
||||||
Text {
|
Text {
|
||||||
id: chip
|
id: chip
|
||||||
|
objectName: "toolChip"
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
/* "no result" is not an outcome: no exit code belongs on it. */
|
text: card.chipText()
|
||||||
text: card.stateText + (card.hasResult && card.state !== "unfinished"
|
|
||||||
? " · exit " + card.exitCode : "")
|
|
||||||
color: card.stateColor
|
color: card.stateColor
|
||||||
font.pixelSize: Theme.smallSize
|
font.pixelSize: Theme.smallSize
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-15
@@ -45,6 +45,24 @@ class Turn : public QThread {
|
|||||||
QByteArray m_prompt;
|
QByteArray m_prompt;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* The engine asks for a tool only when its own confirm_shell / confirm_write /
|
||||||
|
* confirm_net flag says so (`needs_confirm()`, src/agent_loop.c) -- the
|
||||||
|
* approval mode on top of that only *relaxes* them (AUTO writes a file
|
||||||
|
* without asking, YOLO everything). The CLI fills those three in
|
||||||
|
* (src/main.c: parse_confirm), the TUI and the WebUI do too; the window never
|
||||||
|
* did: AgentCore::open() zeroed the whole config and cst_agent_create() keeps
|
||||||
|
* what it is given, so every tool ran without asking while the sidebar said
|
||||||
|
* "ask". One mapping, used by both open() and setApproval(). */
|
||||||
|
namespace {
|
||||||
|
void applyApproval(cst_agent_config_t *cfg, int approval) {
|
||||||
|
const int ask = (approval == CST_APPROVAL_ASK);
|
||||||
|
cfg->approval = approval;
|
||||||
|
cfg->confirm_shell = ask;
|
||||||
|
cfg->confirm_write = ask;
|
||||||
|
cfg->confirm_net = ask;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
/* --- lifetime ------------------------------------------------------------ */
|
/* --- lifetime ------------------------------------------------------------ */
|
||||||
|
|
||||||
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
|
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
|
||||||
@@ -141,6 +159,14 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
|||||||
/* The GUI answers itself: never let the engine decide behind its back. */
|
/* The GUI answers itself: never let the engine decide behind its back. */
|
||||||
m_cfg.auto_approve = 0;
|
m_cfg.auto_approve = 0;
|
||||||
m_cfg.headless = 0;
|
m_cfg.headless = 0;
|
||||||
|
/* What the person chose last -- the CLI and the TUI remember it the same
|
||||||
|
* way and the TUI writes the same file -- else "ask", which is what the
|
||||||
|
* window's default label promises and the only mode where its consent
|
||||||
|
* dialog is ever reached. */
|
||||||
|
int approval = CST_APPROVAL_ASK;
|
||||||
|
if (have_settings && st.approval[0])
|
||||||
|
cst_approval_parse(st.approval, &approval);
|
||||||
|
applyApproval(&m_cfg, approval);
|
||||||
if (!m_cfg.mode[0])
|
if (!m_cfg.mode[0])
|
||||||
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
|
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
|
||||||
m_agent = cst_agent_create(&m_cfg);
|
m_agent = cst_agent_create(&m_cfg);
|
||||||
@@ -189,6 +215,12 @@ void AgentCore::close() {
|
|||||||
/* --- turns --------------------------------------------------------------- */
|
/* --- turns --------------------------------------------------------------- */
|
||||||
|
|
||||||
namespace {
|
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"
|
/* The first line of a prompt, cut short: the line the phone's "working"
|
||||||
* notification carries while the turn runs (app::holdForeground). */
|
* notification carries while the turn runs (app::holdForeground). */
|
||||||
QString firstLine(const QString &s) {
|
QString firstLine(const QString &s) {
|
||||||
@@ -224,9 +256,28 @@ void AgentCore::cancel() {
|
|||||||
cst_agent_request_cancel(m_agent);
|
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<QGuiApplication *>(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) {
|
void AgentCore::finishTurn(int rc, const QString &err) {
|
||||||
Turn *t = static_cast<Turn *>(sender());
|
Turn *t = static_cast<Turn *>(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] {
|
post([this, rc, err] {
|
||||||
|
m_turn = nullptr;
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
||||||
/* The prompt carried whatever was pending; the engine dropped it. */
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
||||||
@@ -234,11 +285,6 @@ void AgentCore::finishTurn(int rc, const QString &err) {
|
|||||||
emit pendingChanged();
|
emit pendingChanged();
|
||||||
emit turnFinished(rc, err);
|
emit turnFinished(rc, err);
|
||||||
});
|
});
|
||||||
if (t) {
|
|
||||||
t->wait();
|
|
||||||
t->deleteLater();
|
|
||||||
}
|
|
||||||
m_turn = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::setRunning(bool on) {
|
void AgentCore::setRunning(bool on) {
|
||||||
@@ -275,16 +321,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail,
|
|||||||
return CST_PERM_DENY;
|
return CST_PERM_DENY;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return CST_PERM_ONCE;
|
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);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_permAnswer = CST_PERM_DENY;
|
self->m_permAnswer = CST_PERM_DENY;
|
||||||
self->m_permPending = true;
|
self->m_permPending = true;
|
||||||
const QString t = QString::fromUtf8(tool ? tool : "");
|
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||||
const QString p = QString::fromUtf8(preview ? preview : "");
|
const QString p = QString::fromUtf8(preview ? preview : "");
|
||||||
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
||||||
while (self->m_permPending && !self->m_closing)
|
/* And even in the foreground nobody may hang forever: a dialog can be
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
* 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;
|
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;
|
return self->m_permAnswer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,16 +368,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
|
|||||||
return -1;
|
return -1;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return -1;
|
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);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_questionPending = true;
|
self->m_questionPending = true;
|
||||||
self->m_answer.clear();
|
self->m_answer.clear();
|
||||||
const QString q = QString::fromUtf8(question ? question : "");
|
const QString q = QString::fromUtf8(question ? question : "");
|
||||||
const QString o = QString::fromUtf8(options ? options : "");
|
const QString o = QString::fromUtf8(options ? options : "");
|
||||||
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
||||||
while (self->m_questionPending && !self->m_closing)
|
bool expired = false;
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
while (self->m_questionPending && !self->m_closing) {
|
||||||
|
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
|
||||||
|
expired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (self->m_closing)
|
if (self->m_closing)
|
||||||
return -1;
|
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());
|
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -324,9 +421,12 @@ void AgentCore::setMode(const QString &mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::setApproval(int approval) {
|
void AgentCore::setApproval(int approval) {
|
||||||
m_cfg.approval = approval;
|
applyApproval(&m_cfg, approval);
|
||||||
if (m_agent)
|
if (m_agent)
|
||||||
cst_agent_config_mut(m_agent)->approval = approval;
|
applyApproval(cst_agent_config_mut(m_agent), approval);
|
||||||
|
/* Remembered like the TUI's /approval: the next start of the window (or of
|
||||||
|
* the CLI) comes up the way it was left. */
|
||||||
|
cst_settings_remember_approval(approval);
|
||||||
emit approvalChanged();
|
emit approvalChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,29 +630,56 @@ QString AgentCore::shareArtifact(const QString &rel) {
|
|||||||
|
|
||||||
QVariantList AgentCore::sessions() const {
|
QVariantList AgentCore::sessions() const {
|
||||||
QVariantList out;
|
QVariantList out;
|
||||||
if (m_workspace.isEmpty())
|
if (m_workspace.isEmpty()) {
|
||||||
|
m_sessionCache.clear();
|
||||||
return out;
|
return out;
|
||||||
|
}
|
||||||
const QByteArray ws = m_workspace.toUtf8();
|
const QByteArray ws = m_workspace.toUtf8();
|
||||||
const int kMax = 200;
|
const int kMax = 200;
|
||||||
char(*ids)[128] = new char[kMax][128];
|
char(*ids)[128] = new char[kMax][128];
|
||||||
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
||||||
const QString current = sessionId();
|
const QString current = sessionId();
|
||||||
|
QHash<QString, SessionCache> fresh;
|
||||||
|
fresh.reserve(n);
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
|
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;
|
cst_session_info_t in;
|
||||||
memset(&in, 0, sizeof(in));
|
memset(&in, 0, sizeof(in));
|
||||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||||
continue;
|
continue;
|
||||||
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
entry.mtime = mtime;
|
||||||
|
entry.size = size;
|
||||||
QVariantMap m;
|
QVariantMap m;
|
||||||
m["id"] = id;
|
m["id"] = id;
|
||||||
m["title"] = utf8(in.title);
|
m["title"] = utf8(in.title);
|
||||||
m["firstUser"] = utf8(in.first_user);
|
m["firstUser"] = utf8(in.first_user);
|
||||||
m["messages"] = in.messages;
|
m["messages"] = in.messages;
|
||||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||||
|
entry.row = m;
|
||||||
|
}
|
||||||
|
QVariantMap m = entry.row;
|
||||||
m["current"] = (id == current);
|
m["current"] = (id == current);
|
||||||
out.append(m);
|
out.append(m);
|
||||||
|
fresh.insert(id, entry);
|
||||||
}
|
}
|
||||||
delete[] ids;
|
delete[] ids;
|
||||||
|
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +837,9 @@ bool AgentCore::deleteSession(const QString &id) {
|
|||||||
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
||||||
if (id.isEmpty() || m_workspace.isEmpty())
|
if (id.isEmpty() || m_workspace.isEmpty())
|
||||||
return false;
|
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(),
|
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
||||||
t = title.toUtf8();
|
t = title.toUtf8();
|
||||||
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
||||||
@@ -1138,7 +1268,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 id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||||
const QString n = QString::fromUtf8(call ? call->name : "");
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
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,
|
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#define CST_APP_AGENTCORE_H
|
#define CST_APP_AGENTCORE_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
@@ -204,6 +205,11 @@ class AgentCore : public QObject {
|
|||||||
void permissionRequested(const QString &tool, const QString &detail,
|
void permissionRequested(const QString &tool, const QString &detail,
|
||||||
const QString &preview);
|
const QString &preview);
|
||||||
void questionAsked(const QString &question, const QString &options);
|
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 turnFinished(int rc, const QString &err);
|
||||||
void runningChanged();
|
void runningChanged();
|
||||||
void workspaceChanged();
|
void workspaceChanged();
|
||||||
@@ -218,6 +224,10 @@ class AgentCore : public QObject {
|
|||||||
void syncConfig();
|
void syncConfig();
|
||||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||||
void afterSessionChange();
|
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). */
|
/* Hook trampolines (C, run on the worker thread). */
|
||||||
static void hookStatus(const char *msg, void *ud);
|
static void hookStatus(const char *msg, void *ud);
|
||||||
@@ -255,6 +265,15 @@ class AgentCore : public QObject {
|
|||||||
bool m_permPending = false;
|
bool m_permPending = false;
|
||||||
QString m_question;
|
QString m_question;
|
||||||
QString m_todo;
|
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<QString, SessionCache> m_sessionCache;
|
||||||
/* Names of the files this window attached, in the engine's pending order:
|
/* 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. */
|
* the engine's own description says what it is, not where it came from. */
|
||||||
QStringList m_attachNames;
|
QStringList m_attachNames;
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/* 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 <QByteArray>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QMarginsF>
|
||||||
|
#include <QPageLayout>
|
||||||
|
#include <QPageSize>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPdfWriter>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QString>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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<size_t>(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) {
|
||||||
|
std::unique_ptr<QTextDocument> doc(paperDocument(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
|
||||||
|
|
||||||
|
/* The document writePdf lays out, with a colour filled in for text that names
|
||||||
|
* none of its own.
|
||||||
|
*
|
||||||
|
* Paper is white and has no theme, but a QTextDocument takes its default text
|
||||||
|
* colour from the application palette: in a window running a dark palette --
|
||||||
|
* which is what a phone in dark mode hands Qt -- every plain markdown
|
||||||
|
* paragraph came out #eeeeee on a white page, i.e. a PDF that looks blank
|
||||||
|
* when printed, and reads as "the font is oddly faint" on screen. Naming the
|
||||||
|
* colour makes the page depend on the paper rather than on the window. A
|
||||||
|
* colour the document does name still wins (an .html file somebody handed us
|
||||||
|
* keeps its own), so this only fills the holes. */
|
||||||
|
QTextDocument *paperDocument(const QString &html) {
|
||||||
|
auto *doc = new QTextDocument;
|
||||||
|
doc->setDefaultStyleSheet(QStringLiteral("body { color: #000000; }"));
|
||||||
|
doc->setHtml(html);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -22,6 +22,8 @@ int registerPlatformTools(AgentCore *core) {
|
|||||||
#elif defined(__linux__)
|
#elif defined(__linux__)
|
||||||
n += platformRegisterLinux(core);
|
n += platformRegisterLinux(core);
|
||||||
#endif
|
#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
|
/* A service may have appeared or gone since the last probe (a desktop
|
||||||
* session starting, the notification daemon coming up). */
|
* session starting, the notification daemon coming up). */
|
||||||
cst_tool_meta_reload();
|
cst_tool_meta_reload();
|
||||||
@@ -35,6 +37,7 @@ void unregisterPlatformTools(AgentCore *core) {
|
|||||||
core->unregisterOwner(QStringLiteral("app:windows"));
|
core->unregisterOwner(QStringLiteral("app:windows"));
|
||||||
core->unregisterOwner(QStringLiteral("app:macos"));
|
core->unregisterOwner(QStringLiteral("app:macos"));
|
||||||
core->unregisterOwner(QStringLiteral("app:android"));
|
core->unregisterOwner(QStringLiteral("app:android"));
|
||||||
|
core->unregisterOwner(QStringLiteral("app:qt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
||||||
* travels through this header, so the class stays incomplete here. */
|
* travels through this header, so the class stays incomplete here. */
|
||||||
class AgentCore;
|
class AgentCore;
|
||||||
|
/* Same for the document md_to_pdf lays out (PdfTools.cpp): the caller owns
|
||||||
|
* it, and the test in ui/UiTest.cpp inspects how it draws. */
|
||||||
|
class QTextDocument;
|
||||||
|
|
||||||
namespace app {
|
namespace app {
|
||||||
|
|
||||||
@@ -44,6 +47,14 @@ extern const char *const kDesktopOnly[];
|
|||||||
/* The platform tools this app provides. Called once the engine exists;
|
/* The platform tools this app provides. Called once the engine exists;
|
||||||
* returns how many were registered. */
|
* returns how many were registered. */
|
||||||
int registerPlatformTools(AgentCore *core);
|
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);
|
||||||
|
/* The document md_to_pdf lays out from `html`: the page's own defaults
|
||||||
|
* applied (text with no colour of its own is black, not the window's palette
|
||||||
|
* colour). The caller owns the result. */
|
||||||
|
QTextDocument *paperDocument(const QString &html);
|
||||||
/* Drop them again (they belong to the owners "app:*"). */
|
/* Drop them again (they belong to the owners "app:*"). */
|
||||||
void unregisterPlatformTools(AgentCore *core);
|
void unregisterPlatformTools(AgentCore *core);
|
||||||
|
|
||||||
@@ -93,6 +104,22 @@ bool hasAllFilesAccess();
|
|||||||
* dialog for it). A desktop, a phone that already has it or one too old to
|
* dialog for it). A desktop, a phone that already has it or one too old to
|
||||||
* know about it returns "" and nothing opens. */
|
* know about it returns "" and nothing opens. */
|
||||||
QString requestAllFilesAccess();
|
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
|
||||||
|
* Qt's own plumbing for that extra does not reach this build. Empty
|
||||||
|
* everywhere else, and empty on a device that was not started with any. */
|
||||||
|
QStringList activityArguments();
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
|
|
||||||
|
|||||||
+49
-1
@@ -16,6 +16,7 @@
|
|||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQuickStyle>
|
#include <QQuickStyle>
|
||||||
|
#include <QQuickWindow>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
#include "core/AgentCore.h"
|
#include "core/AgentCore.h"
|
||||||
#include "core/ToolProvider.h"
|
#include "core/ToolProvider.h"
|
||||||
#include "ui/UiTest.h"
|
#include "ui/UiTest.h"
|
||||||
|
#include "ui/BackKey.h"
|
||||||
#include "ui/SessionImages.h"
|
#include "ui/SessionImages.h"
|
||||||
#include "ui/ThemeIcons.h"
|
#include "ui/ThemeIcons.h"
|
||||||
#include "cst_version.h"
|
#include "cst_version.h"
|
||||||
@@ -193,13 +195,30 @@ int main(int argc, char **argv) {
|
|||||||
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
||||||
QStringLiteral("initial window size, e.g. 480x900"),
|
QStringLiteral("initial window size, e.g. 480x900"),
|
||||||
QStringLiteral("WxH"));
|
QStringLiteral("WxH"));
|
||||||
|
/* A device run cannot be handed an environment (see activityArguments()), so
|
||||||
|
* what a desktop run asks for with CST_APP_SHOT=shot.png has to be an
|
||||||
|
* argument here. */
|
||||||
|
QCommandLineOption shotsOpt(
|
||||||
|
QStringLiteral("shots"),
|
||||||
|
QStringLiteral("--uitest: save a picture of the window at the interesting "
|
||||||
|
"points, in the workspace"));
|
||||||
parser.addOption(wsOpt);
|
parser.addOption(wsOpt);
|
||||||
parser.addOption(providerOpt);
|
parser.addOption(providerOpt);
|
||||||
parser.addOption(modelOpt);
|
parser.addOption(modelOpt);
|
||||||
parser.addOption(planOpt);
|
parser.addOption(planOpt);
|
||||||
parser.addOption(uitestOpt);
|
parser.addOption(uitestOpt);
|
||||||
parser.addOption(sizeOpt);
|
parser.addOption(sizeOpt);
|
||||||
parser.process(app);
|
parser.addOption(shotsOpt);
|
||||||
|
/* What the process was started with, plus -- on a phone -- what the
|
||||||
|
* activity was started with: Qt's own applicationArguments plumbing does
|
||||||
|
* not deliver the extra on the NX789J (see activityArguments()), and
|
||||||
|
* without it there is no way to hand this window a workspace or a --uitest
|
||||||
|
* run on a device that has no keyboard. */
|
||||||
|
QStringList args = QCoreApplication::arguments();
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
args += app::activityArguments();
|
||||||
|
#endif
|
||||||
|
parser.process(args);
|
||||||
|
|
||||||
QString ws = parser.value(wsOpt);
|
QString ws = parser.value(wsOpt);
|
||||||
if (ws.isEmpty()) {
|
if (ws.isEmpty()) {
|
||||||
@@ -226,6 +245,12 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Before the window exists: maybeShot() reads this at the point a picture is
|
||||||
|
* wanted (the settings dialog, the end of the run). */
|
||||||
|
if (parser.isSet(shotsOpt))
|
||||||
|
qputenv("CST_APP_SHOT",
|
||||||
|
(ws + QStringLiteral("/uitest-shot.png")).toUtf8());
|
||||||
|
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
/* Before the engine is created: its settings, credentials and session store
|
/* Before the engine is created: its settings, credentials and session store
|
||||||
* all resolve their paths from the environment. */
|
* all resolve their paths from the environment. */
|
||||||
@@ -326,5 +351,28 @@ int main(int argc, char **argv) {
|
|||||||
engine.load(url);
|
engine.load(url);
|
||||||
if (engine.rootObjects().isEmpty())
|
if (engine.rootObjects().isEmpty())
|
||||||
return 1;
|
return 1;
|
||||||
|
/* 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<QQuickWindow *>(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();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFuture>
|
#include <QFuture>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
|
#include <QJniEnvironment>
|
||||||
#include <QJniObject>
|
#include <QJniObject>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
|
#include <QSemaphore>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/AgentCore.h"
|
#include "core/AgentCore.h"
|
||||||
@@ -59,16 +62,34 @@ void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
|||||||
r->exit_code = 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
|
/* 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 <typename Fn>
|
template <typename Fn>
|
||||||
void onGuiThread(Fn &&fn) {
|
bool onGuiThread(Fn &&fn) {
|
||||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||||
fn();
|
fn();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
auto pinged = std::make_shared<QSemaphore>(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>(fn),
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||||
Qt::BlockingQueuedConnection);
|
Qt::BlockingQueuedConnection);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
jint sdkInt() {
|
jint sdkInt() {
|
||||||
@@ -81,6 +102,80 @@ QJniObject appContext() {
|
|||||||
return QNativeInterface::QAndroidApplication::context();
|
return QNativeInterface::QAndroidApplication::context();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The strings the activity was started with. Qt is meant to hand the
|
||||||
|
* "applicationArguments" extra to QCoreApplication::arguments() by itself; on
|
||||||
|
* the NX789J it does not -- `am start ... --esa applicationArguments
|
||||||
|
* "--workspace,/sdcard/x"` left the window on its default workspace, and so
|
||||||
|
* did the same extra as a plain string. So the intent is asked directly, which
|
||||||
|
* is what makes `cst-agent-app --uitest --workspace DIR` (or any other
|
||||||
|
* argument) reachable on a device with no keyboard attached.
|
||||||
|
*
|
||||||
|
* Everything here degrades to "no arguments": an invalid object, a missing
|
||||||
|
* method, or a context that is not an activity all come back empty. */
|
||||||
|
QStringList intentArguments() {
|
||||||
|
QStringList out;
|
||||||
|
QJniObject activity = appContext();
|
||||||
|
if (!activity.isValid())
|
||||||
|
return out;
|
||||||
|
QJniObject intent =
|
||||||
|
activity.callObjectMethod("getIntent", "()Landroid/content/Intent;");
|
||||||
|
if (!intent.isValid())
|
||||||
|
return out;
|
||||||
|
const QJniObject key =
|
||||||
|
QJniObject::fromString(QStringLiteral("applicationArguments"));
|
||||||
|
/* Three shapes for the same extra, and which one arrives depends on who
|
||||||
|
* sent it: `am start --esa applicationArguments "--uitest,--workspace,/x"`
|
||||||
|
* stores a String[] (an empty launch, arguments and all, is what that cost
|
||||||
|
* us -- getStringArrayListExtra answers null for an array), a launcher or
|
||||||
|
* Qt's own loader uses putStringArrayListExtra, and `--es` puts one
|
||||||
|
* comma-separated string there. Try each, in that order. */
|
||||||
|
const QJniObject array = intent.callObjectMethod(
|
||||||
|
"getStringArrayExtra", "(Ljava/lang/String;)[Ljava/lang/String;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (array.isValid()) {
|
||||||
|
QJniEnvironment env;
|
||||||
|
const jobjectArray a = reinterpret_cast<jobjectArray>(array.object());
|
||||||
|
const jsize n = env->GetArrayLength(a);
|
||||||
|
for (jsize i = 0; i < n; i++) {
|
||||||
|
const jstring item =
|
||||||
|
reinterpret_cast<jstring>(env->GetObjectArrayElement(a, i));
|
||||||
|
if (!item)
|
||||||
|
continue;
|
||||||
|
const char *c = env->GetStringUTFChars(item, nullptr);
|
||||||
|
if (c) {
|
||||||
|
out << QString::fromUtf8(c);
|
||||||
|
env->ReleaseStringUTFChars(item, c);
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(item);
|
||||||
|
}
|
||||||
|
if (!out.isEmpty())
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
QJniObject list = intent.callObjectMethod(
|
||||||
|
"getStringArrayListExtra", "(Ljava/lang/String;)Ljava/util/ArrayList;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (list.isValid()) {
|
||||||
|
const jint n = list.callMethod<jint>("size", "()I");
|
||||||
|
for (jint i = 0; i < n; i++) {
|
||||||
|
QJniObject item =
|
||||||
|
list.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
|
||||||
|
if (item.isValid())
|
||||||
|
out << item.toString();
|
||||||
|
}
|
||||||
|
if (!out.isEmpty())
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const QJniObject one = intent.callObjectMethod(
|
||||||
|
"getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (one.isValid()) {
|
||||||
|
const QStringList parts =
|
||||||
|
one.toString().split(QLatin1Char(','), Qt::SkipEmptyParts);
|
||||||
|
out << parts;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- notification_send --------------------------------------------------- */
|
/* --- notification_send --------------------------------------------------- */
|
||||||
|
|
||||||
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
||||||
@@ -496,6 +591,11 @@ cst_tool_def_t g_share;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
/* What main() asks for (ToolProvider.h): the reading itself is the JNI helper
|
||||||
|
* above, next to the Android context it needs. It sits outside the anonymous
|
||||||
|
* namespace so main.cpp can link to it. */
|
||||||
|
QStringList activityArguments() { return intentArguments(); }
|
||||||
|
|
||||||
/* A file card's "open": whatever app the phone has for the type, with the read
|
/* A file card's "open": whatever app the phone has for the type, with the read
|
||||||
* permission granted to the app that picks it up. */
|
* permission granted to the app that picks it up. */
|
||||||
QString openLocalFile(const QString &path, const QString &title,
|
QString openLocalFile(const QString &path, const QString &title,
|
||||||
@@ -746,6 +846,65 @@ QString requestAllFilesAccess() {
|
|||||||
return err;
|
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<void>("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<jint>(
|
||||||
|
"android/view/WindowInsets$Type", "systemBars", "()I");
|
||||||
|
control.callMethod<void>("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<void>("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<void>("setSystemUiVisibility", "(I)V", flags);
|
||||||
|
});
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
int platformRegisterAndroid(AgentCore *core) {
|
int platformRegisterAndroid(AgentCore *core) {
|
||||||
if (!core)
|
if (!core)
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
|||||||
bool hasAllFilesAccess() { return true; }
|
bool hasAllFilesAccess() { return true; }
|
||||||
QString requestAllFilesAccess() { return QString(); }
|
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) {
|
int platformRegisterLinux(AgentCore *core) {
|
||||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||||
{"message", "string", 1, "text of the notification"},
|
{"message", "string", 1, "text of the notification"},
|
||||||
|
|||||||
@@ -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 <QByteArray>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#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<QByteArray> dirs = path.split(';');
|
||||||
|
const QList<QByteArray> 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
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#include "ui/BackKey.h"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class BackKeyFilter : public QObject {
|
||||||
|
public:
|
||||||
|
BackKeyFilter(QObject *window, QObject *root)
|
||||||
|
: QObject(window), m_window(window), m_root(root) {}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool eventFilter(QObject *watched, QEvent *event) override {
|
||||||
|
if (!m_root || watched != m_window ||
|
||||||
|
(event->type() != QEvent::KeyPress &&
|
||||||
|
event->type() != QEvent::KeyRelease))
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
QKeyEvent *key = static_cast<QKeyEvent *>(event);
|
||||||
|
if (key->key() != Qt::Key_Back)
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
/* The key is down and up: a press that was taken is followed by a release
|
||||||
|
* that goes with it, so the platform never sees half of a key it did not
|
||||||
|
* get. */
|
||||||
|
if (event->type() == QEvent::KeyRelease)
|
||||||
|
return m_tookPress ? (m_tookPress = false, true)
|
||||||
|
: QObject::eventFilter(watched, event);
|
||||||
|
QVariant layer;
|
||||||
|
if (!QMetaObject::invokeMethod(m_root, "backLayer",
|
||||||
|
Q_RETURN_ARG(QVariant, layer)))
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
if (layer.toString().isEmpty())
|
||||||
|
return QObject::eventFilter(watched, event); /* nothing open: not ours */
|
||||||
|
/* One layer back, on the next turn of the event loop (the dialogs insist,
|
||||||
|
* see Main.qml's stepBack). The key is spent: the window stays. */
|
||||||
|
QMetaObject::invokeMethod(m_root, "stepBack");
|
||||||
|
m_tookPress = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QObject *m_window; /* whose keys are read; outlives this filter */
|
||||||
|
QObject *m_root; /* the window's QML root */
|
||||||
|
bool m_tookPress = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void installBackKey(QObject *window, QObject *root) {
|
||||||
|
if (!window || !root)
|
||||||
|
return;
|
||||||
|
/* On the application, not on the window: a key press arrives here as an
|
||||||
|
* event sent to the window, but a filter installed on the window itself
|
||||||
|
* never sees one (measured -- a window-installed filter stayed silent while
|
||||||
|
* the same key closed the drawer through a QML Shortcut). The filter keeps
|
||||||
|
* to `window`'s own events, so nothing else in the process is touched. */
|
||||||
|
QCoreApplication::instance()->installEventFilter(
|
||||||
|
new BackKeyFilter(window, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* The phone's back key, read before anything else sees the event.
|
||||||
|
*
|
||||||
|
* Android hands its back button to the window as a Qt::Key_Back key press, and
|
||||||
|
* the platform leaves the activity (the window goes away) when nothing takes
|
||||||
|
* the key. What takes it here is one layer at a time -- the dialog on top,
|
||||||
|
* then the sliding sidebar -- and only while there is a layer: the window's
|
||||||
|
* own QML answers which (Main.qml's backLayer()) and closes it (stepBack()).
|
||||||
|
*
|
||||||
|
* This lives in C++ rather than in a QML Shortcut because a window-level
|
||||||
|
* shortcut never sees the key while a modal popup is up: the dialog keeps it
|
||||||
|
* (a desktop run that presses Back with the settings dialog open shows the
|
||||||
|
* shortcut does not fire, while the drawer -- no popup -- closes fine). A
|
||||||
|
* filter on the window runs before the shortcut map, the popup and the focused
|
||||||
|
* item, so the same key does the same thing on every platform; with nothing
|
||||||
|
* open the key is passed on untouched, which is what leaves the window on a
|
||||||
|
* phone and does nothing on a desktop.
|
||||||
|
*
|
||||||
|
* A window must not be given a second one: installBackKey() installs once. */
|
||||||
|
#ifndef CST_APP_BACK_KEY_H
|
||||||
|
#define CST_APP_BACK_KEY_H
|
||||||
|
|
||||||
|
class QObject;
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
|
||||||
|
/* `window` is the QQuickWindow (the object key events are sent to), `root` the
|
||||||
|
* QML root object of that window (the one carrying backLayer()/stepBack()).
|
||||||
|
* The filter is owned by the window. */
|
||||||
|
void installBackKey(QObject *window, QObject *root);
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
|
|
||||||
|
#endif // CST_APP_BACK_KEY_H
|
||||||
+537
-15
@@ -11,25 +11,35 @@
|
|||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QBuffer>
|
#include <QBuffer>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
|
#include <QPalette>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QPointingDevice>
|
#include <QPointingDevice>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
|
#include <QQuickStyle>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQuickWindow>
|
#include <QQuickWindow>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
#include "cst_attach.h"
|
#include "cst_attach.h"
|
||||||
|
#include "core/ToolProvider.h"
|
||||||
|
#include "ui/BackKey.h"
|
||||||
#include "ui/SessionImages.h"
|
#include "ui/SessionImages.h"
|
||||||
#include "ui/ThemeIcons.h"
|
#include "ui/ThemeIcons.h"
|
||||||
|
|
||||||
@@ -38,13 +48,61 @@ namespace {
|
|||||||
|
|
||||||
int g_fail;
|
int g_fail;
|
||||||
|
|
||||||
|
/* The run's own log file, opened once the workspace is known (see the top of
|
||||||
|
* the run below): every line stdout gets is written here as well. On a phone
|
||||||
|
* stdout reaches nobody, and an app started from adb cannot be handed
|
||||||
|
* CST_APP_SHOT or any other environment -- so `--uitest --workspace DIR` plus
|
||||||
|
* `adb pull DIR/uitest.log` is how a device run is read back. Empty when the
|
||||||
|
* file could not be made, in which case the run only prints. */
|
||||||
|
QFile g_runLog;
|
||||||
|
|
||||||
|
/* Qt's own messages go into the same log as the checks: this is where QML
|
||||||
|
* puts "TypeError: Cannot read property ... of undefined" and every other
|
||||||
|
* reason a step did nothing, and a device run has nowhere else to show it
|
||||||
|
* (logcat does not carry this build's Qt messages -- a plain qInfo from the
|
||||||
|
* app is not in the buffer). Messages before the log is opened still print. */
|
||||||
|
void logQtMessage(QtMsgType type, const QMessageLogContext &, const QString &msg) {
|
||||||
|
const char *kind = type == QtDebugMsg ? "debug"
|
||||||
|
: type == QtInfoMsg ? "info"
|
||||||
|
: type == QtWarningMsg ? "warn"
|
||||||
|
: type == QtCriticalMsg ? "crit"
|
||||||
|
: "fatal";
|
||||||
|
const QByteArray line = QByteArray(kind) + " " + msg.toUtf8() + "\n";
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write(line);
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
|
fwrite(line.constData(), 1, static_cast<size_t>(line.size()), stderr);
|
||||||
|
if (type == QtFatalMsg)
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
|
||||||
void check(bool ok, const QString &what) {
|
void check(bool ok, const QString &what) {
|
||||||
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
||||||
std::fflush(stdout);
|
std::fflush(stdout);
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write(ok ? "ok " : "FAIL");
|
||||||
|
g_runLog.write(what.toUtf8());
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
if (!ok)
|
if (!ok)
|
||||||
g_fail++;
|
g_fail++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A line in the run's log that is not a check: what the window looked like at
|
||||||
|
* a step, for the cases where "it did nothing" is all the checks can say. */
|
||||||
|
void note(const QString &what) {
|
||||||
|
std::printf("note %s\n", qPrintable(what));
|
||||||
|
std::fflush(stdout);
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write("note ");
|
||||||
|
g_runLog.write(what.toUtf8());
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Every item with this objectName in the visual tree. A view that has just
|
/* Every item with this objectName in the visual tree. A view that has just
|
||||||
* been replayed can still hold the delegates it is about to drop, so a name
|
* been replayed can still hold the delegates it is about to drop, so a name
|
||||||
* can match more than one item for a moment. */
|
* can match more than one item for a moment. */
|
||||||
@@ -72,11 +130,20 @@ int countNamed(QQuickItem *root, const QString &prefix) {
|
|||||||
/* An item by objectName. The QObject tree is tried first (it holds the named
|
/* 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
|
* 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. */
|
* 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) {
|
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
||||||
QList<QQuickItem *> hits;
|
QList<QQuickItem *> hits;
|
||||||
collectNamed(win->contentItem(), name, &hits);
|
collectNamed(win->contentItem(), name, &hits);
|
||||||
/* What a finger can reach is the one the test means: a delegate on its way
|
/* What a finger can reach is the one the test means. Two things say
|
||||||
* out is still in the tree but invisible. */
|
* "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)
|
for (QQuickItem *it : hits)
|
||||||
if (it->isVisible())
|
if (it->isVisible())
|
||||||
return it;
|
return it;
|
||||||
@@ -91,12 +158,87 @@ QPoint centre(QQuickItem *it) {
|
|||||||
return p.toPoint();
|
return p.toPoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Is this item's centre really reachable? isVisible() answers whether hiding
|
||||||
|
* was asked for, not whether a clipping ancestor (a scrolling view) has moved
|
||||||
|
* it out of sight -- and an event goes to whatever is at that point instead. */
|
||||||
|
bool onScreen(QQuickItem *it) {
|
||||||
|
if (!it || !it->isVisible() || it->width() <= 0 || it->height() <= 0)
|
||||||
|
return false;
|
||||||
|
QQuickWindow *win = it->window();
|
||||||
|
if (!win)
|
||||||
|
return false;
|
||||||
|
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
|
||||||
|
if (!QRectF(0, 0, win->width(), win->height()).contains(p))
|
||||||
|
return false;
|
||||||
|
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
|
||||||
|
if (!a->clip())
|
||||||
|
continue;
|
||||||
|
if (!a->mapRectToScene(QRectF(0, 0, a->width(), a->height())).contains(p))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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) {
|
void clickMouse(QQuickWindow *win, const QString &name) {
|
||||||
QQuickItem *it = item(win, name);
|
QQuickItem *it = item(win, name);
|
||||||
if (!it || !it->isVisible()) {
|
if (!it || !it->isVisible()) {
|
||||||
check(false, QStringLiteral("mouse click %1: not there").arg(name));
|
check(false, QStringLiteral("mouse click %1: not there").arg(name));
|
||||||
return;
|
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));
|
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +248,10 @@ void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
|
|||||||
check(false, QStringLiteral("touch tap %1: not there").arg(name));
|
check(false, QStringLiteral("touch tap %1: not there").arg(name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tapItem(win, dev, it);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) {
|
||||||
const QPoint p = centre(it);
|
const QPoint p = centre(it);
|
||||||
QTest::touchEvent(win, dev).press(0, p, win);
|
QTest::touchEvent(win, dev).press(0, p, win);
|
||||||
QTest::touchEvent(win, dev).release(0, p, win);
|
QTest::touchEvent(win, dev).release(0, p, win);
|
||||||
@@ -239,6 +385,11 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
/* A file card hands its file to the system: in a test run nothing may open
|
/* A file card hands its file to the system: in a test run nothing may open
|
||||||
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
||||||
qputenv("CST_APP_NO_OPEN", "1");
|
qputenv("CST_APP_NO_OPEN", "1");
|
||||||
|
/* From here on the run leaves a log in the workspace next to the files it
|
||||||
|
* makes, so a device run can be read back without logcat (see g_runLog). */
|
||||||
|
g_runLog.setFileName(core.workspace() + QStringLiteral("/uitest.log"));
|
||||||
|
g_runLog.open(QIODevice::WriteOnly | QIODevice::Truncate);
|
||||||
|
qInstallMessageHandler(logQtMessage);
|
||||||
const QString resumed =
|
const QString resumed =
|
||||||
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
||||||
check(!resumed.isEmpty() && core.switchSession(resumed),
|
check(!resumed.isEmpty() && core.switchSession(resumed),
|
||||||
@@ -265,6 +416,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
std::printf("FAIL the root object is not a window\n");
|
std::printf("FAIL the root object is not a window\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
/* The same handler main() installs: every Back key check below goes through
|
||||||
|
* the code the phone runs, not through a Shortcut that only the test has. */
|
||||||
|
installBackKey(win, engine.rootObjects().value(0));
|
||||||
QTest::qWait(250);
|
QTest::qWait(250);
|
||||||
|
|
||||||
/* --- the window opens on the session the engine already had ----------- */
|
/* --- the window opens on the session the engine already had ----------- */
|
||||||
@@ -332,6 +486,10 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("a session fixture with a tool call is in the store"));
|
QStringLiteral("a session fixture with a tool call is in the store"));
|
||||||
QMetaObject::invokeMethod(win, "reloadSessions");
|
QMetaObject::invokeMethod(win, "reloadSessions");
|
||||||
QTest::qWait(150);
|
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:"));
|
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
|
||||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
||||||
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
||||||
@@ -377,14 +535,97 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
item(win, QStringLiteral("fileShare0")) != nullptr,
|
item(win, QStringLiteral("fileShare0")) != nullptr,
|
||||||
QStringLiteral("the card carries open and share"));
|
QStringLiteral("the card carries open and share"));
|
||||||
win->setProperty("statusText", QString());
|
win->setProperty("statusText", QString());
|
||||||
clickMouse(win, QStringLiteral("fileOpen0"));
|
/* 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). 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);
|
||||||
|
}
|
||||||
|
QStringList rels;
|
||||||
|
QString cardSays;
|
||||||
|
QList<QQuickItem *> 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 (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<QQuickItem *> 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 && openButton)
|
||||||
|
tapItem(win, touch, openButton);
|
||||||
const QString opened = win->property("statusText").toString();
|
const QString opened = win->property("statusText").toString();
|
||||||
check(opened.startsWith(QLatin1String("opened ")),
|
check(opened.startsWith(QLatin1String("opened ")),
|
||||||
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', "
|
||||||
clickMouse(win, QStringLiteral("fileShare0"));
|
"%4, card says '%5')")
|
||||||
|
.arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"),
|
||||||
|
rels.join(QLatin1String(", ")),
|
||||||
|
onScreen(openButton)
|
||||||
|
? QStringLiteral("on screen")
|
||||||
|
: QStringLiteral("out of the viewport"),
|
||||||
|
cardSays));
|
||||||
|
win->setProperty("statusText", QString());
|
||||||
|
if (shareButton) {
|
||||||
|
scrollIntoView(shareButton, true);
|
||||||
|
frame(win);
|
||||||
|
clickItem(win, shareButton);
|
||||||
|
}
|
||||||
|
const bool shareByMouse = !win->property("statusText").toString().isEmpty();
|
||||||
|
if (!shareByMouse && shareButton)
|
||||||
|
tapItem(win, touch, shareButton);
|
||||||
const QString shared = win->property("statusText").toString();
|
const QString shared = win->property("statusText").toString();
|
||||||
check(shared.contains(QLatin1String("report.md")),
|
check(shared.contains(QLatin1String("report.md")),
|
||||||
QStringLiteral("Share names the file (%1)").arg(shared));
|
QStringLiteral("Share names the file (%1%2; card says '%3')")
|
||||||
|
.arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap"),
|
||||||
|
cardSays));
|
||||||
const QString refused =
|
const QString refused =
|
||||||
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
||||||
check(refused.contains(QLatin1String("no such file")),
|
check(refused.contains(QLatin1String("no such file")),
|
||||||
@@ -416,6 +657,136 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("and the line is where the card is"));
|
QStringLiteral("and the line is where the card is"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The approval switch is not decoration: the engine asks for a tool only
|
||||||
|
* when its own confirm_* flags say so (needs_confirm() in agent_loop.c),
|
||||||
|
* and every front end that has a consent dialog has to set them -- the
|
||||||
|
* window used to leave all three at zero, which is why "ask" never asked. */
|
||||||
|
{
|
||||||
|
const int was = core.approval();
|
||||||
|
core.setApproval(CST_APPROVAL_ASK);
|
||||||
|
const cst_agent_config_t *live = cst_agent_config(core.agent());
|
||||||
|
check(live && live->approval == CST_APPROVAL_ASK && live->confirm_shell &&
|
||||||
|
live->confirm_write && live->confirm_net,
|
||||||
|
QStringLiteral("'ask' reaches the engine as the three confirm flags"));
|
||||||
|
core.setApproval(CST_APPROVAL_YOLO);
|
||||||
|
live = cst_agent_config(core.agent());
|
||||||
|
check(live && live->approval == CST_APPROVAL_YOLO && !live->confirm_shell &&
|
||||||
|
!live->confirm_write && !live->confirm_net,
|
||||||
|
QStringLiteral("and 'yolo' takes them all away"));
|
||||||
|
core.setApproval(was); /* leave the machine as it was found */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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<char *>(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<char *>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper is white whatever the window's palette says: markdown carries no
|
||||||
|
* colour, so a dark window turned every plain paragraph #eeeeee -- a PDF
|
||||||
|
* that looked blank. Draw it on a white image and look for ink. */
|
||||||
|
{
|
||||||
|
const QPalette was = QGuiApplication::palette();
|
||||||
|
QPalette darkPalette = was;
|
||||||
|
darkPalette.setColor(QPalette::Text, QColor(0xee, 0xee, 0xee));
|
||||||
|
darkPalette.setColor(QPalette::WindowText, QColor(0xee, 0xee, 0xee));
|
||||||
|
QGuiApplication::setPalette(darkPalette);
|
||||||
|
std::unique_ptr<QTextDocument> page(app::paperDocument(
|
||||||
|
QStringLiteral("<html><body>text that names no colour of its own"
|
||||||
|
"</body></html>")));
|
||||||
|
QImage paper(320, 60, QImage::Format_RGB32);
|
||||||
|
paper.fill(0xffffffffu);
|
||||||
|
QPainter ink(&paper);
|
||||||
|
page->drawContents(&ink);
|
||||||
|
ink.end();
|
||||||
|
int darkest = 255;
|
||||||
|
for (int y = 0; y < paper.height(); y++) {
|
||||||
|
const QRgb *row = reinterpret_cast<const QRgb *>(paper.constScanLine(y));
|
||||||
|
for (int x = 0; x < paper.width(); x++)
|
||||||
|
darkest = qMin(darkest, qGray(row[x]));
|
||||||
|
}
|
||||||
|
check(darkest < 64,
|
||||||
|
QStringLiteral("a dark window still lays out black ink on paper "
|
||||||
|
"(darkest pixel %1)")
|
||||||
|
.arg(darkest));
|
||||||
|
QGuiApplication::setPalette(was);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tool that takes minutes -- a build, a wide search -- must not read as a
|
||||||
|
* window that has hung: while a card runs, its chip counts. */
|
||||||
|
{
|
||||||
|
QList<QQuickItem *> cards;
|
||||||
|
collectNamed(win->contentItem(), QStringLiteral("toolCard"), &cards);
|
||||||
|
check(!cards.isEmpty(),
|
||||||
|
QStringLiteral("the fixture session has a tool card to watch"));
|
||||||
|
if (!cards.isEmpty()) {
|
||||||
|
QQuickItem *card = cards.first();
|
||||||
|
card->setProperty("state", "running");
|
||||||
|
/* four minutes eighteen in, the point where "running" alone stopped
|
||||||
|
* being an answer */
|
||||||
|
card->setProperty(
|
||||||
|
"startedAt",
|
||||||
|
static_cast<double>(QDateTime::currentMSecsSinceEpoch()) - 258000.0);
|
||||||
|
card->setProperty("runningFor", 258);
|
||||||
|
QList<QQuickItem *> chips;
|
||||||
|
collectNamed(card, QStringLiteral("toolChip"), &chips);
|
||||||
|
const QString counting = chips.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: chips.first()->property("text").toString();
|
||||||
|
check(counting.contains(QLatin1String("4:18")),
|
||||||
|
QStringLiteral("a running card says how long it has been going (%1)")
|
||||||
|
.arg(counting));
|
||||||
|
card->setProperty("state", "done");
|
||||||
|
const QString settled = chips.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: chips.first()->property("text").toString();
|
||||||
|
check(!settled.contains(QLatin1String("4:18")),
|
||||||
|
QStringLiteral("and a finished card stops counting (%1)")
|
||||||
|
.arg(settled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* new sessions: the engine moves, the view empties, the store grows */
|
/* new sessions: the engine moves, the view empties, the store grows */
|
||||||
const int before = core.sessions().size();
|
const int before = core.sessions().size();
|
||||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||||
@@ -439,27 +810,46 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
|
|
||||||
/* and back to the fixture, which still has its six rows */
|
/* and back to the fixture, which still has its six rows */
|
||||||
QTest::qWait(150);
|
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);
|
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||||
check(core.sessionId() == fixture && list &&
|
check(core.sessionId() == fixture && list &&
|
||||||
list->property("count").toInt() == 6,
|
list->property("count").toInt() == 6,
|
||||||
QStringLiteral("switching back replays it again"));
|
QStringLiteral("switching back replays it again"));
|
||||||
{
|
{
|
||||||
bool promptImage = false;
|
bool promptImage = false;
|
||||||
|
int promptImageRow = -1;
|
||||||
const QVariantList rows = core.history();
|
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();
|
const QVariantMap r = v.toMap();
|
||||||
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
||||||
r.value(QStringLiteral("images")).toString().contains(
|
r.value(QStringLiteral("images")).toString().contains(
|
||||||
QLatin1String("|")))
|
QLatin1String("|"))) {
|
||||||
promptImage = true;
|
promptImage = true;
|
||||||
|
if (promptImageRow < 0)
|
||||||
|
promptImageRow = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
check(promptImage,
|
check(promptImage,
|
||||||
QStringLiteral("a stored prompt image comes back with its row"));
|
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
|
/* 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,
|
* 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. */
|
* and that frame is also the picture a person looks at. */
|
||||||
maybeShot(win, QStringLiteral("-images"));
|
maybeShot(win, QStringLiteral("-images"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
||||||
QVariant v;
|
QVariant v;
|
||||||
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
||||||
@@ -489,6 +879,52 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
clickMouse(win, QStringLiteral("settingsButton"));
|
clickMouse(win, QStringLiteral("settingsButton"));
|
||||||
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
|
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
|
||||||
|
/* Where the pieces actually are, whether or not that worked: a click goes to
|
||||||
|
* whatever is on top at that point, and a style lays a header out its own
|
||||||
|
* way -- the phone runs a different style from a desktop (Universal against
|
||||||
|
* Basic), which is where the header's content ended up 35 px below the
|
||||||
|
* header itself. */
|
||||||
|
{
|
||||||
|
QQuickItem *bar = item(win, QStringLiteral("appBar"));
|
||||||
|
const QMargins safe = win->property("safeAreaMargins").value<QMargins>();
|
||||||
|
note(QStringLiteral("style %1, window %2x%3, header y %4 h %5, "
|
||||||
|
"gear centre y %6, safe area %7/%8/%9/%10")
|
||||||
|
.arg(QQuickStyle::name())
|
||||||
|
.arg(win->width())
|
||||||
|
.arg(win->height())
|
||||||
|
.arg(bar ? bar->y() : -1)
|
||||||
|
.arg(bar ? bar->height() : -1)
|
||||||
|
.arg(item(win, QStringLiteral("settingsButton"))
|
||||||
|
? centre(item(win, QStringLiteral("settingsButton"))).y()
|
||||||
|
: -1)
|
||||||
|
.arg(safe.left())
|
||||||
|
.arg(safe.top())
|
||||||
|
.arg(safe.right())
|
||||||
|
.arg(safe.bottom()));
|
||||||
|
}
|
||||||
|
/* item() answers with an item that exists even when nothing is visible (the
|
||||||
|
* QObject-tree fallback), so "the dialog is not open" is the invisible case
|
||||||
|
* too. */
|
||||||
|
if (!panel || !panel->isVisible()) {
|
||||||
|
/* Two ways to be wrong -- a click that never lands, and a handler that
|
||||||
|
* does nothing -- and from the outside they look the same (this failed
|
||||||
|
* only on the phone, with no QML error at all). Say which, and go on with
|
||||||
|
* the dialog either way, so the checks below still say something. */
|
||||||
|
QQuickItem *gear = item(win, QStringLiteral("settingsButton"));
|
||||||
|
check(gear != nullptr && gear->isVisible(),
|
||||||
|
QStringLiteral("... the gear is %1, centre %2,%3 of %4x%5")
|
||||||
|
.arg(gear && gear->isVisible() ? QStringLiteral("reachable")
|
||||||
|
: QStringLiteral("not there"))
|
||||||
|
.arg(gear ? centre(gear).x() : -1)
|
||||||
|
.arg(gear ? centre(gear).y() : -1)
|
||||||
|
.arg(win->width())
|
||||||
|
.arg(win->height()));
|
||||||
|
QMetaObject::invokeMethod(win, "openSettings");
|
||||||
|
QTest::qWait(120);
|
||||||
|
panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
|
check(panel && panel->isVisible(),
|
||||||
|
QStringLiteral("... and openSettings() opens it when called directly"));
|
||||||
|
}
|
||||||
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
|
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
|
||||||
QStringLiteral("the dialog lists the providers"));
|
QStringLiteral("the dialog lists the providers"));
|
||||||
maybeShot(win, QStringLiteral("-settings"));
|
maybeShot(win, QStringLiteral("-settings"));
|
||||||
@@ -641,6 +1077,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
|
|
||||||
/* the paste path: an image on the clipboard becomes an attachment */
|
/* the paste path: an image on the clipboard becomes an attachment */
|
||||||
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||||
|
#if !defined(Q_OS_ANDROID)
|
||||||
if (gui && gui->clipboard()) {
|
if (gui && gui->clipboard()) {
|
||||||
QImage img(8, 8, QImage::Format_RGB32);
|
QImage img(8, 8, QImage::Format_RGB32);
|
||||||
img.fill(Qt::red);
|
img.fill(Qt::red);
|
||||||
@@ -653,6 +1090,13 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
} else {
|
} else {
|
||||||
check(false, QStringLiteral("there is a clipboard to paste from"));
|
check(false, QStringLiteral("there is a clipboard to paste from"));
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
/* Android's clipboard carries text and URIs, not images: setting one there
|
||||||
|
* does nothing, and a phone that "fails" this is not a phone that is
|
||||||
|
* broken. The paste path itself is the same code the desktop runs. */
|
||||||
|
(void)gui;
|
||||||
|
note(QStringLiteral("clipboard image checks skipped (Android)"));
|
||||||
|
#endif
|
||||||
|
|
||||||
/* --- tool calls: two calls of one tool are two cards ------------------ */
|
/* --- tool calls: two calls of one tool are two cards ------------------ */
|
||||||
const int rows0 = list ? list->property("count").toInt() : -1;
|
const int rows0 = list ? list->property("count").toInt() : -1;
|
||||||
@@ -733,9 +1177,13 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
/* --- the image provider: only the session's own files ----------------- */
|
/* --- the image provider: only the session's own files ----------------- */
|
||||||
{
|
{
|
||||||
const QString dir = core.attachmentDir();
|
const QString dir = core.attachmentDir();
|
||||||
/* one directory per session since v0.9.5: <sessions>/<id>/attachments */
|
/* one directory per session since v0.9.5: <sessions>/<id>/attachments.
|
||||||
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) &&
|
* The engine builds the path with the separator of the platform it runs
|
||||||
dir.contains(QLatin1String("/sessions/")),
|
* on, so what arrives here is "…/sessions\<id>\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));
|
QStringLiteral("the agent names its image directory (%1)").arg(dir));
|
||||||
QImage pic(32, 16, QImage::Format_RGB32);
|
QImage pic(32, 16, QImage::Format_RGB32);
|
||||||
pic.fill(Qt::darkGreen);
|
pic.fill(Qt::darkGreen);
|
||||||
@@ -776,6 +1224,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
const int before = list->property("count").toInt();
|
const int before = list->property("count").toInt();
|
||||||
const int thumbsBefore =
|
const int thumbsBefore =
|
||||||
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
|
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"),
|
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
|
||||||
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
||||||
const QString imgs =
|
const QString imgs =
|
||||||
@@ -784,7 +1240,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
Q_ARG(QVariant, QStringLiteral("call-img")),
|
Q_ARG(QVariant, QStringLiteral("call-img")),
|
||||||
Q_ARG(QVariant, imgs)),
|
Q_ARG(QVariant, imgs)),
|
||||||
QStringLiteral("images a tool made reach the view"));
|
QStringLiteral("images a tool made reach the view"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
const QVariantMap card = rowAt(before);
|
const QVariantMap card = rowAt(before);
|
||||||
check(card.value(QStringLiteral("images"))
|
check(card.value(QStringLiteral("images"))
|
||||||
.toString()
|
.toString()
|
||||||
@@ -794,7 +1250,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("the card of that call carries the image"));
|
QStringLiteral("the card of that call carries the image"));
|
||||||
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
||||||
thumbsBefore,
|
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"));
|
maybeShot(win, QStringLiteral("-toolimage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +1278,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
Q_ARG(QVariant, QStringLiteral("call-file")),
|
Q_ARG(QVariant, QStringLiteral("call-file")),
|
||||||
Q_ARG(QVariant, files)),
|
Q_ARG(QVariant, files)),
|
||||||
QStringLiteral("the files a tool delivered reach the view"));
|
QStringLiteral("the files a tool delivered reach the view"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
const QVariantMap card = rowAt(before);
|
const QVariantMap card = rowAt(before);
|
||||||
check(card.value(QStringLiteral("images"))
|
check(card.value(QStringLiteral("images"))
|
||||||
.toString()
|
.toString()
|
||||||
@@ -842,6 +1300,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
clickMouse(win, QStringLiteral("navButton"));
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
check(navPanel && navPanel->isVisible(),
|
check(navPanel && navPanel->isVisible(),
|
||||||
QStringLiteral("drawer opens over the transcript"));
|
QStringLiteral("drawer opens over the transcript"));
|
||||||
|
/* A phone closes the drawer with the back key, not with a second tap. */
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(120);
|
||||||
|
check(navPanel && !navPanel->isVisible(),
|
||||||
|
QStringLiteral("back closes the drawer"));
|
||||||
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
|
check(navPanel && navPanel->isVisible(),
|
||||||
|
QStringLiteral("and the drawer button opens it again"));
|
||||||
clickMouse(win, QStringLiteral("navButton"));
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
check(navPanel && !navPanel->isVisible(),
|
check(navPanel && !navPanel->isVisible(),
|
||||||
QStringLiteral("drawer closes again"));
|
QStringLiteral("drawer closes again"));
|
||||||
@@ -850,10 +1316,66 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
check(docked && docked->isVisible(),
|
check(docked && docked->isVisible(),
|
||||||
QStringLiteral("widening again docks the sidebar back"));
|
QStringLiteral("widening again docks the sidebar back"));
|
||||||
|
|
||||||
|
/* --- the phone's back key closes what is open -------------------------- */
|
||||||
|
/* This one is last on purpose: on the offscreen platform a popup closed by a
|
||||||
|
* key leaves its modal overlay over the window -- the dialog says it is
|
||||||
|
* closed while the overlay still eats clicks. Nothing that clicks may come
|
||||||
|
* after it. */
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(win, "openSettings");
|
||||||
|
QTest::qWait(300);
|
||||||
|
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
|
check(panel && panel->isVisible(),
|
||||||
|
QStringLiteral("back: the settings dialog is open to close"));
|
||||||
|
/* Which layer is open is asked of QML, not of an item's `visible`: the
|
||||||
|
* dialog's content item lives on after a close and still reports visible
|
||||||
|
* while it fades. backLayer() is what the Back key reads, so the test asks
|
||||||
|
* for exactly that. */
|
||||||
|
QVariant before;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before));
|
||||||
|
check(before.toString() == QStringLiteral("settings"),
|
||||||
|
QStringLiteral("back: the settings layer is the one open"));
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QVariant after;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after));
|
||||||
|
note(QStringLiteral("after back: layer \"%1\"").arg(after.toString()));
|
||||||
|
check(after.toString().isEmpty(),
|
||||||
|
QStringLiteral("back closes the dialog instead of the window"));
|
||||||
|
check(win->isVisible(), QStringLiteral("and the window is still there"));
|
||||||
|
/* With nothing open the key belongs to the platform: a phone leaves the
|
||||||
|
* window with it, so this side must not take it. On a desktop nothing
|
||||||
|
* happens and the window has to still be there; on a phone the window
|
||||||
|
* going away *is* the hand-over, so what is asked is that no layer was
|
||||||
|
* touched (whether the activity is already gone is the platform's own
|
||||||
|
* timing). This is the last thing the run does on a phone: the activity
|
||||||
|
* leaving takes the process with it, so the log can end on the check below
|
||||||
|
* without the closing line -- every line is flushed as it is written. */
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(120);
|
||||||
|
QVariant idle;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle));
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
note(QStringLiteral("after the last back: window visible %1")
|
||||||
|
.arg(win->isVisible() ? 1 : 0));
|
||||||
|
check(idle.toString().isEmpty(),
|
||||||
|
QStringLiteral("back with nothing open is left to the platform"));
|
||||||
|
#else
|
||||||
|
check(idle.toString().isEmpty() && win->isVisible(),
|
||||||
|
QStringLiteral("back with nothing open is left to the platform"));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/* A picture of the last state on request (see maybeShot). */
|
/* A picture of the last state on request (see maybeShot). */
|
||||||
maybeShot(win, QString());
|
maybeShot(win, QString());
|
||||||
|
|
||||||
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write("--- ");
|
||||||
|
g_runLog.write(g_fail ? "FAILED" : "all UI checks passed");
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.close();
|
||||||
|
}
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
# NixOS and other nix users: the desktop front end (Qt Quick) with the engine
|
||||||
|
# it embeds, built and installed the nix way.
|
||||||
|
#
|
||||||
|
# nix-build # ./result/bin/cst-agent-app, run it in place
|
||||||
|
# nix-env -f . -i cst-agent # into the user profile: PATH, menu entry, icon
|
||||||
|
#
|
||||||
|
# The Qt apps hook is what makes the installed binary find Qt's plugins and
|
||||||
|
# QML modules outside a nix-shell (this is also what keeps them out of
|
||||||
|
# nix-collect-garbage's reach). Only the window is installed: the CLI has its
|
||||||
|
# own installer, scripts/bootstrap_linux.sh.
|
||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
let
|
||||||
|
# The version lives in one place: the header the engine and the APK read.
|
||||||
|
vLine = re:
|
||||||
|
let
|
||||||
|
hit = pkgs.lib.findFirst
|
||||||
|
(l: builtins.match "#define ${re} ([0-9]+)" l != null) ""
|
||||||
|
(pkgs.lib.splitString "\n" (builtins.readFile ./include/cst_version.h));
|
||||||
|
in
|
||||||
|
builtins.head (builtins.match "#define ${re} ([0-9]+)" hit);
|
||||||
|
in
|
||||||
|
pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "cst-agent";
|
||||||
|
version =
|
||||||
|
"${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}";
|
||||||
|
|
||||||
|
# Not the whole checkout: third_party/ (the static curl/mbedTLS/ncurses
|
||||||
|
# sources, the NDK builds), the build directories and the session store are
|
||||||
|
# gigabytes this build never reads -- curl, ncurses and Qt come from nixpkgs.
|
||||||
|
src = builtins.filterSource
|
||||||
|
(path: type:
|
||||||
|
!(builtins.elem (baseNameOf path) [
|
||||||
|
".agent"
|
||||||
|
".cursor"
|
||||||
|
".gen"
|
||||||
|
".git"
|
||||||
|
"build-android-arm64-v8a"
|
||||||
|
"build-apk-android"
|
||||||
|
"build-win-tui"
|
||||||
|
"dist"
|
||||||
|
"result"
|
||||||
|
"third_party"
|
||||||
|
]))
|
||||||
|
./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [ cmake pkg-config qt6.wrapQtAppsHook ];
|
||||||
|
# qtwayland so a Wayland session gets a native client, qtsvg for the icon
|
||||||
|
# theme: Breeze's icons are SVG, and without the image format plugin the
|
||||||
|
# window falls back to text glyphs.
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
qt6.qtbase
|
||||||
|
qt6.qtdeclarative
|
||||||
|
qt6.qtwayland
|
||||||
|
qt6.qtsvg
|
||||||
|
curl
|
||||||
|
curl.dev
|
||||||
|
ncurses
|
||||||
|
ncurses.dev
|
||||||
|
];
|
||||||
|
|
||||||
|
cmakeFlags = [ "-DCST_BUILD_APP=ON" "-DCMAKE_BUILD_TYPE=Release" ];
|
||||||
|
|
||||||
|
# The "app" component is the window, its launcher entry and the icon;
|
||||||
|
# app/CMakeLists.txt is the one place that knows where each goes.
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
cmake --install . --component app --prefix "$out"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
doCheck = true;
|
||||||
|
# One test cannot pass here: tests/test_sandbox.c has the engine run a
|
||||||
|
# sandboxed command and asserts Landlock *denied* the write outside the
|
||||||
|
# workspace, and Landlock does not work inside the nix build sandbox -- the
|
||||||
|
# command succeeds and the assertion trips. The other 40 run; the full set
|
||||||
|
# is `make check` / `ctest` in the dev tree.
|
||||||
|
checkPhase = ''
|
||||||
|
runHook preCheck
|
||||||
|
ctest --output-on-failure -j"$NIX_BUILD_CORES" -E test_sandbox
|
||||||
|
runHook postCheck
|
||||||
|
'';
|
||||||
|
# The tests write settings, sessions, undo files and a scratch workspace:
|
||||||
|
# give them a home of their own.
|
||||||
|
preCheck = ''
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
export XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache"
|
||||||
|
export XDG_DATA_HOME="$HOME/.local/share"
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME"
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description =
|
||||||
|
"Local coding agent: Qt Quick front end with the engine in-process";
|
||||||
|
homepage = "https://git.whcself.xyz/WHC/CST-agent";
|
||||||
|
platforms = pkgs.lib.platforms.linux;
|
||||||
|
mainProgram = "cst-agent-app";
|
||||||
|
};
|
||||||
|
}
|
||||||
+22
-3
@@ -197,6 +197,25 @@ have, so the script rewrites the shebang of the build-tools wrappers.
|
|||||||
start of the turn and released at its end, `run_shell sleep 60` finished with
|
start of the turn and released at its end, `run_shell sleep 60` finished with
|
||||||
the screen dark, and the service was gone once the turn was over.
|
the screen dark, and the service was gone once the turn was over.
|
||||||
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
||||||
* Still to come: the SAF picker for attaching a picture from another app, the
|
* **A turn that asked and got no answer**: a consent question cannot outlive
|
||||||
back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the
|
the app being off screen. `AgentCore::hookPermission()` / `hookAskUser()`
|
||||||
phone and draws empty boxes where the desktop shows Breeze icons.
|
refuse (or drop the question) at once while the app is in the background --
|
||||||
|
Qt reports that as `Qt::ApplicationSuspended`, so the worker is never parked
|
||||||
|
on a dialog nobody can see, and the transcript carries a line saying why. In
|
||||||
|
the foreground the same two hooks wait two minutes (a permission) or five
|
||||||
|
(a question) and then answer "no" with a reason instead of waiting for good.
|
||||||
|
The GUI hop a platform tool makes (`onGuiThread()` in
|
||||||
|
`app/src/platform/android/AndroidTools.cpp`) pings first and gives up after
|
||||||
|
three seconds, and the header shows what the turn is doing
|
||||||
|
(`shell: make -j8`) so a long turn is not mistaken for a stuck one.
|
||||||
|
`--uitest` covers the window side of all this.
|
||||||
|
* **Documents**: `md_to_pdf` (the window's own tool,
|
||||||
|
`app/src/core/PdfTools.cpp`, owner `app:qt`) lays markdown or a workspace
|
||||||
|
`.md`/`.html` out as an A4 PDF with 15 mm margins and hands it over as a file
|
||||||
|
card, exactly the way `deliver_file` does; `QPrinter`/PrintSupport is not
|
||||||
|
used, so the phone gets it too. Qt's PDF engine embeds a font *subset*, which
|
||||||
|
is why a Chinese page is a few KB rather than the few MB a whole CJK font
|
||||||
|
would cost (see `app/README.md`).
|
||||||
|
* Still to come: the back gesture, and the icon glyphs -- `ThemeIcon` finds no
|
||||||
|
icon files on the phone and draws empty boxes where the desktop shows Breeze
|
||||||
|
icons.
|
||||||
|
|||||||
@@ -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` |
|
| `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` |
|
| `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` |
|
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_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 上次使用的模型 → 预设 |
|
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
||||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
|
| 推理强度 | `--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),每行 `<provider>.api_key=...`;
|
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
|
||||||
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
||||||
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
|
- 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
|
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 官方
|
### OpenAI 官方
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 9
|
#define CST_VERSION_MINOR 9
|
||||||
#define CST_VERSION_PATCH 6
|
#define CST_VERSION_PATCH 8
|
||||||
|
|
||||||
#define CST_VERSION_STR_(x) #x
|
#define CST_VERSION_STR_(x) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#define CST_OS_COMPAT_H
|
#define CST_OS_COMPAT_H
|
||||||
|
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
#include <stdio.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
@@ -12,6 +13,13 @@ extern "C" {
|
|||||||
#define OS_PATH_MAX 4096
|
#define OS_PATH_MAX 4096
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/* --- text --- */
|
||||||
|
/* One line of a file into *lineptr, growing it as needed: POSIX getline(3),
|
||||||
|
* which a MinGW CRT does not have (the session file readers used it directly
|
||||||
|
* and only the Windows build noticed). Returns the number of bytes read -- 0
|
||||||
|
* for an empty line -- or -1 at the end of the file. */
|
||||||
|
long long os_getline(char **lineptr, size_t *n, FILE *stream);
|
||||||
|
|
||||||
/* --- path --- */
|
/* --- path --- */
|
||||||
int os_path_is_abs(const char *p);
|
int os_path_is_abs(const char *p);
|
||||||
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
|
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
|
||||||
|
|||||||
Executable
+273
@@ -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 <exe>/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"
|
||||||
@@ -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 <nixpkgs> {};
|
||||||
|
|
||||||
|
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
|
||||||
@@ -16,6 +16,9 @@ win.mkShell {
|
|||||||
zip
|
zip
|
||||||
curl
|
curl
|
||||||
nsis
|
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 ];
|
buildInputs = [ win.windows.pthreads ];
|
||||||
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
|
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
|
||||||
|
|||||||
+53
-20
@@ -444,38 +444,71 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
|||||||
FILE *fp = fopen(p, "rb");
|
FILE *fp = fopen(p, "rb");
|
||||||
if (!fp)
|
if (!fp)
|
||||||
return 0;
|
return 0;
|
||||||
size_t cap = 1 << 16;
|
/* Reading a transcript must stay cheap: the session list asks for this for
|
||||||
char *line = malloc(cap);
|
* every stored session, on the GUI thread, every time the store changes.
|
||||||
int have_first = 0;
|
* So a line is only ever *parsed* while the first user message is still
|
||||||
while (line && fgets(line, (int)cap, fp)) {
|
* missing and only within the first kFirstUserBytes; everything else is
|
||||||
size_t n = strlen(line);
|
* counted by looking for newlines, which costs a memchr per block instead
|
||||||
if (n == cap - 1 && line[n - 1] != '\n') {
|
* of a cJSON tree per line. */
|
||||||
/* very long line: skip the rest, it is not the first user line */
|
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
|
||||||
int ch;
|
char *buf = malloc(kBlock);
|
||||||
while ((ch = fgetc(fp)) != EOF && ch != '\n')
|
if (!buf) {
|
||||||
;
|
fclose(fp);
|
||||||
info->messages++;
|
return 0;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (n <= 1)
|
size_t have = 0; /* bytes in buf that belong to the current line */
|
||||||
continue;
|
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));
|
||||||
|
/* A line counts once. A line that ran past a whole block was counted
|
||||||
|
* when its tail was dropped, so the newline that ends it is not a line
|
||||||
|
* of its own. */
|
||||||
|
if (len > 0 && !slice)
|
||||||
info->messages++;
|
info->messages++;
|
||||||
if (have_first)
|
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
|
||||||
continue;
|
*nl = 0; /* the parse reads a C string; the newline is ours */
|
||||||
cJSON *o = cJSON_Parse(line);
|
cJSON *o = cJSON_Parse(buf + at);
|
||||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||||
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||||
cJSON_IsString(c) && c->valuestring[0]) {
|
cJSON_IsString(c) && c->valuestring[0]) {
|
||||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||||
have_first = 1;
|
want_first = 0;
|
||||||
}
|
}
|
||||||
cJSON_Delete(o);
|
cJSON_Delete(o);
|
||||||
}
|
}
|
||||||
free(line);
|
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);
|
fclose(fp);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -22,6 +22,25 @@
|
|||||||
#define ERRBODY_MAX 4096
|
#define ERRBODY_MAX 4096
|
||||||
#define LLM_MAX_RETRIES 3
|
#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) {
|
static int ca_file_ok(const char *path) {
|
||||||
if (!path || !path[0])
|
if (!path || !path[0])
|
||||||
return 0;
|
return 0;
|
||||||
@@ -452,8 +471,31 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
cJSON *root = cJSON_CreateObject();
|
cJSON *root = cJSON_CreateObject();
|
||||||
cJSON_AddStringToObject(root, "model", req->model);
|
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);
|
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");
|
cJSON_AddTrueToObject(root, "stream");
|
||||||
const char *nousage = getenv("CST_NO_STREAM_USAGE");
|
const char *nousage = getenv("CST_NO_STREAM_USAGE");
|
||||||
if (!nousage || !nousage[0] || nousage[0] == '0') {
|
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])
|
if (req->send_reasoning && m->reasoning && m->reasoning[0])
|
||||||
cJSON_AddItemToObject(o, "reasoning_content",
|
cJSON_AddItemToObject(o, "reasoning_content",
|
||||||
clean_string(m->reasoning, 0));
|
clean_string(m->reasoning, 0));
|
||||||
if (m->prefix)
|
if (m->prefix) {
|
||||||
cJSON_AddTrueToObject(o, "prefix");
|
/* Kimi calls the OpenAI-style assistant prefill flag `partial`. */
|
||||||
|
cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cJSON_AddItemToArray(msgs, o);
|
cJSON_AddItemToArray(msgs, o);
|
||||||
if (role == CST_ROLE_TOOL && n_att)
|
if (role == CST_ROLE_TOOL && n_att)
|
||||||
|
|||||||
@@ -876,6 +876,11 @@ const char *cst_effort_wire(const char *provider, const char *model,
|
|||||||
pthread_mutex_unlock(&g_mu);
|
pthread_mutex_unlock(&g_mu);
|
||||||
if (strcmp(mode, "passthrough") == 0)
|
if (strcmp(mode, "passthrough") == 0)
|
||||||
return effort;
|
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) {
|
if (strcmp(mode, "openai") == 0) {
|
||||||
cst_model_info_t mi;
|
cst_model_info_t mi;
|
||||||
cst_model_info(provider, model, &mi);
|
cst_model_info(provider, model, &mi);
|
||||||
|
|||||||
@@ -15,6 +15,23 @@
|
|||||||
"prefix_completion": true,
|
"prefix_completion": true,
|
||||||
"effort": "passthrough"
|
"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": {
|
"openai": {
|
||||||
"api_base": "https://api.openai.com/v1",
|
"api_base": "https://api.openai.com/v1",
|
||||||
"key_env": "OPENAI_API_KEY",
|
"key_env": "OPENAI_API_KEY",
|
||||||
@@ -65,6 +82,16 @@
|
|||||||
{"match": "glm", "context": 128000},
|
{"match": "glm", "context": 128000},
|
||||||
{"match": "glm-*v*", "vision": true},
|
{"match": "glm-*v*", "vision": true},
|
||||||
{"match": "kimi", "context": 131072},
|
{"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": "pixtral", "vision": true},
|
||||||
{"match": "llava", "vision": true},
|
{"match": "llava", "vision": true},
|
||||||
{"match": "*-vl*", "vision": true},
|
{"match": "*-vl*", "vision": true},
|
||||||
|
|||||||
@@ -1217,3 +1217,40 @@ int unsetenv(const char *name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endif /* _WIN32 */
|
#endif /* _WIN32 */
|
||||||
|
|
||||||
|
/* getline(3) itself where the libc has it; the same thing spelled out with
|
||||||
|
* fgetc() where it does not (Windows), so the callers read the same on every
|
||||||
|
* platform. */
|
||||||
|
long long os_getline(char **lineptr, size_t *n, FILE *stream) {
|
||||||
|
#ifndef _WIN32
|
||||||
|
return (long long)getline(lineptr, n, stream);
|
||||||
|
#else
|
||||||
|
if (!lineptr || !n || !stream)
|
||||||
|
return -1;
|
||||||
|
if (!*lineptr || *n == 0) {
|
||||||
|
*n = 256;
|
||||||
|
*lineptr = (char *)malloc(*n);
|
||||||
|
if (!*lineptr)
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
size_t len = 0;
|
||||||
|
int c = 0;
|
||||||
|
while ((c = fgetc(stream)) != EOF) {
|
||||||
|
if (len + 2 > *n) {
|
||||||
|
const size_t nn = (*n) * 2;
|
||||||
|
char *nb = (char *)realloc(*lineptr, nn);
|
||||||
|
if (!nb)
|
||||||
|
return -1;
|
||||||
|
*lineptr = nb;
|
||||||
|
*n = nn;
|
||||||
|
}
|
||||||
|
(*lineptr)[len++] = (char)c;
|
||||||
|
if (c == '\n')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (len == 0 && c == EOF)
|
||||||
|
return -1;
|
||||||
|
(*lineptr)[len] = 0;
|
||||||
|
return (long long)len;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|||||||
+2
-34
@@ -15,39 +15,7 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#ifdef _WIN32
|
#include "os_compat.h"
|
||||||
typedef long long cst_ssize_t;
|
|
||||||
static cst_ssize_t cst_getline(char **lineptr, size_t *n, FILE *stream) {
|
|
||||||
if (!lineptr || !n || !stream)
|
|
||||||
return -1;
|
|
||||||
if (!*lineptr || *n == 0) {
|
|
||||||
*n = 256;
|
|
||||||
*lineptr = (char *)malloc(*n);
|
|
||||||
if (!*lineptr)
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
size_t len = 0;
|
|
||||||
int c = 0;
|
|
||||||
while ((c = fgetc(stream)) != EOF) {
|
|
||||||
if (len + 2 > *n) {
|
|
||||||
size_t nn = (*n) * 2;
|
|
||||||
char *nb = (char *)realloc(*lineptr, nn);
|
|
||||||
if (!nb)
|
|
||||||
return -1;
|
|
||||||
*lineptr = nb;
|
|
||||||
*n = nn;
|
|
||||||
}
|
|
||||||
(*lineptr)[len++] = (char)c;
|
|
||||||
if (c == '\n')
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (len == 0 && c == EOF)
|
|
||||||
return -1;
|
|
||||||
(*lineptr)[len] = 0;
|
|
||||||
return (cst_ssize_t)len;
|
|
||||||
}
|
|
||||||
#define getline cst_getline
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct cst_session {
|
struct cst_session {
|
||||||
char path[4096];
|
char path[4096];
|
||||||
@@ -158,7 +126,7 @@ static void load_existing(cst_session_t *s) {
|
|||||||
return;
|
return;
|
||||||
char *line = NULL;
|
char *line = NULL;
|
||||||
size_t cap = 0;
|
size_t cap = 0;
|
||||||
while (getline(&line, &cap, rp) != -1) {
|
while (os_getline(&line, &cap, rp) != -1) {
|
||||||
cJSON *o = cJSON_Parse(line);
|
cJSON *o = cJSON_Parse(line);
|
||||||
if (!o || !cJSON_IsObject(o)) {
|
if (!o || !cJSON_IsObject(o)) {
|
||||||
cJSON_Delete(o);
|
cJSON_Delete(o);
|
||||||
|
|||||||
+14
-1
@@ -159,6 +159,14 @@ static const cst_tool_def_t TOOLS[] = {
|
|||||||
NULL,
|
NULL,
|
||||||
{{"todos", S, 1, "the whole list, one item per line"}},
|
{{"todos", S, 1, "the whole list, one item per line"}},
|
||||||
CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META},
|
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",
|
{"web_fetch",
|
||||||
"Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML "
|
"Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML "
|
||||||
"is reduced to readable text.",
|
"is reduced to readable text.",
|
||||||
@@ -204,7 +212,12 @@ static const cst_tool_def_t TOOLS[] = {
|
|||||||
"Search earlier and current sessions for text.",
|
"Search earlier and current sessions for text.",
|
||||||
{{"query", S, 1, "text to look for"},
|
{{"query", S, 1, "text to look for"},
|
||||||
{"max_matches", I, 0, "stop after this many matches (default 20)"},
|
{"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},
|
CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META},
|
||||||
{"device_info",
|
{"device_info",
|
||||||
"Where this agent is running: system and CPU count, workspace, sandbox, "
|
"Where this agent is running: system and CPU count, workspace, sandbox, "
|
||||||
|
|||||||
@@ -83,12 +83,30 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
if (want > 100)
|
if (want > 100)
|
||||||
want = 100;
|
want = 100;
|
||||||
int icase = tool_arg_bool(c, "ignore_case", 1);
|
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];
|
static char ids[200][128];
|
||||||
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
|
||||||
cst_buf_t hits = {0};
|
cst_buf_t hits = {0};
|
||||||
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
int matches = 0, in_sessions = 0, scanned_sessions = 0;
|
||||||
for (int i = 0; i < n && matches < (int)want; i++) {
|
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];
|
char path[OS_PATH_MAX];
|
||||||
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
|
||||||
sizeof(path)) != 0)
|
sizeof(path)) != 0)
|
||||||
@@ -103,11 +121,27 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|||||||
char *line = NULL;
|
char *line = NULL;
|
||||||
size_t cap = 0;
|
size_t cap = 0;
|
||||||
cst_buf_t one = {0};
|
cst_buf_t one = {0};
|
||||||
ssize_t got;
|
long long got;
|
||||||
while ((got = getline(&line, &cap, fp)) > 0) {
|
while ((got = os_getline(&line, &cap, fp)) > 0) {
|
||||||
lineno++;
|
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;
|
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);
|
cJSON *o = cJSON_Parse(line);
|
||||||
if (!o)
|
if (!o)
|
||||||
continue;
|
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) {
|
if (matches == 0) {
|
||||||
cst_buf_free(&hits);
|
cst_buf_free(&hits);
|
||||||
return tool_set_resultf(r, 0,
|
return tool_set_resultf(r, 0,
|
||||||
"no match for \"%.80s\" in %d stored session%s "
|
"no match for \"%.80s\" in %d of %d stored session%s"
|
||||||
"(the store holds %d)\n",
|
" (%.1f MB scanned%s)\n",
|
||||||
query, scanned_sessions,
|
query, scanned_sessions, n,
|
||||||
scanned_sessions == 1 ? "" : "s", 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_t out = {0};
|
||||||
cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n",
|
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)
|
if (matches >= (int)want)
|
||||||
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
|
||||||
"query)\n");
|
"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);
|
cst_buf_free(&hits);
|
||||||
return tool_take_buf(r, &out, 0);
|
return tool_take_buf(r, &out, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx,
|
|||||||
const ctl_tool_call_t *c, char **out);
|
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,
|
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
cst_tool_result_t *r);
|
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,
|
int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
cst_tool_result_t *r);
|
cst_tool_result_t *r);
|
||||||
int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
|||||||
+209
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include "cJSON.h"
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
#include "cst_attach.h"
|
#include "cst_attach.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
@@ -23,6 +24,211 @@ typedef struct {
|
|||||||
int overflow;
|
int overflow;
|
||||||
} fetch_t;
|
} 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) {
|
static size_t on_body(char *p, size_t sz, size_t n, void *ud) {
|
||||||
fetch_t *f = ud;
|
fetch_t *f = ud;
|
||||||
size_t len = sz * n;
|
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 &&
|
if (strncasecmp(url, "http://", 7) != 0 &&
|
||||||
strncasecmp(url, "https://", 8) != 0)
|
strncasecmp(url, "https://", 8) != 0)
|
||||||
return tool_set_result(r, "error: only http:// and https:// URLs", 1);
|
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);
|
long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT);
|
||||||
if (max_out <= 0)
|
if (max_out <= 0)
|
||||||
max_out = FETCH_DEFAULT_OUT;
|
max_out = FETCH_DEFAULT_OUT;
|
||||||
|
|||||||
@@ -298,6 +298,77 @@ static void test_cache_hints(void) {
|
|||||||
cJSON_Delete(j);
|
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) {
|
static void test_tools_snapshot(void) {
|
||||||
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
|
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
|
||||||
cst_llm_request_t r;
|
cst_llm_request_t r;
|
||||||
@@ -559,6 +630,7 @@ int main(void) {
|
|||||||
test_document_parts();
|
test_document_parts();
|
||||||
test_reasoning_and_prefix();
|
test_reasoning_and_prefix();
|
||||||
test_cache_hints();
|
test_cache_hints();
|
||||||
|
test_kimi_wire_format();
|
||||||
test_tools_snapshot();
|
test_tools_snapshot();
|
||||||
test_gen_params();
|
test_gen_params();
|
||||||
test_escaping();
|
test_escaping();
|
||||||
|
|||||||
@@ -267,6 +267,98 @@ static void test_old_store(void) {
|
|||||||
EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it");
|
EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The scan behind the session list (cst_agent_store_info): the count is a
|
||||||
|
* count of transcript lines, a line longer than one read block is still one
|
||||||
|
* line, and the first user message is only looked for in the head of the file
|
||||||
|
* -- a sidebar refresh is not allowed to read a whole transcript of tool
|
||||||
|
* output to find a greeting. */
|
||||||
|
static void test_info_scan(void) {
|
||||||
|
char id[64] = "";
|
||||||
|
EXPECT(cst_agent_store_new_id(id, sizeof(id)) == 0, "an id for the scan");
|
||||||
|
char path[1200];
|
||||||
|
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0,
|
||||||
|
"a session path to write into");
|
||||||
|
cst_session_t *s = cst_session_open(path);
|
||||||
|
EXPECT(s != NULL, "its transcript opens");
|
||||||
|
if (!s)
|
||||||
|
return;
|
||||||
|
cst_message_t m;
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_USER;
|
||||||
|
m.content = (char *)"the first user line";
|
||||||
|
EXPECT(cst_session_append(s, &m) == 0, "the first user message goes in");
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.content = (char *)"an answer";
|
||||||
|
EXPECT(cst_session_append(s, &m) == 0, "and an answer");
|
||||||
|
/* One line that does not fit a read block: counted once, not once per block
|
||||||
|
* of it (which is what a wrong block count would report). */
|
||||||
|
const size_t big = 70 * 1024;
|
||||||
|
char *huge = malloc(big + 1);
|
||||||
|
EXPECT(huge != NULL, "a buffer for one very long line");
|
||||||
|
if (!huge)
|
||||||
|
return;
|
||||||
|
memset(huge, 'x', big);
|
||||||
|
huge[big] = 0;
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.content = huge;
|
||||||
|
EXPECT(cst_session_append(s, &m) == 0, "a line longer than a block goes in");
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.content = (char *)"a short one after it";
|
||||||
|
EXPECT(cst_session_append(s, &m) == 0, "and a short line after it");
|
||||||
|
cst_session_close(s);
|
||||||
|
|
||||||
|
cst_session_info_t in;
|
||||||
|
memset(&in, 0, sizeof(in));
|
||||||
|
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0, "its info reads back");
|
||||||
|
EXPECT(in.messages == 4, "four lines counted, the long one once");
|
||||||
|
EXPECT(strcmp(in.first_user, "the first user line") == 0,
|
||||||
|
"and the first user line is the one it found");
|
||||||
|
|
||||||
|
/* Now a head of tool output wider than the window the search gets, with no
|
||||||
|
* user line in it: the greeting below stays unseen (and nothing read
|
||||||
|
* megabytes of transcript to look for it). */
|
||||||
|
char id2[64] = "";
|
||||||
|
EXPECT(cst_agent_store_new_id(id2, sizeof(id2)) == 0, "an id for the tail");
|
||||||
|
char path2[1200];
|
||||||
|
EXPECT(cst_agent_store_path_for_id(g_ws, id2, path2, sizeof(path2)) == 0,
|
||||||
|
"a second session path");
|
||||||
|
s = cst_session_open(path2);
|
||||||
|
EXPECT(s != NULL, "the second transcript opens");
|
||||||
|
if (!s) {
|
||||||
|
free(huge);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const size_t mid = 60 * 1024;
|
||||||
|
char *chunk = malloc(mid + 1);
|
||||||
|
if (chunk) {
|
||||||
|
memset(chunk, 'y', mid);
|
||||||
|
chunk[mid] = 0;
|
||||||
|
for (int i = 0; i < 6; i++) { /* 360 KiB of tool output, no user line */
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_TOOL;
|
||||||
|
m.content = chunk;
|
||||||
|
if (cst_session_append(s, &m) != 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
free(chunk);
|
||||||
|
}
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_USER;
|
||||||
|
m.content = (char *)"a greeting far down the file";
|
||||||
|
EXPECT(cst_session_append(s, &m) == 0, "a user line after all that");
|
||||||
|
cst_session_close(s);
|
||||||
|
|
||||||
|
memset(&in, 0, sizeof(in));
|
||||||
|
EXPECT(cst_agent_store_info(g_ws, id2, &in) == 0, "its info reads too");
|
||||||
|
EXPECT(in.messages == 7, "seven lines, one per line");
|
||||||
|
EXPECT(in.first_user[0] == 0,
|
||||||
|
"a user line past the window is not called the first one");
|
||||||
|
free(huge);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) {
|
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) {
|
||||||
perror("tmpdir");
|
perror("tmpdir");
|
||||||
@@ -280,6 +372,7 @@ int main(void) {
|
|||||||
test_ids();
|
test_ids();
|
||||||
test_new_session();
|
test_new_session();
|
||||||
test_old_store();
|
test_old_store();
|
||||||
|
test_info_scan();
|
||||||
|
|
||||||
test_rmrf(g_ws);
|
test_rmrf(g_ws);
|
||||||
if (nfail) {
|
if (nfail) {
|
||||||
|
|||||||
@@ -236,6 +236,50 @@ static void test_session_search(cst_tool_ctx_t *ctx) {
|
|||||||
c = mk("session_search", NULL);
|
c = mk("session_search", NULL);
|
||||||
EXPECT(run(ctx, c, &r) == 1, "a query is required");
|
EXPECT(run(ctx, c, &r) == 1, "a query is required");
|
||||||
cst_tool_result_free(&r);
|
cst_tool_result_free(&r);
|
||||||
|
|
||||||
|
/* A store is not read to the end on a whim: a byte budget stops the walk
|
||||||
|
* and says so, and it never reports a match it did not get to. */
|
||||||
|
{
|
||||||
|
char path2[512];
|
||||||
|
EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-130000-big", path2,
|
||||||
|
sizeof(path2)) == 0,
|
||||||
|
"a second session path");
|
||||||
|
cst_session_t *s2 = cst_session_open(path2);
|
||||||
|
EXPECT(s2 != NULL, "its transcript opens");
|
||||||
|
if (s2) {
|
||||||
|
cst_message_t m2;
|
||||||
|
memset(&m2, 0, sizeof(m2));
|
||||||
|
m2.role = CST_ROLE_USER;
|
||||||
|
m2.content = (char *)"the start of a long one";
|
||||||
|
EXPECT(cst_session_append(s2, &m2) == 0, "a first line goes in");
|
||||||
|
for (int i = 0; i < 600; i++) {
|
||||||
|
memset(&m2, 0, sizeof(m2));
|
||||||
|
m2.role = CST_ROLE_ASSISTANT;
|
||||||
|
m2.content = (char *)"a line of tool output that says nothing special";
|
||||||
|
if (cst_session_append(s2, &m2) != 0)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
memset(&m2, 0, sizeof(m2));
|
||||||
|
m2.role = CST_ROLE_ASSISTANT;
|
||||||
|
m2.content = (char *)"the needle is only at the very end";
|
||||||
|
EXPECT(cst_session_append(s2, &m2) == 0, "the needle goes in last");
|
||||||
|
cst_session_close(s2);
|
||||||
|
}
|
||||||
|
/* Enough lines that a byte budget is spent before the end is reached. */
|
||||||
|
c = mk("session_search", "query", "needle", "max_bytes", "4096", NULL);
|
||||||
|
EXPECT(run(ctx, c, &r) == 0 && r.out != NULL, "a bounded search runs");
|
||||||
|
EXPECT(r.out && strstr(r.out, "budget stopped the walk") != NULL,
|
||||||
|
"the byte budget stops the walk and says so");
|
||||||
|
EXPECT(r.out == NULL || strstr(r.out, "the needle is only") == NULL,
|
||||||
|
"and it does not report a match it never got to");
|
||||||
|
cst_tool_result_free(&r);
|
||||||
|
/* The same query without a budget reaches the end and finds it. */
|
||||||
|
c = mk("session_search", "query", "needle", NULL);
|
||||||
|
EXPECT(run(ctx, c, &r) == 0 && r.out &&
|
||||||
|
strstr(r.out, "the needle is only") != NULL,
|
||||||
|
"the unbounded search finds it");
|
||||||
|
cst_tool_result_free(&r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- device_info: the environment, without a shell ---------------------- */
|
/* --- device_info: the environment, without a shell ---------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user