Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235652604d | ||
|
|
60047ca5ce | ||
|
|
b7034baec4 | ||
|
|
db8cdd7dd1 | ||
|
|
81aeee7d1e | ||
|
|
a99f78134a | ||
|
|
8e5bdd0c9d | ||
|
|
f8030ec201 | ||
|
|
a34c74e453 | ||
|
|
9d7ad9433e | ||
|
|
4c69ef39ab | ||
|
|
da04913071 | ||
|
|
7b2e7a3dbd | ||
|
|
4e990c8e35 | ||
|
|
dae51e06c7 | ||
|
|
042d7f6999 | ||
|
|
2ffe74b37c | ||
|
|
580d533656 | ||
|
|
08008a5a4c | ||
|
|
7299f39245 | ||
|
|
d4f65b17cf | ||
|
|
60cbce3c5c | ||
|
|
84f9585588 | ||
|
|
c2320f946b | ||
|
|
82dee65942 | ||
|
|
3fd039cb51 | ||
|
|
8694afca94 | ||
|
|
cbd71b83c7 | ||
|
|
be4025e276 | ||
|
|
69842edf16 | ||
|
|
f5d340bd2a | ||
|
|
da812cc7e8 | ||
|
|
5ba215b36c |
@@ -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,10 +28,31 @@ third_party/busybox-src/
|
||||
third_party/prefix-mingw/
|
||||
third_party/prefix-linux-*/
|
||||
third_party/build-linux-*/
|
||||
# the Windows app package (scripts/package_windows_app.sh): MSYS2's Qt and the
|
||||
# host tools it is built with, both fetched/unpacked by the script
|
||||
third_party/msys-qt6/
|
||||
third_party/prefix-qt6-host
|
||||
third_party/prefix-android-*/
|
||||
third_party/build-android-*/
|
||||
third_party/src/
|
||||
vendor/sashidown/*.o
|
||||
vendor/sashidown/src/*.o
|
||||
vendor/sashidown/libsashidown.a
|
||||
vendor/sashidown/libsashidown-*.a
|
||||
vendor/sashidown/libsashidown-mingw.a
|
||||
.gen/
|
||||
.build-tmp/
|
||||
|
||||
# Android APK builds (scripts/build_apk_android.sh) and what gradle leaves
|
||||
build-apk-android/
|
||||
*.apk
|
||||
*.aab
|
||||
app/android/.gradle/
|
||||
app/android/build/
|
||||
app/android/local.properties
|
||||
|
||||
# the engine as a static library (app/ links it; built by `make`)
|
||||
libcstcore.a
|
||||
|
||||
# nix-build
|
||||
result
|
||||
|
||||
@@ -6,6 +6,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
|
||||
option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON)
|
||||
option(CST_BUILD_APP "Build the Qt front end in app/ (in-process engine, no port)" OFF)
|
||||
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
|
||||
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
|
||||
|
||||
@@ -64,6 +65,29 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMENT "Building libsashidown for MinGW"
|
||||
VERBATIM)
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||
# The host archive has to stay host-built (the CLI, the tests and the
|
||||
# Makefile link it), so the NDK build keeps its own copy, one per ABI.
|
||||
# CMake's compiler is plain clang (the --target/--sysroot flags live in its
|
||||
# own variables), so the vendor make gets the per-target wrapper instead:
|
||||
# aarch64-linux-android24-clang carries both.
|
||||
get_filename_component(_SD_CC_BIN "${CMAKE_C_COMPILER}" DIRECTORY)
|
||||
string(REPLACE "-none" "" _SD_TRIPLE "${CMAKE_C_COMPILER_TARGET}")
|
||||
set(_SD_CC "${_SD_CC_BIN}/${_SD_TRIPLE}-clang")
|
||||
if(NOT EXISTS "${_SD_CC}")
|
||||
set(_SD_CC "${CMAKE_C_COMPILER}")
|
||||
endif()
|
||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown-${ANDROID_ABI}.a")
|
||||
add_custom_command(
|
||||
OUTPUT "${SASHIDOWN_A}"
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
|
||||
CC=${_SD_CC} AR=${CMAKE_AR} RANLIB=${CMAKE_RANLIB}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMENT "Building libsashidown for Android (${ANDROID_ABI})"
|
||||
VERBATIM)
|
||||
else()
|
||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
|
||||
add_custom_command(
|
||||
@@ -103,6 +127,7 @@ set(CST_CORE_SOURCES
|
||||
src/tools/fs_tools.c
|
||||
src/tools/agent_tools.c
|
||||
src/tools/web.c
|
||||
src/tools/session_tools.c
|
||||
src/tools/diff.c
|
||||
src/undo.c
|
||||
src/permissions.c
|
||||
@@ -181,7 +206,34 @@ if(WIN32)
|
||||
set_source_files_properties(${_CST_TRE_SOURCES} PROPERTIES COMPILE_OPTIONS "-w")
|
||||
endif()
|
||||
|
||||
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
|
||||
# --- the engine as a library ---------------------------------------------
|
||||
# cstcore is the core without main()/TUI: the CLI links it and the Qt front
|
||||
# end in app/ (CST_BUILD_APP) embeds the very same objects in-process, so the
|
||||
# engine is compiled once per build instead of once per front end.
|
||||
add_library(cstcore STATIC ${CST_CORE_SOURCES})
|
||||
add_dependencies(cstcore sashidown)
|
||||
target_include_directories(cstcore PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||
"${SD_DIR}/include"
|
||||
"${CJSON_DIR}"
|
||||
)
|
||||
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
|
||||
if(WIN32)
|
||||
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(cstcore PUBLIC
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads
|
||||
ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
||||
else()
|
||||
target_compile_definitions(cstcore PRIVATE _XOPEN_SOURCE_EXTENDED)
|
||||
target_link_libraries(cstcore PUBLIC
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads m)
|
||||
endif()
|
||||
|
||||
if(CST_BUILD_APP)
|
||||
add_subdirectory(app)
|
||||
endif()
|
||||
|
||||
set(CST_SOURCES src/main.c)
|
||||
|
||||
if(CST_BUILD_TUI)
|
||||
list(APPEND CST_SOURCES src/textsel.c src/tui.c)
|
||||
@@ -204,14 +256,8 @@ if(NOT WIN32)
|
||||
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
|
||||
endif()
|
||||
|
||||
target_link_libraries(cst-agent PRIVATE
|
||||
"${SASHIDOWN_A}"
|
||||
CURL::libcurl
|
||||
Threads::Threads
|
||||
)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(cst-agent PRIVATE m)
|
||||
endif()
|
||||
# cstcore carries sashidown, curl, threads and libm as usage requirements.
|
||||
target_link_libraries(cst-agent PRIVATE cstcore)
|
||||
|
||||
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
|
||||
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
|
||||
@@ -325,12 +371,14 @@ endforeach()
|
||||
|
||||
# Tests that link the whole core (no TUI).
|
||||
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||
test_apply_patch test_session_roundtrip test_attach
|
||||
test_shell test_diff test_tools_fs test_registry
|
||||
test_apply_patch test_session_roundtrip test_store test_attach
|
||||
test_artifacts
|
||||
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
||||
test_undo test_config test_models test_model_registry test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent test_lowil
|
||||
test_win_utf8 test_os_dir test_tmpdir)
|
||||
test_win_utf8 test_os_dir test_tmpdir
|
||||
test_tool_meta test_embed)
|
||||
if(CST_BUILD_WEBUI)
|
||||
# test_webui drives the real server; the stub has nothing to test.
|
||||
list(APPEND _CST_CORE_TESTS test_webui)
|
||||
|
||||
@@ -104,6 +104,7 @@ CORE_SRC = \
|
||||
src/tools/fs_tools.c \
|
||||
src/tools/agent_tools.c \
|
||||
src/tools/web.c \
|
||||
src/tools/session_tools.c \
|
||||
src/tools/diff.c \
|
||||
src/undo.c \
|
||||
src/permissions.c \
|
||||
@@ -120,6 +121,10 @@ CORE_SRC = \
|
||||
|
||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||
OBJ = $(SRC:.c=.o)
|
||||
# The core as a static library: what the Qt app embeds in-process (app/), so
|
||||
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
|
||||
CORE_OBJ = $(CORE_SRC:.c=.o)
|
||||
CST_CORE_LIB = libcstcore.a
|
||||
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
|
||||
.gen/models_builtin.inc .gen/web_assets.inc
|
||||
|
||||
@@ -135,12 +140,17 @@ TESTS = \
|
||||
tests/test_sse_decode \
|
||||
tests/test_apply_patch \
|
||||
tests/test_session_roundtrip \
|
||||
tests/test_store \
|
||||
tests/test_attach \
|
||||
tests/test_artifacts \
|
||||
tests/test_pdf_text \
|
||||
tests/test_shell \
|
||||
tests/test_diff \
|
||||
tests/test_tools_fs \
|
||||
tests/test_tools_ext \
|
||||
tests/test_registry \
|
||||
tests/test_tool_meta \
|
||||
tests/test_embed \
|
||||
tests/test_undo \
|
||||
tests/test_config \
|
||||
tests/test_models \
|
||||
@@ -171,7 +181,11 @@ TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
|
||||
|
||||
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
|
||||
|
||||
all: sashidown-lib cst-agent
|
||||
all: sashidown-lib cst-agent $(CST_CORE_LIB)
|
||||
|
||||
$(CST_CORE_LIB): $(CORE_OBJ) FORCE
|
||||
rm -f $@
|
||||
$(AR) rcs $@ $(CORE_OBJ)
|
||||
|
||||
cmake-gcc:
|
||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||
@@ -257,7 +271,8 @@ demo: cst-agent
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
|
||||
clean:
|
||||
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc
|
||||
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
|
||||
.gen/web_assets.inc
|
||||
|
||||
install: cst-agent
|
||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||
|
||||
@@ -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,脚本自动编译)。
|
||||
### 桌面 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)已随仓库提供。
|
||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||
`--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` |
|
||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||
|
||||
@@ -225,7 +242,11 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|
||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
||||
|
||||
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
|
||||
推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。
|
||||
推理强度会按 provider 适配:DeepSeek/OpenAI 使用 `reasoning_effort`,Kimi K3 使用同名字段,Kimi K2 使用 `thinking` 并保留 `reasoning_content`。Kimi 默认开启 1 小时隐式 prompt cache,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭提示。
|
||||
|
||||
Kimi 与 DeepSeek 还提供官方联网工具:`web_search` 调用 provider 的 Search API,`web_fetch` 调用 provider 的 Fetch API;其它 provider 继续使用通用 HTTP 抓取。
|
||||
|
||||
`kimi` 是按量计费的 Kimi 开放平台,`kimi-code` 是会员订阅的 Kimi Code API;两者的 Base URL 和 API Key 不通用。
|
||||
|
||||
详见 [docs/env_openai.md](docs/env_openai.md)。
|
||||
|
||||
@@ -273,6 +294,7 @@ cst-agent --headless --prompt 'List files'
|
||||
| `-N` / `--new` | 开新会话 |
|
||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
||||
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
|
||||
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
|
||||
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
|
||||
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
||||
@@ -283,7 +305,7 @@ cst-agent --headless --prompt 'List files'
|
||||
|
||||
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
|
||||
|
||||
会话落盘:`<workspace>/.agent/sessions/<id>.jsonl` + `CURRENT`;`.agent/` 自带 `.gitignore`,不会被误提交。
|
||||
会话落盘:一个会话一个目录 `<workspace>/.agent/sessions/<id>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
|
||||
|
||||
### 内置工具
|
||||
|
||||
@@ -299,12 +321,20 @@ cst-agent --headless --prompt 'List files'
|
||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
||||
| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 |
|
||||
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
||||
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
||||
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
||||
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
|
||||
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
|
||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||
| `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 |
|
||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 |
|
||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||
|
||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
|
||||
|
||||
# The Qt front end (app/) embeds the C engine in-process: it links libcstcore
|
||||
# and never opens a port. Two targets:
|
||||
#
|
||||
# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the
|
||||
# C side is still guarded on machines without Qt Quick
|
||||
# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules
|
||||
# are there
|
||||
#
|
||||
# Standalone build (builds the engine from ../ too):
|
||||
# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j
|
||||
# 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
|
||||
#
|
||||
# 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:
|
||||
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
||||
|
||||
option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON)
|
||||
option(CST_APP_SELFTEST "build the C embedding smoke test" ON)
|
||||
|
||||
# --- the engine ----------------------------------------------------------
|
||||
# Used as a subproject (../CMakeLists.txt already defined cstcore, engine
|
||||
# compiled once for CLI and app) or built here from the sources next door.
|
||||
if(NOT TARGET cstcore)
|
||||
set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
|
||||
include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson")
|
||||
file(GLOB CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/*.c"
|
||||
"${CST_APP_ROOT}/src/tools/*.c"
|
||||
"${CST_APP_ROOT}/vendor/cjson/cJSON.c")
|
||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/main.c"
|
||||
"${CST_APP_ROOT}/src/tui.c"
|
||||
"${CST_APP_ROOT}/src/tui_stub.c"
|
||||
"${CST_APP_ROOT}/src/textsel.c")
|
||||
# The HTTP front end is not part of an in-process app: no server, no port.
|
||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/net.c"
|
||||
"${CST_APP_ROOT}/src/http_srv.c"
|
||||
"${CST_APP_ROOT}/src/webui.c")
|
||||
# web_md.c stays in: the window renders markdown with it (AgentCore::
|
||||
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
|
||||
# is left out.
|
||||
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
||||
include_directories("${CST_APP_ROOT}/.gen")
|
||||
endif()
|
||||
add_library(cstcore STATIC ${CST_APP_CORE_SRC})
|
||||
target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1)
|
||||
find_package(CURL REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m)
|
||||
message(STATUS "app: building the engine from ${CST_APP_ROOT}")
|
||||
else()
|
||||
message(STATUS "app: linking the cstcore target of the parent build")
|
||||
endif()
|
||||
|
||||
# --- the C smoke test ----------------------------------------------------
|
||||
if(CST_APP_SELFTEST)
|
||||
add_executable(cst-app-selftest tools/selftest.c)
|
||||
target_link_libraries(cst-app-selftest PRIVATE cstcore)
|
||||
enable_testing()
|
||||
add_test(NAME cst-app-selftest COMMAND cst-app-selftest)
|
||||
endif()
|
||||
|
||||
# --- the GUI -------------------------------------------------------------
|
||||
if(CST_APP_GUI)
|
||||
# Android's platform file talks to the platform through QtAndroidPrivate
|
||||
# (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate.
|
||||
if(ANDROID)
|
||||
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test
|
||||
CorePrivate QUIET)
|
||||
else()
|
||||
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
|
||||
endif()
|
||||
if(NOT Qt6_FOUND)
|
||||
message(WARNING
|
||||
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
|
||||
"(the C selftest still builds; install e.g. qt6.qtdeclarative / "
|
||||
"qt6.qtquickcontrols2 and reconfigure)")
|
||||
else()
|
||||
qt_standard_project_setup()
|
||||
# Qt 6.5+ policies: keep the module resource at :/qt/qml/<URI>/ (what
|
||||
# main.cpp loads) and let CMake generate the qmldir of qml/ for us.
|
||||
qt_policy(SET QTP0001 NEW)
|
||||
qt_policy(SET QTP0004 NEW)
|
||||
# The front end's platform tools: one file per platform, the same split
|
||||
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
||||
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
|
||||
# the rest use the Linux file.
|
||||
if(ANDROID)
|
||||
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
||||
elseif(WIN32)
|
||||
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
|
||||
else()
|
||||
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
||||
endif()
|
||||
qt_add_executable(cst-agent-app
|
||||
src/main.cpp
|
||||
src/core/AgentCore.cpp
|
||||
src/core/ToolProvider.cpp
|
||||
src/core/PdfTools.cpp
|
||||
src/ui/UiTest.cpp
|
||||
src/ui/ThemeIcons.cpp
|
||||
src/ui/SessionImages.cpp
|
||||
src/ui/BackKey.cpp
|
||||
${CST_APP_PLATFORM_SRC})
|
||||
target_include_directories(cst-agent-app PRIVATE
|
||||
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
||||
target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}")
|
||||
if(CST_APP_QML_IMPORT)
|
||||
target_compile_definitions(cst-agent-app PRIVATE
|
||||
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
|
||||
endif()
|
||||
target_link_libraries(cst-agent-app PRIVATE
|
||||
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
|
||||
if(ANDROID)
|
||||
# app/src/platform/android/AndroidTools.cpp talks to the platform
|
||||
# through QtAndroidPrivate (permissions) -- a private header, hence the
|
||||
# private module for its include path.
|
||||
target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate)
|
||||
endif()
|
||||
if(WIN32)
|
||||
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
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) -----------------------------------------------------
|
||||
# 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
|
||||
# nothing listens on a port). The manifest, icons and strings live in
|
||||
# app/android/, which androiddeployqt copies next to its own gradle
|
||||
# project. minSdk 24 is the engine's own API_LEVEL (scripts/
|
||||
# build_third_party_android.sh builds curl/mbedTLS for android-24).
|
||||
if(ANDROID)
|
||||
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../include/cst_version.h"
|
||||
_cst_ver_lines REGEX "#define CST_VERSION_(MAJOR|MINOR|PATCH) ")
|
||||
foreach(_line ${_cst_ver_lines})
|
||||
if(_line MATCHES "CST_VERSION_MAJOR ([0-9]+)")
|
||||
set(_cst_vmaj "${CMAKE_MATCH_1}")
|
||||
elseif(_line MATCHES "CST_VERSION_MINOR ([0-9]+)")
|
||||
set(_cst_vmin "${CMAKE_MATCH_1}")
|
||||
elseif(_line MATCHES "CST_VERSION_PATCH ([0-9]+)")
|
||||
set(_cst_vpat "${CMAKE_MATCH_1}")
|
||||
endif()
|
||||
endforeach()
|
||||
set_target_properties(cst-agent-app PROPERTIES
|
||||
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
|
||||
QT_ANDROID_MIN_SDK_VERSION 24
|
||||
QT_ANDROID_TARGET_SDK_VERSION 35
|
||||
QT_ANDROID_VERSION_NAME "${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}"
|
||||
QT_ANDROID_VERSION_CODE 1
|
||||
QT_ANDROID_ABIS "${ANDROID_ABI}")
|
||||
message(STATUS "app: Android package "
|
||||
"${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}, ABI ${ANDROID_ABI}, "
|
||||
"minSdk 24, targetSdk 35")
|
||||
|
||||
# Signing: androiddeployqt signs the APK itself and refuses a signed
|
||||
# gradle output ("Could not copy unsigned APK"), so the keystore goes
|
||||
# here, not into gradle. The path, alias and passwords arrive through
|
||||
# the QT_ANDROID_KEYSTORE_* environment variables;
|
||||
# scripts/build_apk_android.sh points them at the standard Android debug
|
||||
# key it creates on first use, and CST_ANDROID_KEYSTORE overrides it.
|
||||
if(DEFINED ENV{QT_ANDROID_KEYSTORE_PATH} AND NOT "$ENV{QT_ANDROID_KEYSTORE_PATH}" STREQUAL "")
|
||||
set(QT_ANDROID_SIGN_APK TRUE)
|
||||
message(STATUS "app: signing the APK with "
|
||||
"$ENV{QT_ANDROID_KEYSTORE_PATH} ($ENV{QT_ANDROID_KEYSTORE_ALIAS})")
|
||||
else()
|
||||
message(STATUS "app: QT_ANDROID_KEYSTORE_PATH unset -- the APK stays "
|
||||
"unsigned and 'adb install' will refuse it (INSTALL_PARSE_FAILED_NO_CERTIFICATES)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Theme is read by every component, so it is a QML singleton (one instance,
|
||||
# no object needed in the tree).
|
||||
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
|
||||
# Sources live in qml/, but a module whose files sit in a subdirectory of
|
||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||
# requires absolute URLs" on every start).
|
||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
|
||||
ThemeIcon Theme ImageStrip)
|
||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||
endforeach()
|
||||
qt_add_qml_module(cst-agent-app
|
||||
URI CstAgent
|
||||
VERSION 1.0
|
||||
QML_FILES
|
||||
qml/Main.qml
|
||||
qml/ChatView.qml
|
||||
qml/Sidebar.qml
|
||||
qml/SessionRow.qml
|
||||
qml/MessageCard.qml
|
||||
qml/ToolCard.qml
|
||||
qml/ThinkBlock.qml
|
||||
qml/NoteLine.qml
|
||||
qml/TodoPanel.qml
|
||||
qml/Composer.qml
|
||||
qml/ConsentDialog.qml
|
||||
qml/AskDialog.qml
|
||||
qml/SettingsDialog.qml
|
||||
qml/ThemeIcon.qml
|
||||
qml/ImageStrip.qml
|
||||
qml/Theme.qml)
|
||||
|
||||
# The icons the window asks for, compiled in (app/icons, Breeze's scalable
|
||||
# names copied at 24@3x). The desktop finds them in the platform's icon
|
||||
# theme and only falls back to these (ThemeIcons.cpp); a machine without
|
||||
# any theme -- Android -- has nothing else, and before this the QML glyph
|
||||
# fallback was all that showed, which the phone's fonts cannot render.
|
||||
set(CST_APP_ICONS
|
||||
go-down mail-attachment application-menu settings-configure edit-copy
|
||||
edit-delete arrow-down arrow-right list-add task-complete box
|
||||
document-edit text-x-generic folder-open edit-find code-context
|
||||
internet-services format-list-unordered help-hint)
|
||||
set(_cst_icon_files "")
|
||||
foreach(_icon ${CST_APP_ICONS})
|
||||
list(APPEND _cst_icon_files "${CMAKE_CURRENT_SOURCE_DIR}/icons/${_icon}.svg")
|
||||
endforeach()
|
||||
qt_add_resources(cst-agent-app "cst_icons"
|
||||
PREFIX "/cst-icons"
|
||||
BASE "${CMAKE_CURRENT_SOURCE_DIR}/icons"
|
||||
FILES ${_cst_icon_files})
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,467 @@
|
||||
# app/ — the Qt front end
|
||||
|
||||
CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP
|
||||
server, no port, no token, no second process — the app creates a `cst_agent_t`
|
||||
directly and drives it through `cst_agent.h`.
|
||||
|
||||
```
|
||||
app/ the GUI
|
||||
src/main.cpp entry point, --workspace / --selftest
|
||||
src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent
|
||||
src/core/ToolProvider.h how the front end hands tools to the engine
|
||||
src/platform/<os>/ platform tools the app implements itself
|
||||
qml/Main.qml the window (chat, tool runs, consent dialog)
|
||||
tools/selftest.c C smoke test of the embedding path (no Qt)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
# engine + GUI + C smoke test
|
||||
cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build-app -j
|
||||
QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
|
||||
|
||||
# the app alone
|
||||
./build-app/cst-agent-app --workspace ~/notes
|
||||
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
|
||||
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
|
||||
./build-app/cst-agent-app --selftest # same checks as the C test
|
||||
```
|
||||
|
||||
The standalone build is the supported one: `app/CMakeLists.txt` uses the
|
||||
`cstcore` target when the parent project defines one, and otherwise builds
|
||||
the engine itself from `../src` (TUI, `main.c` and the HTTP front end
|
||||
excluded). Wiring it into the repository's `CMakeLists.txt` as
|
||||
`-DCST_APP=ON` is on the list below.
|
||||
|
||||
Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`,
|
||||
`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on
|
||||
Windows/macOS). Without them the configure step warns and builds only the C
|
||||
smoke test — that is deliberate: the embedding path stays verifiable
|
||||
everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick
|
||||
Controls and keeps the layout in Kirigami's shape (single column, cards,
|
||||
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
|
||||
`-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
|
||||
|
||||
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
||||
`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so
|
||||
there is no protocol to version, no socket to authenticate and no second
|
||||
process to babysit — and the CLI, TUI and WebUI keep working off the same
|
||||
source.
|
||||
|
||||
- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread;
|
||||
the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas,
|
||||
tool events, tool results, todo, turn end).
|
||||
- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the
|
||||
GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL /
|
||||
ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in
|
||||
`.agent/permissions.json`, so a desktop decision is visible to the TUI.
|
||||
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
|
||||
result, not an exception.
|
||||
|
||||
## Layout and input
|
||||
|
||||
One shell, three sizes (everything is styled from `qml/Theme.qml`):
|
||||
|
||||
- **Desktop**: the sidebar is docked and its divider can be dragged (double
|
||||
click restores the auto width). Hover states, tooltips, selectable text and
|
||||
a wheel-scrolled transcript.
|
||||
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
|
||||
folds away, the header grows a `☰` button, and the settings slide in over
|
||||
the transcript with a scrim; the sidebar itself scrolls, because a phone
|
||||
window cannot show it all.
|
||||
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
|
||||
not depend on hover, the composer asks the platform keyboard not to
|
||||
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
|
||||
|
||||
`--uitest` checks this without a human: it loads the window, clicks with
|
||||
synthesized **mouse** events, taps with a synthesized **touch** device, types
|
||||
into the composer, drives the session list, then narrows the window to 460 px
|
||||
to assert the dock → drawer switch and opens/closes the drawer. One line per
|
||||
check, non-zero exit on failure:
|
||||
|
||||
```sh
|
||||
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
|
||||
# ok wide window: sidebar docked
|
||||
# ok mouse click on "general" reaches the engine
|
||||
# ok touch tap on "coding" reaches the engine
|
||||
# ...
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
A GUI session is a directory in the workspace's store
|
||||
(`.agent/sessions/<id>/session.jsonl`, with its `title`, its `attachments/` and
|
||||
its sub-agent transcripts beside it), the same one the CLI, the TUI and the
|
||||
WebUI use — `AgentCore::open()` resolves the workspace's current session before
|
||||
the engine starts, so nothing is written next to the process's working
|
||||
directory. A store an older version wrote (flat `<id>.jsonl` files) still
|
||||
reads; `cst-agent --migrate-store` moves it in.
|
||||
|
||||
- The sidebar lists the store (newest first): title or first prompt, message
|
||||
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
|
||||
deletes one (the engine first moves off a session it has open, so it never
|
||||
sits on a file that is gone), and a click switches.
|
||||
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
|
||||
messages and returns the same rows the live signals build — messages,
|
||||
thoughts, notes, and one card per tool call, with its result paired by name
|
||||
(a call with no result keeps state `unfinished`, so no spinner runs
|
||||
forever). A reopened session therefore renders like the turn that wrote it.
|
||||
- Session controls are disabled while a turn runs: switching would move the
|
||||
engine out from under it.
|
||||
|
||||
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
|
||||
session through the store API (a tool call with its result), the checks click
|
||||
its row, assert the five replayed rows and the paired result, start two new
|
||||
sessions (the view empties), delete one by its `×` (the open one stays), and
|
||||
switch back.
|
||||
|
||||
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
|
||||
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
|
||||
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
|
||||
|
||||
The sidebar shows where the engine points (`provider · model`); the gear in the
|
||||
header opens the dialog that changes it.
|
||||
|
||||
- The provider list is the engine's own preset table (`cst_provider_count()`),
|
||||
with each entry's base URL and where its key would come from: "key saved"
|
||||
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from
|
||||
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side —
|
||||
the dialog only ever describes it. The window starts the engine without a
|
||||
key (`AgentCore::open()` is passed an empty one), so `open()` resolves it
|
||||
for the provider it starts on: saved key, then the provider's env variable,
|
||||
then `CST_API_KEY` — the same order the CLI uses in `main.c`. Without that
|
||||
the key sat in the file but never in the turn, and every start asked for it
|
||||
again.
|
||||
- "Use this" calls `AgentCore::useProvider()`, which runs
|
||||
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config:
|
||||
a pasted key is verified against the provider's `/models` before it is saved
|
||||
(offline it is kept unverified and the header says so), the model is
|
||||
remembered in `settings.json`, and the **session is not touched**. Model
|
||||
candidates come from the provider entry, the fetched cache
|
||||
(`cst_ps_cached`, no network) and the remembered pick; the label next to the
|
||||
field is `cst_model_info` ("200K · text only").
|
||||
- The dialog also reports the session's token use (`cst_agent_usage`).
|
||||
|
||||
`--uitest` checks it end to end: the presets are readable, an unknown provider
|
||||
is refused, the gear opens the panel, the provider rows exist, a model typed
|
||||
into the field reaches the engine when "Use this" is clicked, the note says so,
|
||||
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
|
||||
directory, so settings and keys of the real user are never rewritten.
|
||||
|
||||
## Custom providers, model parameters
|
||||
|
||||
The dialog's **Advanced options** covers the two things the presets cannot:
|
||||
a provider of your own, and what the model should do.
|
||||
|
||||
- A custom provider is an entry the engine keeps in
|
||||
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
|
||||
`include/cst_models.h`) — the same file a hand-written override would go
|
||||
into, so the GUI and a text editor are interchangeable:
|
||||
|
||||
```json
|
||||
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
|
||||
"key_env": "MYLLM_API_KEY",
|
||||
"default_model": "m1",
|
||||
"models": ["m1", "m2"] } } }
|
||||
```
|
||||
|
||||
`AgentCore::addProvider()` / `removeProvider()` write it
|
||||
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
|
||||
reload the registry, so the provider is in the list, in `models[]` on the
|
||||
wire and in `/provider` of the CLI at once. A name a preset uses overrides
|
||||
that preset (a self-hosted endpoint, a proxy); the other content of the file
|
||||
(model rules) is kept, and a file that is not a JSON object is never
|
||||
overwritten. The key is still a key: it is verified against `GET /models` of
|
||||
the new base URL before it is saved, and stored per provider name as before.
|
||||
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
|
||||
remembered per provider in `settings.json` (`"params": {...}`,
|
||||
`cst_settings_remember_params()`), and applied to the engine's config by
|
||||
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
|
||||
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
|
||||
An **empty field is not sent at all** — that is not the same as sending 0,
|
||||
which is a real temperature; `cst_llm_build_body()` only adds a field that
|
||||
was set (`tests/test_llm_body.c:test_gen_params`).
|
||||
- A long model list (a router lists hundreds) is filtered and capped at 40
|
||||
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
|
||||
put and nothing can spill out of the frame.
|
||||
|
||||
## Attachments
|
||||
|
||||
The next prompt can carry files, and they arrive three ways:
|
||||
|
||||
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
|
||||
- **Dropping files on the window** — a `DropArea` covers it; while something is
|
||||
dragged over, the border turns accent and says "drop to attach".
|
||||
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
|
||||
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
|
||||
intercepts the keystroke when there is an image.
|
||||
|
||||
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
|
||||
`cst_agent_attach_bytes()`: the engine validates the file, copies it into the
|
||||
session's own directory (`.agent/sessions/<id>/attachments/`), and the next
|
||||
`run_turn` carries it — the
|
||||
window only shows what the engine's `pending` list says, one chip per file in
|
||||
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
|
||||
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
|
||||
with the turn. When the model cannot be shown images
|
||||
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
|
||||
them as text notes instead.
|
||||
|
||||
`--uitest` writes a file into its scratch workspace and checks the whole path:
|
||||
`attachFile` succeeds, the engine reports one pending attachment, a missing file
|
||||
is refused with a reason, the composer shows one chip, "clear all" empties the
|
||||
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
|
||||
`attachClipboard()` behave.
|
||||
|
||||
## Images
|
||||
|
||||
What a session keeps as a picture is shown, not only stored: a file
|
||||
`view_image` returned, an image an MCP server sent, a screenshot the user
|
||||
attached or pasted. Each one appears as a thumbnail in the transcript -- a
|
||||
prompt's pictures under the message, a tool's under its card -- with the name,
|
||||
size and origin in the tooltip. The height is capped so a 4K screenshot cannot
|
||||
push the conversation off the screen, and a file the store no longer has says
|
||||
which image it was instead of leaving a hole.
|
||||
|
||||
The view never touches the filesystem on its own authority: the thumbnails
|
||||
come from `image://cstattach/<stored name>` (`app/src/ui/SessionImages.cpp`),
|
||||
where the name is the one the engine wrote next to the session and is checked
|
||||
with the engine's own `cst_attach_rel_ok()`, so only the session's attachment
|
||||
directory can be read. The picture is decoded at the size of its thumbnail
|
||||
rather than at its full resolution, and the provider's directory follows the
|
||||
session: `AgentCore::attachmentDir()` is re-read on every switch.
|
||||
|
||||
A document (PDF, UTF-8 text) still arrives as its name in the message text --
|
||||
those are sent to the model as text, not as a picture.
|
||||
|
||||
## Files the agent delivers
|
||||
|
||||
The opposite direction: the engine's `deliver_file` tool (registered in
|
||||
`src/tools/registry.c`, run in `src/tools/fs_tools.c`) copies a finished file
|
||||
out of the workspace into the session's own store, and the transcript shows it
|
||||
as a **file card** -- an extension badge, the file's name, its type and size,
|
||||
and two buttons.
|
||||
|
||||
- The copy is marked `for_user` (`cst_attachment_t.for_user`,
|
||||
`cst_attach_import_artifact()`): any bytes up to `CST_ARTIFACT_MAX_BYTES`
|
||||
(64 MB), the name's extension decides the mime, and **no request ever
|
||||
contains it** -- `src/llm_openai.c` skips those entries when it builds the
|
||||
body, which is what keeps a delivered report from being pasted back into the
|
||||
context. `tests/test_artifacts.c` holds that down at the wire level.
|
||||
- A card never invents a path. The row carries the stored file's name, and
|
||||
`AgentCore::openArtifact()` / `shareArtifact()` accept it only when one of
|
||||
the session's own messages carries that name (`artifactPathOf()`, plus the
|
||||
engine's `cst_attach_rel_ok()`), so a forged name is refused with a reason.
|
||||
- **Open** hands the file to the system: Android builds a `content://` URI
|
||||
through the app's FileProvider, checks `resolveActivity()`, starts
|
||||
`ACTION_VIEW` and grants the reader permission; a desktop calls
|
||||
`QDesktopServices::openUrl`. **Share** uses the phone's share sheet; a
|
||||
desktop has none, so the path goes to the clipboard.
|
||||
- `CST_APP_NO_OPEN=1` makes both do nothing but report the line they would
|
||||
write. `--uitest` sets it, so a test run never opens anything for real: it
|
||||
replays a session with a delivered `report.md`, checks the card and its two
|
||||
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
||||
and checks a name the session does not own is refused.
|
||||
|
||||
### Making one: `md_to_pdf`
|
||||
|
||||
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
|
||||
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
|
||||
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
|
||||
margins in the workspace, and goes through the same
|
||||
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
|
||||
file card the person can open, print or share, and its bytes never enter a
|
||||
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
|
||||
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
|
||||
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
|
||||
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
|
||||
phone has no print subsystem. The tool runs on the turn's worker thread, and
|
||||
`--uitest` runs it through the registry and checks the file starts with
|
||||
`%PDF-`.
|
||||
|
||||
The card row on the phone is the same one a picture uses: the store keeps the
|
||||
PDF next to the session, the image provider refuses it (it is not an image),
|
||||
and the file card carries it.
|
||||
|
||||
## Transcript, tool calls, streaming text
|
||||
|
||||
- A tool call is a card of its own, and the call **id** is what pairs it with
|
||||
its result: the same tool may run twice in a turn, and pairing by name (what
|
||||
the window did before) hung the first card on "running" forever while the
|
||||
second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry
|
||||
the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the
|
||||
session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an
|
||||
old text-protocol session still falls back to the name.
|
||||
- Nothing may keep spinning after a turn: `closeAllRows()` settles every open
|
||||
row when the turn ends, and a call with no result becomes "no result"
|
||||
(`unfinished`) instead of a spinner that runs forever.
|
||||
- The answer is rendered **while it streams**: a 120 ms timer re-renders the
|
||||
open answer with the engine's own markdown renderer (`cst_web_md_html`, via
|
||||
`AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted
|
||||
instead of turning into markdown when the turn is over. A replayed session
|
||||
renders once, when its rows are built.
|
||||
- Every signal handler is a plain function on the window
|
||||
(`engineToolEvent`, `engineToolResult`, `engineTurnFinished`,
|
||||
`engineAssistant`, ...) so `--uitest` can drive the same path: it opens two
|
||||
calls of one tool, delivers one result, and asserts that the result landed on
|
||||
its own card and that the turn end settled the other one. `rowJson(i)` exists
|
||||
for that (a row as JSON — an object would not survive
|
||||
`QMetaObject::invokeMethod`).
|
||||
|
||||
## Icons, colors
|
||||
|
||||
- Icons come from the desktop's **icon theme** (Breeze on KDE), through
|
||||
`image://csttheme/<name>?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`).
|
||||
Qt Quick has no icon provider of its own without Kirigami, so the window used
|
||||
unicode glyphs before (a clip emoji, a gear, a hamburger): those render in
|
||||
whichever font covers them — colour emoji in a grey toolbar — and are a box
|
||||
on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place
|
||||
that asks for an icon, and every use still names the old character as
|
||||
`glyph:`: a host with no icon theme degrades to what it looked like before
|
||||
rather than going blank.
|
||||
- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons`
|
||||
and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them
|
||||
from XDG once a theme name is set. It then tries the theme the desktop chose,
|
||||
else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising
|
||||
Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a
|
||||
desktop Qt install) — without it the icons fall back to the glyphs instead of
|
||||
disappearing.
|
||||
- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil,
|
||||
read → text page, list/glob → folder, grep → magnifier, shell → braces,
|
||||
web → globe, plan → list, ask → question mark. Toolbar icons are 22 px
|
||||
(Breeze's toolbar size), card and row icons 14–16 px.
|
||||
- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it
|
||||
carries hints, secondary lines and provider URLs. Anything written *on* the
|
||||
accent fill (a selected provider row, the open session) uses `accentText`,
|
||||
`accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is
|
||||
unreadable. The names are not `onAccent…`: QML reads every `onFoo` property
|
||||
as a signal handler and refuses the assignment.
|
||||
- The composer's frame is one `Theme.pad` around its column on **all** sides;
|
||||
with the top margin alone the clip and Send hung over the lower border.
|
||||
|
||||
## Tools the app provides
|
||||
|
||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
||||
and the owner `app:<name>`, exactly like a plugin or MCP server would:
|
||||
|
||||
```c
|
||||
cst_tool_def_t def = {0};
|
||||
def.name = "notification_send";
|
||||
def.desc = "Send a system notification.";
|
||||
def.perm = CST_TP_ASK; /* class: drives the consent dialog */
|
||||
def.perm_name = "notify"; /* policy: what the user is really granting */
|
||||
def.platforms = ANDROID_ONLY; /* NULL = everywhere */
|
||||
def.available = probe; /* 0 / -1 with a reason; cached */
|
||||
def.run_ex = my_run;
|
||||
def.ud = my_state;
|
||||
def.owner = "app:android";
|
||||
cst_tool_register(&def);
|
||||
```
|
||||
|
||||
- `platforms` + `available` decide *whether* the tool exists here: an
|
||||
Android-only tool never reaches the prompt or `tools[]` on Linux, and
|
||||
`cst_tool_run()` refuses it with the reason if a model asks anyway.
|
||||
- A front-end tool may take over the **name** of a built-in that cannot run
|
||||
on this host (the Android clipboard replacing the desktop `xclip` one);
|
||||
it may not shadow one that works.
|
||||
- `cst_tool_unregister_owner("app:android")` drops them again — do it when a
|
||||
platform service goes away, then `cst_tool_meta_reload()`.
|
||||
- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a
|
||||
launcher that knows better).
|
||||
|
||||
Note `run_ex` runs on the worker thread while the turn is going: touch the
|
||||
GUI only through queued signals.
|
||||
|
||||
## Platform notes
|
||||
|
||||
| Platform | How the engine gets built | Platform tools |
|
||||
|---|---|---|
|
||||
| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) |
|
||||
| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path |
|
||||
| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`Notifier.java`: channel + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) |
|
||||
| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) |
|
||||
|
||||
Android's long-term shape: the core lives in the APK (no Termux needed), the
|
||||
platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
|
||||
The build flag that keeps Unix helpers out of the picture is
|
||||
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
|
||||
A Java class is added by dropping it in `app/android/src/<package>/`:
|
||||
androiddeployqt copies `app/android/` with the rest of the package source and
|
||||
Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script
|
||||
change is needed (`Notifier.java` is the first one; a Java helper is the only
|
||||
way to catch a throwable where the platform throws instead of returning).
|
||||
`CstService.java` (the foreground service that holds a phone awake while a
|
||||
turn runs) went in the same way; it is started by `app::holdForeground()`
|
||||
when a turn begins and stopped by `app::releaseForeground()` when it ends,
|
||||
so nothing about it is visible when the app is idle.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
|
||||
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
|
||||
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
|
||||
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
|
||||
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
|
||||
`--uitest` for mouse, touch and small-screen checks
|
||||
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
||||
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
|
||||
- [x] attachments: picker, drop on the window, Ctrl+V image paste
|
||||
- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback
|
||||
- [x] contrast pass: faint text and selected rows are readable
|
||||
- [x] images in the transcript: tool results and attachments are drawn as
|
||||
thumbnails (`image://cstattach/<stored name>`, see the Images section)
|
||||
- [x] Android APK: the engine is linked in (no port), builds, installs under
|
||||
`xyz.whcself.cstagent` and runs on the device (see `docs/android.md`)
|
||||
- [x] the saved provider key reaches the engine on every start (`AgentCore::open()`
|
||||
resolves it: saved key, provider environment, `CST_API_KEY`)
|
||||
- [x] delivered files: `deliver_file` stores the copy next to the session and the
|
||||
transcript draws a file card with Open and Share (see the Files section)
|
||||
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
|
||||
file card's open/share through the app's FileProvider
|
||||
- [x] a turn keeps running with the app off screen (Android foreground service
|
||||
+ 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)
|
||||
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
||||
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
||||
global shortcuts
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- The APK's manifest (QT_ANDROID_PACKAGE_SOURCE_DIR in app/CMakeLists.txt):
|
||||
androiddeployqt copies this file and res/ into its gradle project and
|
||||
fills in the INSERT_* placeholders below (version code and name, library
|
||||
name, app arguments). An XML comment may not contain a double hyphen, so
|
||||
the placeholder syntax is only spelled out in the attributes themselves.
|
||||
|
||||
The SDK levels are not here on purpose: they come from
|
||||
QT_ANDROID_MIN_SDK_VERSION and QT_ANDROID_TARGET_SDK_VERSION in
|
||||
app/CMakeLists.txt, and androiddeployqt writes them into the generated
|
||||
gradle.properties. The app name and icon come from res/ next to this
|
||||
file (values/strings.xml, drawable/ic_launcher.xml).
|
||||
|
||||
WHAT THIS APP IS: the engine runs in this process (no server, no port) and
|
||||
writes everything (workspace, session store, settings, credentials) under
|
||||
the app's own data directory, which main.cpp points the engine at (see
|
||||
androidRoot() there).
|
||||
|
||||
Permissions: INTERNET for the providers the user configures,
|
||||
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
|
||||
asks at runtime; the tool stays hidden until it is granted), the
|
||||
foreground-service permissions with WAKE_LOCK for the service that keeps
|
||||
a turn going while the phone is in a pocket (CstService.java), and
|
||||
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
|
||||
can read a path in shared storage that a task names rather than picks:
|
||||
Android only offers the Settings screen for it, so main.cpp opens that
|
||||
once and everything still works without it: what the system picker (SAF)
|
||||
hands over is copied into the app's own directory (AttachFile.java). No
|
||||
cleartext traffic: every provider URL is https. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="xyz.whcself.cstagent"
|
||||
android:installLocation="auto"
|
||||
android:versionCode="-- %%INSERT_VERSION_CODE%% --"
|
||||
android:versionName="-- %%INSERT_VERSION_NAME%% --">
|
||||
|
||||
<!-- %%INSERT_PERMISSIONS -->
|
||||
<!-- %%INSERT_FEATURES -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- "All files access": without it the engine can only read the app's own
|
||||
directory, so a task that names a file in shared storage cannot be
|
||||
answered. Android 11+ has no runtime dialog for it, only the Settings
|
||||
screen (AttachFile.java opens it once). Files the user picks work
|
||||
either way: their content:// grant is copied into the app first. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true" />
|
||||
|
||||
<application
|
||||
android:name="org.qtproject.qt.android.bindings.QtApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="false">
|
||||
|
||||
<activity
|
||||
android:name="org.qtproject.qt.android.bindings.QtActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|keyboard|keyboardHidden|navigation|density|mcc|mnc"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="unspecified"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="-- %%INSERT_APP_LIB_NAME%% --" />
|
||||
<meta-data
|
||||
android:name="android.app.arguments"
|
||||
android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
|
||||
</activity>
|
||||
|
||||
<!-- The share tool hands a file out of the workspace, and a file
|
||||
crossing an app boundary has to travel as a content:// uri. This
|
||||
is Qt's own provider (androidx.core.content.FileProvider, paths
|
||||
from res/xml/qtprovider_paths.xml): its files-path is the app's
|
||||
files directory, which is where the workspace lives. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.qtprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/qtprovider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- A turn can outlive the screen: while one is running the front end
|
||||
starts this service (AgentCore::send -> app::holdForeground), which
|
||||
makes the process a foreground one, so Android neither freezes nor
|
||||
kills it in the background, and takes a partial wake lock so the
|
||||
CPU keeps going with the phone in a pocket. dataSync is the type
|
||||
that fits "the app is finishing some work". -->
|
||||
<service
|
||||
android:name="xyz.whcself.cstagent.CstService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Launcher icon: the terminal prompt the app is about (">_"), the window's
|
||||
own colors (bg #12161a, accent #5cc0f0, faint #8d959c). A vector drawable
|
||||
works from API 24 up; a proper adaptive icon (mipmap-anydpi-v26 + a PNG
|
||||
fallback) is polish for later. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#12161a"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5cc0f0"
|
||||
android:strokeWidth="7"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M32,38 L52,54 L32,70" />
|
||||
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#8d959c"
|
||||
android:strokeWidth="7"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M58,74 L78,74" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The name under the launcher icon. -->
|
||||
<resources>
|
||||
<string name="app_name">CST-agent</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,189 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.provider.Settings;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/* What the user picks in the system file chooser comes back as a content://
|
||||
* URI, and the engine opens files by path -- so the bytes are copied into the
|
||||
* app's own directory first. That copy needs no permission at all (the grant
|
||||
* travels with the URI), which is what makes picking work on a phone that
|
||||
* grants nothing else.
|
||||
*
|
||||
* The "all files access" below is a different thing: it is what lets the
|
||||
* engine read /sdcard by path afterwards (a model that opens a file the user
|
||||
* names rather than picks). Android 11+ has no runtime dialog for it -- only
|
||||
* the Settings screen -- so it is a request, not a demand: everything works
|
||||
* without it, one file at a time.
|
||||
*
|
||||
* Qt swallows a Java exception (it comes back as a null object), so every
|
||||
* method catches its own and answers with a reason instead.
|
||||
*/
|
||||
public final class AttachFile {
|
||||
private AttachFile() {}
|
||||
|
||||
private static final long MAX_BYTES = 256L * 1024 * 1024;
|
||||
|
||||
public static boolean isContent(String s) {
|
||||
return s != null && s.startsWith("content:");
|
||||
}
|
||||
|
||||
/* content://<uri> -> the path of a copy inside destDir; "" when the string
|
||||
* is already a path; "error: ..." when the copy failed. */
|
||||
public static String materialize(Context ctx, String pathOrUri, String destDir) {
|
||||
if (pathOrUri == null || pathOrUri.isEmpty())
|
||||
return "error: no file was picked";
|
||||
if (!isContent(pathOrUri))
|
||||
return "";
|
||||
try {
|
||||
if (ctx == null)
|
||||
return "error: no Android context";
|
||||
final Uri uri = Uri.parse(pathOrUri);
|
||||
String name = displayName(ctx, uri);
|
||||
if (name == null || name.trim().isEmpty())
|
||||
name = uri.getLastPathSegment();
|
||||
name = sanitize(name);
|
||||
File dir = new File(destDir);
|
||||
if (!dir.isDirectory() && !dir.mkdirs())
|
||||
return "error: cannot use " + destDir;
|
||||
File out = unique(dir, name);
|
||||
final ContentResolver cr = ctx.getContentResolver();
|
||||
final InputStream in = cr.openInputStream(uri);
|
||||
if (in == null)
|
||||
return "error: the picker gave no data for that file";
|
||||
long total = 0;
|
||||
try {
|
||||
final OutputStream os = new FileOutputStream(out);
|
||||
try {
|
||||
final byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
total += n;
|
||||
if (total > MAX_BYTES) {
|
||||
os.close();
|
||||
in.close();
|
||||
out.delete();
|
||||
return "error: that file is larger than 256 MB";
|
||||
}
|
||||
os.write(buf, 0, n);
|
||||
}
|
||||
} finally {
|
||||
os.close();
|
||||
}
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
if (total == 0) {
|
||||
out.delete();
|
||||
return "error: that file is empty";
|
||||
}
|
||||
return out.getAbsolutePath();
|
||||
} catch (Throwable t) {
|
||||
return "error: cannot read the picked file: " + t;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android 11+ keeps "all files access" in a special app-op; older systems
|
||||
* have no such gate. */
|
||||
public static boolean hasAllFilesAccess(Context ctx) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < 30)
|
||||
return true;
|
||||
return Environment.isExternalStorageManager();
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* The Settings screen Android requires for it. "" when it was opened. */
|
||||
public static String requestAllFilesAccess(Context ctx) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < 30 || ctx == null)
|
||||
return "";
|
||||
if (Environment.isExternalStorageManager())
|
||||
return "";
|
||||
final Intent intent = new Intent(
|
||||
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
Uri.parse("package:" + ctx.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
ctx.startActivity(intent);
|
||||
} catch (ActivityNotFoundException notFound) {
|
||||
final Intent all = new Intent(
|
||||
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
|
||||
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
ctx.startActivity(all);
|
||||
}
|
||||
return "";
|
||||
} catch (Throwable t) {
|
||||
return "cannot open the storage settings: " + t;
|
||||
}
|
||||
}
|
||||
|
||||
private static String displayName(Context ctx, Uri uri) {
|
||||
Cursor c = null;
|
||||
try {
|
||||
c = ctx.getContentResolver().query(uri,
|
||||
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
|
||||
null);
|
||||
if (c != null && c.moveToFirst()) {
|
||||
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (i >= 0)
|
||||
return c.getString(i);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
/* fall back to the last path segment */
|
||||
} finally {
|
||||
if (c != null)
|
||||
c.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A plain file name: no separators, no traversal, no control bytes. */
|
||||
private static String sanitize(String name) {
|
||||
if (name == null || name.isEmpty())
|
||||
return "picked.bin";
|
||||
final StringBuilder b = new StringBuilder();
|
||||
for (int i = 0; i < name.length() && b.length() < 120; i++) {
|
||||
final char ch = name.charAt(i);
|
||||
if (ch == '/' || ch == '\\' || ch < 0x20)
|
||||
b.append('_');
|
||||
else
|
||||
b.append(ch);
|
||||
}
|
||||
String s = b.toString();
|
||||
while (s.startsWith("."))
|
||||
s = s.substring(1);
|
||||
if (s.isEmpty())
|
||||
return "picked.bin";
|
||||
return s;
|
||||
}
|
||||
|
||||
private static File unique(File dir, String name) {
|
||||
File f = new File(dir, name);
|
||||
if (!f.exists())
|
||||
return f;
|
||||
final int dot = name.lastIndexOf('.');
|
||||
final String stem = dot > 0 ? name.substring(0, dot) : name;
|
||||
final String ext = dot > 0 ? name.substring(dot) : "";
|
||||
for (int i = 2; i < 1000; i++) {
|
||||
f = new File(dir, stem + "-" + i + ext);
|
||||
if (!f.exists())
|
||||
return f;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
/**
|
||||
* The service that keeps a turn running while the app is not on screen.
|
||||
*
|
||||
* A phone freezes an app it cannot see (and may kill it), so a turn that takes
|
||||
* minutes -- a build, a long web fetch, a provider that thinks -- dies in the
|
||||
* pocket. While one is going, AgentCore::send() starts this service through
|
||||
* app::holdForeground(); being a foreground service makes the process one
|
||||
* Android does not touch, and the partial wake lock keeps the CPU awake with
|
||||
* the screen off. turnFinished() stops it again (app::releaseForeground()), so
|
||||
* the permanent notification exists exactly as long as the work does.
|
||||
*
|
||||
* Started with an explicit component (AndroidTools.cpp builds the Intent), no
|
||||
* intent filter: nothing outside the app can start it.
|
||||
*/
|
||||
public class CstService extends Service {
|
||||
/** Same channel the notification_send tool uses: one place in settings. */
|
||||
private static final String CHANNEL_ID = "cst-agent";
|
||||
private static final int NOTE_ID = 2;
|
||||
private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
|
||||
/** A turn that runs longer than this is stuck; the lock must not outlive it. */
|
||||
private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||
|
||||
private PowerManager.WakeLock wake;
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
stopEverything();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
String text = intent == null ? null : intent.getStringExtra("text");
|
||||
try {
|
||||
startForeground(NOTE_ID, buildNote(text));
|
||||
} catch (Throwable t) {
|
||||
/* Android 12+ refuses a foreground start from the background: the
|
||||
* turn then simply runs without the protection rather than not at
|
||||
* all. Nothing to show the user here -- the turn's own result is
|
||||
* what matters. */
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
acquireWake();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseWake();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void stopEverything() {
|
||||
releaseWake();
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
else
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private void acquireWake() {
|
||||
try {
|
||||
if (wake == null) {
|
||||
PowerManager pm =
|
||||
(PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
if (pm == null)
|
||||
return;
|
||||
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"cst-agent:turn");
|
||||
wake.setReferenceCounted(false);
|
||||
}
|
||||
wake.acquire(WAKE_LOCK_TIMEOUT_MS);
|
||||
} catch (Throwable ignored) {
|
||||
/* no wake lock: the turn still runs while the screen is on */
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseWake() {
|
||||
try {
|
||||
if (wake != null && wake.isHeld())
|
||||
wake.release();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/** "the agent is working: <first line of the prompt>", tapping opens it. */
|
||||
private Notification buildNote(String text) {
|
||||
Context ctx = getApplicationContext();
|
||||
NotificationManager manager =
|
||||
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager != null && Build.VERSION.SDK_INT >= 26) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setDescription("A turn is running");
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= 26
|
||||
? new Notification.Builder(ctx, CHANNEL_ID)
|
||||
: new Notification.Builder(ctx);
|
||||
builder.setContentTitle("CST-agent is working")
|
||||
.setSmallIcon(smallIcon())
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false);
|
||||
if (text != null && !text.isEmpty())
|
||||
builder.setContentText(text);
|
||||
Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
|
||||
if (open != null) {
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** A framework drawable first (the same order the tool uses), then the app's. */
|
||||
private int smallIcon() {
|
||||
int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
|
||||
if (icon == 0)
|
||||
icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
|
||||
if (icon == 0)
|
||||
icon = getApplicationInfo().icon;
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/**
|
||||
* The notification behind the engine's notification_send tool.
|
||||
*
|
||||
* It lives in Java because a Java exception is invisible to C++ by the time
|
||||
* Qt's JNI wrapper is done with the call: Notification.Builder.build() can
|
||||
* throw (a small icon Android refuses, a channel it does not know) and the
|
||||
* JNI call then simply returns null -- the tool could only say "failed, no
|
||||
* exception detail" on a device where the phone's own shade never saw
|
||||
* anything. Catching it here turns the failure into the line the tool shows.
|
||||
*
|
||||
* Called from app/src/platform/android/AndroidTools.cpp through QJniObject.
|
||||
* androiddeployqt copies this file with the rest of the package source
|
||||
* (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds
|
||||
* "src" to java.srcDirs, so no build script change is needed for a file
|
||||
* added here.
|
||||
*/
|
||||
public final class Notifier {
|
||||
private Notifier() {}
|
||||
|
||||
/**
|
||||
* Post one notification. An empty channelId means an API below 26, which
|
||||
* has no channels. Returns "" when it went out, the Java failure
|
||||
* otherwise (class name and message), so the tool result can name it.
|
||||
*/
|
||||
public static String post(Context ctx, String channelId, String title,
|
||||
String text, int iconId, Intent tap, int id) {
|
||||
try {
|
||||
Notification.Builder builder = channelId == null || channelId.isEmpty()
|
||||
? new Notification.Builder(ctx)
|
||||
: new Notification.Builder(ctx, channelId);
|
||||
builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId);
|
||||
if (tap != null) {
|
||||
tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pending = PendingIntent.getActivity(
|
||||
ctx, 0, tap,
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
builder.setContentIntent(pending);
|
||||
}
|
||||
NotificationManager manager = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager == null)
|
||||
return "no NotificationManager";
|
||||
manager.notify(id, builder.build());
|
||||
return "";
|
||||
} catch (Throwable t) {
|
||||
String message = t.getMessage();
|
||||
return t.getClass().getSimpleName()
|
||||
+ (message == null || message.isEmpty() ? "" : ": " + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m7.707031 3l-.707031.707031 6.125 6.125 1.167969 1.167969-1.167969 1.167969-6.125 6.125.707031.707031 6.125-6.125 1.875-1.875-1.875-1.875-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" transform="translate(-.99999-.99999)" d="m4 4v16h16v-16h-16m1 1h14v14h-14v-14" fill="currentColor"/>
|
||||
<path d="m3.5056345 10.977883v-7.5052069h14.878582v15.010415h-14.878582z" class="ColorScheme-Text" fill="currentColor" fill-opacity=".50196078"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m10.04492 3c-1.77683 0-2.521484.706446-2.521484 2.509766v2.441406c0 1.11382-.034195 2.404297-1.015625 2.404297h-.507813v1.289062h.507813c.98148 0 1.015625 1.290477 1.015625 2.404297v2.441406c0 1.80332.744654 2.509766 2.521484 2.509766v-1.183594c-1.18455 0-1.201172-.478327-1.201172-2.210937v-2.01563c0-.83093-.204705-2.067378-1.203125-2.580078.99842-.51271 1.203125-1.751092 1.203125-2.582032v-2.033203c0-1.7326.016592-2.210937 1.201172-2.210937v-1.183594m1.894531 0v1.183594c1.18456 0 1.216797.478337 1.216797 2.210937v2.033203c0 .83094.204705 2.069322 1.203125 2.582032-.99842.5127-1.203125 1.749148-1.203125 2.580078v2.01563c0 1.73261-.032187 2.210937-1.216797 2.210937v1.183594c1.77684 0 2.537109-.706446 2.537109-2.509766v-2.441406c0-1.11382.068644-2.404297 1.033204-2.404297h.490234v-1.289062h-.490234c-.96457 0-1.033204-1.290477-1.033204-2.404297v-2.441406c0-1.80332-.760269-2.509766-2.537109-2.509766" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m14.996094 3l-11.992188 11.992188h-.003906v4.00781h1 2 1.00781v-.003906l11.992188-11.992188-.001953-.001953.001953-.001953-4-4-.001953.001953-.001953-.001953m-1.998047 3.412109l2.589844 2.589844-7.587891 7.587891v-1.589844h-1-1v-1-.589844l6.998047-6.998047m-7.998047 7.998047v1.589844h1 1v1 .589844l-.410156.410156h-1.589844l-1-1v-1.589844l1-1" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.1" width="24" height="24">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 3 3 L 3 17 L 7 17 L 7 19 L 17 19 L 17 10 L 13 6 L 12 6 L 9 3 L 3 3 Z M 4 4 L 8 4 L 8 6 L 7 6 L 7 16 L 4 16 L 4 4 Z M 8 7 L 12 7 L 12 11 L 16 11 L 16 18 L 8 18 L 8 7 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-NegativeText { color: #da4453; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-NegativeText" d="M8 3v2h1V4h4v1h1V3H8M4 6v1h14V6H4m2 2v11h10V8h-1v10H7V8H6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 9 3 C 5.6759952 3 3 5.6759952 3 9 C 3 12.324005 5.6759952 15 9 15 C 10.481205 15 11.830584 14.465318 12.875 13.582031 L 18.292969 19 L 19 18.292969 L 13.582031 12.875 C 14.465318 11.830584 15 10.481205 15 9 C 15 5.6759952 12.324005 3 9 3 z M 9 4 C 11.770005 4 14 6.2299952 14 9 C 14 11.770005 11.770005 14 9 14 C 6.2299952 14 4 11.770005 4 9 C 4 6.2299952 6.2299952 4 9 4 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor" d="M 3 3 L 3 4 L 3 19 L 4 19 L 19 19 L 19 18 L 19 5 L 12. 5 L 10. 3 L 10 3 L 10 3 L 4 3 L 3 3 z M 9 8 L 18 8 L 18 18 L 4 18 L 4 10 L 7 10 L 7 10 L 7 10 L 9 8 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 4 3 C 3.4477153 3 3 3.4477153 3 4 C 3 4.5522847 3.4477153 5 4 5 C 4.5522847 5 5 4.5522847 5 4 C 5 3.4477153 4.5522847 3 4 3 z M 10 3 L 10 4 L 19 4 L 19 3 L 10 3 z M 10 5 L 10 6 L 16 6 L 16 5 L 10 5 z M 4 9 C 3.4477153 9 3 9.4477153 3 10 C 3 10.552285 3.4477153 11 4 11 C 4.5522847 11 5 10.552285 5 10 C 5 9.4477153 4.5522847 9 4 9 z M 10 9 L 10 10 L 19 10 L 19 9 L 10 9 z M 10 11 L 10 12 L 12 12 L 12 11 L 10 11 z M 4 15 C 3.4477153 15 3 15.447715 3 16 C 3 16.552285 3.4477153 17 4 17 C 4.5522847 17 5 16.552285 5 16 C 5 15.447715 4.5522847 15 4 15 z M 10 15 L 10 16 L 19 16 L 19 15 L 10 15 z M 10 17 L 10 18 L 16 18 L 16 17 L 10 17 z M 17 18 L 17 19 L 19 19 L 19 18 L 17 18 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 C 15.432 19 19 15.432 19 11 C 19 6.568 15.432 3 11 3 z M 11 4 C 14.878 4 18 7.122 18 11 C 18 14.878 14.878 18 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 z M 10 6 L 10 8 L 12 8 L 12 6 L 10 6 z M 10 9 L 10 16 L 12 16 L 12 9 L 10 9 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 10 4 L 10 11 L 3 11 L 3 12 L 10 12 L 10 19 L 11 19 L 11 12 L 18 12 L 18 11 L 11 11 L 11 4 L 10 4 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 12.902344 3 L 3.734375 12.167969 C 2.167415 13.734929 2.167415 16.257279 3.734375 17.824219 C 5.301315 19.391169 7.823675 19.391169 9.390625 17.824219 L 17.851562 9.3632812 L 18.558594 8.65625 C 19.733804 7.48104 19.733794 5.5892625 18.558594 4.4140625 C 17.383384 3.2388525 15.491616 3.2388525 14.316406 4.4140625 L 13.609375 5.1210938 L 5.1484375 13.582031 C 4.3649575 14.365511 4.3649675 15.626686 5.1484375 16.410156 C 5.9319175 17.193636 7.1930825 17.193646 7.9765625 16.410156 L 16.4375 7.9492188 L 15.730469 7.2421875 L 7.9765625 14.996094 L 7.2695312 15.703125 C 6.8777912 16.094865 6.2471888 16.094865 5.8554688 15.703125 C 5.4637287 15.311405 5.4637187 14.680813 5.8554688 14.289062 L 14.316406 5.828125 L 15.023438 5.1210938 C 15.806917 4.3376037 17.068092 4.3376037 17.851562 5.1210938 C 18.635042 5.9045538 18.635042 7.1657387 17.851562 7.9492188 L 8.6835938 17.117188 C 7.5083838 18.292397 5.6166162 18.292397 4.4414062 17.117188 C 3.2661862 15.941977 3.2661862 14.05021 4.4414062 12.875 L 13.609375 3.7070312 L 12.902344 3 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11.5 3 C 10.286139 3 9.2809778 3.8559279 9.0507812 5 L 3 5 L 3 6 L 9.0507812 6 C 9.2809778 7.1440721 10.286139 8 11.5 8 C 12.713861 8 13.719022 7.1440721 13.949219 6 L 19 6 L 19 5 L 13.949219 5 C 13.719022 3.8559279 12.713861 3 11.5 3 z M 5.5 14 C 4.1149999 14 3 15.115 3 16.5 C 3 17.885 4.1149999 19 5.5 19 C 6.7138604 19 7.7190223 18.144072 7.9492188 17 L 19 17 L 19 16 L 7.9492188 16 C 7.7190223 14.855928 6.7138604 14 5.5 14 z M 5.5 15 C 6.3310001 15 7 15.669 7 16.5 C 7 17.331 6.3310001 18 5.5 18 C 4.6689999 18 4 17.331 4 16.5 C 4 15.669 4.6689999 15 5.5 15 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 966 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" d="M 19,9 12,16 8,12 9.4896719,10.543135 12,13 17.482382,7.5492102 Z M 4,3 V 5 H 3 V 6 H 4 V 7 H 3 v 1 h 1 v 6 H 3 v 1 h 1 v 1 H 3 v 1 h 1 v 2 h 3 12 v -1 -7 h -1 v 1 1 2 1 2 H 10 9 7 V 16 15 13 12 10 9 7 6 4 h 2 1 8 v 2 h 1 V 3 H 7 Z m 4,3 v 2 h 2 V 6 Z m 3,0 v 1 h 6 V 6 Z" style="fill:currentColor;fill-opacity:1;stroke:none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,14 @@
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="24" version="1.1" height="24" viewBox="0 0 24 24">
|
||||
<defs id="defs3871"/>
|
||||
<metadata id="metadata3874"/>
|
||||
<g transform="translate(1,1)">
|
||||
<g inkscape:label="Capa 1" inkscape:groupmode="layer" id="layer1" transform="matrix(1 0 0 1 -326 -534.3622)">
|
||||
<rect width="8" x="329" y="537.36218" height="1.00002" style="fill:#6c7a89" id="rect4279"/>
|
||||
<rect width="16" x="329" y="541.36218" height="1" style="fill:#6c7a89" id="rect4281"/>
|
||||
<rect width="16" x="329" y="548.36218" height="1" style="fill:#6c7a89" id="rect4283"/>
|
||||
<rect width="8" x="329" y="545.36218" height="1" style="fill:#6c7a89" id="rect4285"/>
|
||||
<rect width="12" x="329" y="552.36218" height="0.999983" style="fill:#6c7a89" id="rect4287"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 919 B |
@@ -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 |
@@ -0,0 +1,108 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// on_ask_user: the agent stopped and wants an answer from the human. Options
|
||||
// (when the engine sends any) become buttons; the field is always there.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string question: ""
|
||||
property string optionText: ""
|
||||
signal answered(string text)
|
||||
|
||||
readonly property var options: {
|
||||
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
|
||||
return raw.length > 1 && raw.length <= 8 ? raw : []
|
||||
}
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(620, parent ? parent.width - 60 : 620)
|
||||
padding: Theme.pad
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
function send(text) {
|
||||
const t = text.trim()
|
||||
if (!t.length)
|
||||
return
|
||||
answered(t)
|
||||
close()
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "The agent asks"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dialog.question
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.optionText.length > 0 && dialog.options.length === 0
|
||||
text: dialog.optionText
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.options.length > 0
|
||||
spacing: Theme.gap
|
||||
Repeater {
|
||||
model: dialog.options
|
||||
Button {
|
||||
text: modelData
|
||||
onClicked: dialog.send(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField {
|
||||
id: answer
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "your answer"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectByMouse: true
|
||||
onAccepted: dialog.send(text)
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Send"
|
||||
highlighted: true
|
||||
enabled: answer.text.trim().length > 0
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: dialog.send(answer.text)
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: answer.forceActiveFocus()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The transcript: cards for the messages, a card per tool call, folded
|
||||
// thinking blocks and one-line notes. Follows the tail of the conversation
|
||||
// until the user scrolls away from it.
|
||||
Item {
|
||||
id: view
|
||||
property var model: null
|
||||
property bool running: false
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
objectName: "transcript"
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.gapL
|
||||
anchors.rightMargin: Theme.gapL
|
||||
model: view.model
|
||||
clip: true
|
||||
spacing: Theme.gap
|
||||
topMargin: Theme.gapL
|
||||
bottomMargin: Theme.gapL
|
||||
cacheBuffer: 2000
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// The delegate picks the card by row kind; the components below read
|
||||
// the ListModel roles directly, which is why they are declared in the
|
||||
// delegate's scope.
|
||||
delegate: Loader {
|
||||
id: rowLoader
|
||||
width: list.width
|
||||
sourceComponent: model.kind === "tool" ? toolComp
|
||||
: model.kind === "think" ? thinkComp
|
||||
: model.kind === "note" ? noteComp : msgComp
|
||||
|
||||
Component {
|
||||
id: msgComp
|
||||
MessageCard {
|
||||
role: model.role
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
/* html comes from the streaming renderer (Main.qml) or,
|
||||
* for a replayed session, from here once. */
|
||||
html: model.html.length > 0 ? model.html
|
||||
: (model.role === "agent" || model.role === "error") &&
|
||||
model.complete && model.text.length > 0
|
||||
? agent.renderMarkdown(model.text) : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: thinkComp
|
||||
ThinkBlock {
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: toolComp
|
||||
ToolCard {
|
||||
objectName: "toolCard"
|
||||
name: model.name
|
||||
state: model.state
|
||||
detail: model.detail
|
||||
output: model.output
|
||||
exitCode: model.exitCode
|
||||
hasResult: model.complete
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: noteComp
|
||||
NoteLine {
|
||||
text: model.text
|
||||
level: model.state === "warn" || model.state === "error" ? model.state : "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMovementEnded: view.stickBottom = atYEnd
|
||||
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
}
|
||||
|
||||
// Follow the tail unless the user asked otherwise.
|
||||
property bool stickBottom: true
|
||||
|
||||
// Empty state: what this window is and what to do with it.
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 520)
|
||||
spacing: Theme.gap
|
||||
visible: view.model ? view.model.count === 0 : true
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: 22
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "The engine runs in this window — no server, no second process."
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "Ask for a change, a command to run, a file to explain.\n" +
|
||||
"Every write, delete and command asks first: Deny / Once / Always.\n" +
|
||||
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Jump back to the live end of the conversation.
|
||||
RoundButton {
|
||||
id: jump
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Theme.padL
|
||||
width: Theme.jumpButton
|
||||
height: Theme.jumpButton
|
||||
visible: !view.stickBottom && list.contentHeight > list.height
|
||||
contentItem: ThemeIcon {
|
||||
name: "go-down"
|
||||
glyph: "↓"
|
||||
size: 20
|
||||
color: Theme.text
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: width / 2
|
||||
color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: {
|
||||
view.stickBottom = true
|
||||
list.positionViewAtEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The input line: multiline, Enter sends, the button changes with the turn
|
||||
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
||||
// keyboard not to autocorrect what is usually code or a path.
|
||||
//
|
||||
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
|
||||
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
|
||||
// engine's pending list, which the next prompt carries.
|
||||
Rectangle {
|
||||
id: root
|
||||
objectName: "composer"
|
||||
property bool running: false
|
||||
property bool compact: false
|
||||
property string note: "" // a failure, or what was just attached
|
||||
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
||||
property alias text: input.text
|
||||
signal submitted(string text)
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length || running)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
}
|
||||
|
||||
/* A file: from the picker, or a URL dropped on the window. */
|
||||
function localPath(url) {
|
||||
let s = String(url)
|
||||
// A phone's picker (SAF) hands over content:// URIs: they are not
|
||||
// paths and must not be decoded -- the app copies what they point at
|
||||
// into its own directory before the engine sees it (see
|
||||
// app::materializeAttachment).
|
||||
if (s.startsWith("content://"))
|
||||
return s
|
||||
if (s.startsWith("file://"))
|
||||
s = s.substring(7)
|
||||
/* Windows keeps its drive letter after the slash. */
|
||||
if (/^\/[A-Za-z]:/.test(s))
|
||||
s = s.substring(1)
|
||||
return decodeURIComponent(s)
|
||||
}
|
||||
|
||||
function attachPath(path) {
|
||||
const r = agent.attachFile(path)
|
||||
note = r.length ? r : ""
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
||||
/* One pad of frame on every side of the column: with the top margin
|
||||
* alone the last row (the clip and Send) hung over the lower border,
|
||||
* which is what put the clip on the frame instead of inside it. */
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
fill: parent
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
// What the next prompt will carry, in the engine's own words.
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.attachments.length > 0
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.attachments
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
objectName: "attachChip" + chip.index
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 24
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: String(chip.modelData)
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: clearAtt
|
||||
objectName: "clearAttachments"
|
||||
text: "clear all"
|
||||
implicitHeight: 24
|
||||
onClicked: {
|
||||
agent.clearPending()
|
||||
root.note = ""
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: clearAtt.text
|
||||
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.note.length > 0
|
||||
text: root.note
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
|
||||
TextArea {
|
||||
id: input
|
||||
objectName: "composerInput"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||
Math.max(56, contentHeight + 12))
|
||||
placeholderText: root.running
|
||||
? "the turn is running — Esc stops it"
|
||||
: (root.compact
|
||||
? "Ask for something…"
|
||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: TextArea.Wrap
|
||||
selectByMouse: true
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||
Qt.ImhNoAutoUppercase
|
||||
background: Rectangle { color: "transparent" }
|
||||
enabled: !root.running
|
||||
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||
if (!(ev.modifiers & Qt.ShiftModifier)) {
|
||||
root.submit()
|
||||
ev.accepted = true
|
||||
}
|
||||
return
|
||||
}
|
||||
/* An image has nothing the editor could paste, so Ctrl+V
|
||||
* becomes an attachment there; text stays with the
|
||||
* editor's own paste. */
|
||||
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
||||
agent.clipboardHasImage()) {
|
||||
root.note = agent.attachClipboard()
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: attach
|
||||
objectName: "attachButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? Theme.touchTarget : 40
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
enabled: !root.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
||||
contentItem: ThemeIcon {
|
||||
name: "mail-attachment"
|
||||
glyph: "📎"
|
||||
size: 20
|
||||
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
}
|
||||
onClicked: picker.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
id: action
|
||||
objectName: "sendButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? "Stop" : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running ? agent.cancel() : root.submit()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: !action.enabled ? Theme.surfaceAlt
|
||||
: root.running ? (action.hovered ? "#e05666" : Theme.err)
|
||||
: (action.hovered ? "#5cc0f0" : Theme.accent)
|
||||
}
|
||||
contentItem: Text {
|
||||
text: action.text
|
||||
color: action.enabled ? Theme.accentInk : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a narrow window the hint would wrap into the editor's space.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !root.compact
|
||||
text: {
|
||||
if (root.running)
|
||||
return "working — Esc cancels the turn"
|
||||
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
|
||||
if (root.attachments.length > 0 && agent.seesImages() === 0)
|
||||
s += " · this model is text-only, images arrive as notes"
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: picker
|
||||
title: "Attach a file"
|
||||
fileMode: FileDialog.OpenFile
|
||||
onAccepted: root.attachPath(root.localPath(selectedFile))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's on_permission hook is blocked on this dialog: nothing runs
|
||||
// until one of the three answers goes back.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string tool: ""
|
||||
property string detail: ""
|
||||
property string preview: ""
|
||||
signal answered(int choice)
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(760, parent ? parent.width - 60 : 760)
|
||||
padding: Theme.pad
|
||||
title: ""
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: Theme.warn
|
||||
}
|
||||
Text {
|
||||
text: "Permission requested"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: dialog.tool
|
||||
color: Theme.warn
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.detail.length > 0
|
||||
text: dialog.detail
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
|
||||
visible: dialog.preview.length > 0
|
||||
color: Theme.bg
|
||||
radius: Theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentWidth: previewText.width
|
||||
contentHeight: previewText.height
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
Text {
|
||||
id: previewText
|
||||
width: Math.max(flick.width, implicitWidth)
|
||||
text: dialog.preview
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
spacing: Theme.gap
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Deny"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
|
||||
onClicked: { dialog.answered(0); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Once"
|
||||
highlighted: true
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: { dialog.answered(1); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Always"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
|
||||
onClicked: { dialog.answered(2); dialog.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The files a message or a tool call carries.
|
||||
//
|
||||
// A picture is a thumbnail from the session's own store (image://cstattach/,
|
||||
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
|
||||
// images when it is tapped. A file the agent delivered (deliver_file) is a card
|
||||
// with the name, the type, the size and two buttons: open and share. The names
|
||||
// are the ones the engine wrote next to the session -- a card never invents a
|
||||
// path.
|
||||
//
|
||||
// One line per file, written by AgentCore.cpp (filesText):
|
||||
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||||
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
|
||||
// <w>x<h>" for pictures; both shapes are read here.
|
||||
Flow {
|
||||
id: strip
|
||||
objectName: "imageStrip"
|
||||
/* A row carries this as text: a ListModel role cannot hold a list. */
|
||||
property string text: ""
|
||||
property var files: decode(text)
|
||||
/* The last refusal, so a tap that cannot work says why (the header's own
|
||||
* status line is the engine's). */
|
||||
property string errorText: ""
|
||||
spacing: 6
|
||||
|
||||
function decode(t) {
|
||||
const out = []
|
||||
const lines = String(t).split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].length)
|
||||
continue
|
||||
const f = lines[i].split("|")
|
||||
if (!f[0].length)
|
||||
continue
|
||||
const old = f.length < 7 // a picture line from an old session
|
||||
const dim = String(old ? f[3] : f[6]).split("x")
|
||||
let w = 0, h = 0
|
||||
if (dim.length === 2) {
|
||||
w = parseInt(dim[0], 10) || 0
|
||||
h = parseInt(dim[1], 10) || 0
|
||||
}
|
||||
const rel = old ? f[0] : f[1]
|
||||
out.push({ kind: old ? "img" : f[0],
|
||||
rel: rel,
|
||||
name: (old ? f[1] : f[2]) || rel,
|
||||
origin: (old ? f[2] : f[3]) || "",
|
||||
mime: old ? "image/*" : (f[4] || ""),
|
||||
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
|
||||
w: w, h: h,
|
||||
url: "image://cstattach/" + rel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function humanSize(n) {
|
||||
if (!n || n <= 0)
|
||||
return ""
|
||||
if (n >= 1024 * 1024)
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||
if (n >= 1024)
|
||||
return Math.round(n / 1024) + " KB"
|
||||
return n + " B"
|
||||
}
|
||||
|
||||
/* The two things a file card does. The engine answers with "" when it
|
||||
* worked (it puts the line in the header itself) or a reason. */
|
||||
function openFile(rel) {
|
||||
errorText = agent.openArtifact(rel)
|
||||
}
|
||||
function shareFile(rel) {
|
||||
errorText = agent.shareArtifact(rel)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: strip.files
|
||||
|
||||
delegate: Rectangle {
|
||||
id: cell
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool isFile: String(modelData.kind) !== "img"
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
/* Images keep their own shape, capped; a file card is a fixed
|
||||
* width so a long name cannot stretch the transcript. */
|
||||
readonly property real ratio: {
|
||||
const w = Number(modelData.w), h = Number(modelData.h)
|
||||
return (w > 0 && h > 0) ? w / h : 1.5
|
||||
}
|
||||
readonly property int cap: 150
|
||||
|
||||
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
|
||||
width: isFile ? 300
|
||||
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
|
||||
height: isFile ? 52
|
||||
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: hovered ? Theme.accentDim : Theme.border
|
||||
clip: true
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
// --- a picture: tap it to look at it ---------------------------
|
||||
Image {
|
||||
id: pic
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
visible: !cell.isFile && status === Image.Ready
|
||||
source: String(cell.modelData.url)
|
||||
sourceSize.width: 512
|
||||
sourceSize.height: 512
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: false
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 8
|
||||
visible: !cell.isFile && pic.status === Image.Error
|
||||
text: String(cell.modelData.name)
|
||||
elide: Text.ElideMiddle
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !cell.isFile
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
}
|
||||
|
||||
// --- a delivered file: name, type, open, share -----------------
|
||||
RowLayout {
|
||||
id: row
|
||||
visible: cell.isFile
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 6
|
||||
spacing: 6
|
||||
|
||||
/* The extension stands in for an icon: no theme lookup, and a
|
||||
* ZIP that says ZIP is more use than a generic sheet. */
|
||||
Rectangle {
|
||||
implicitWidth: 40
|
||||
implicitHeight: 30
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceHi
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
const n = String(cell.modelData.name)
|
||||
const dot = n.lastIndexOf(".")
|
||||
if (dot < 0 || dot === n.length - 1)
|
||||
return "FILE"
|
||||
return n.slice(dot + 1, dot + 5).toUpperCase()
|
||||
}
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: String(cell.modelData.name)
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const m = cell.modelData
|
||||
let s = m.mime ? String(m.mime) : ""
|
||||
const size = strip.humanSize(m.bytes)
|
||||
if (size)
|
||||
s += (s ? " · " : "") + size
|
||||
if (m.origin && m.origin !== "user" && m.origin !== "tool")
|
||||
s += (s ? " · " : "") + m.origin
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileOpen" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Open"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileShare" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.shareFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Share"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: {
|
||||
const m = cell.modelData
|
||||
let s = String(m.name)
|
||||
if (m.w > 0 && m.h > 0)
|
||||
s += " · " + m.w + "×" + m.h
|
||||
if (m.mime && m.kind !== "img")
|
||||
s += " · " + m.mime
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A tap that could not work says why, right where the card is: a phone
|
||||
* with nothing installed for the type is the common case ("no app on the
|
||||
* phone opens report.md"), and a silence there looks like a dead button. */
|
||||
Text {
|
||||
objectName: "fileError"
|
||||
visible: strip.errorText.length > 0
|
||||
width: strip.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: strip.errorText
|
||||
color: Theme.err
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// The window: a sidebar with the engine's settings, the transcript, the
|
||||
// composer, and the two dialogs the engine can block on.
|
||||
//
|
||||
// One shell, three sizes: the sidebar is docked on a desktop (and can be
|
||||
// dragged), slides over the transcript when the window is narrow, and every
|
||||
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
|
||||
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
|
||||
// swapping in org.kde.kirigami later stays a QML change.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ApplicationWindow {
|
||||
id: win
|
||||
width: initialWidth > 0 ? initialWidth : 1180
|
||||
height: initialHeight > 0 ? initialHeight : 800
|
||||
minimumWidth: 360
|
||||
minimumHeight: 420
|
||||
visible: true
|
||||
color: Theme.bg
|
||||
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
|
||||
|
||||
property string statusText: ""
|
||||
property var sessionsView: [] // the store, for the sidebar
|
||||
property bool navOpen: false // the sliding sidebar, compact only
|
||||
property int sidebarWidth: 0 // 0 = follow the window width
|
||||
readonly property bool compact: width < Theme.compactBreakpoint
|
||||
readonly property int sidebarWide: sidebarWidth > 0
|
||||
? sidebarWidth
|
||||
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
|
||||
|
||||
// The settings panel, used docked and in the sliding panel: one definition,
|
||||
// two places.
|
||||
component ShellSidebar: Sidebar {
|
||||
workspace: agent.workspace
|
||||
provider: agent.provider
|
||||
model: agent.model
|
||||
mode: agent.mode
|
||||
approval: agent.approval
|
||||
planMode: agent.planMode
|
||||
running: agent.running
|
||||
status: win.statusText
|
||||
todo: agent.todo
|
||||
sessions: win.sessionsView
|
||||
sessionId: agent.sessionId
|
||||
onModeRequested: function (m) { agent.setMode(m) }
|
||||
onSessionRequested: function (id) { win.switchSession(id) }
|
||||
onSessionNewRequested: win.newSession()
|
||||
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
|
||||
onApprovalRequested: function (a) { agent.setApproval(a) }
|
||||
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
|
||||
}
|
||||
|
||||
// ---- the transcript --------------------------------------------------
|
||||
ListModel { id: transcript }
|
||||
|
||||
function lastIndex() { return transcript.count - 1 }
|
||||
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
|
||||
|
||||
function addRow(kind, role, state) {
|
||||
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
|
||||
name: "", callId: "", state: state, detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
}
|
||||
|
||||
// The engine streams one kind at a time: close whatever was open before.
|
||||
function closeOpenRows(exceptKind) {
|
||||
const last = lastRow()
|
||||
if (last && !last.complete && last.kind !== exceptKind)
|
||||
transcript.setProperty(lastIndex(), "complete", true)
|
||||
}
|
||||
|
||||
function appendDelta(kind, role, text) {
|
||||
closeOpenRows(kind)
|
||||
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
|
||||
addRow(kind, role, "running")
|
||||
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
|
||||
}
|
||||
|
||||
// The open card of this call, -1 when none. The id is what tells two calls
|
||||
// of the same tool apart (pairing by name left the first card running
|
||||
// forever); a call without one -- an old text-protocol session -- falls
|
||||
// back to the name.
|
||||
function findTool(callId, name) {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "tool" || r.complete)
|
||||
continue
|
||||
if (callId.length) {
|
||||
if (r.callId === callId)
|
||||
return i
|
||||
continue /* another call: its card is further back */
|
||||
}
|
||||
if (r.callId.length)
|
||||
continue
|
||||
if (r.name === name)
|
||||
return i
|
||||
return -1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function openTool(callId, name, state) {
|
||||
closeOpenRows("tool")
|
||||
addRow("tool", "tool", state)
|
||||
transcript.setProperty(lastIndex(), "name", name)
|
||||
transcript.setProperty(lastIndex(), "callId", callId)
|
||||
return lastIndex()
|
||||
}
|
||||
|
||||
function submit(text) {
|
||||
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
|
||||
name: "", callId: "", state: "", detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
agent.send(text)
|
||||
}
|
||||
|
||||
// A turn is over: nothing else is coming, so nothing may keep spinning.
|
||||
function closeAllRows() {
|
||||
for (let i = 0; i < transcript.count; i++) {
|
||||
const r = transcript.get(i)
|
||||
if (r.complete)
|
||||
continue
|
||||
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
|
||||
r.state === "running"))
|
||||
transcript.setProperty(i, "state", "unfinished")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown while it streams: the open answer is re-rendered with the
|
||||
// engine's own renderer (the timer below), so the text fills in formatted
|
||||
// instead of turning into markdown when the turn ends.
|
||||
function renderStreaming() {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "message" || r.role !== "agent" || r.complete)
|
||||
continue
|
||||
const html = agent.renderMarkdown(r.text)
|
||||
if (html !== r.html)
|
||||
transcript.setProperty(i, "html", html)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sessions --------------------------------------------------------
|
||||
function reloadSessions() { win.sessionsView = agent.sessions() }
|
||||
|
||||
// A stored session comes back as rows: the engine holds the transcript
|
||||
// (AgentCore::history()), these rows only mirror it.
|
||||
function replay() {
|
||||
transcript.clear()
|
||||
const rows = agent.history()
|
||||
for (let i = 0; i < rows.length; i++)
|
||||
transcript.append(rows[i])
|
||||
chat.stickBottom = true
|
||||
}
|
||||
|
||||
function switchSession(id) {
|
||||
if (agent.switchSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
if (agent.newSession()) {
|
||||
transcript.clear()
|
||||
win.reloadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSession(id) {
|
||||
if (agent.deleteSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
// ---- provider, model, key --------------------------------------------
|
||||
function openSettings() {
|
||||
settings.load(agent.providers(), agent.provider, agent.model)
|
||||
settings.open()
|
||||
}
|
||||
|
||||
function applySettings(provider, model, key, temperature, topP, maxTokens) {
|
||||
/* The parameters are stored first: useProvider() then picks up what
|
||||
* was remembered for whichever provider it ends on. */
|
||||
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
|
||||
const err = perr.length ? perr : agent.useProvider(provider, model, key)
|
||||
settings.note = err.length ? err
|
||||
: "using " + agent.provider + " / " + agent.model
|
||||
settings.choices = agent.modelChoices(provider)
|
||||
settings.syncPick()
|
||||
}
|
||||
|
||||
/* The engine opens the workspace's last session (cst_agent_open()), so the
|
||||
* window is created with a transcript already in it: draw it, or the pane
|
||||
* starts empty while the sidebar points at the very session it belongs to.
|
||||
* A switch draws it the same way (the sessionChanged handler below). */
|
||||
Component.onCompleted: {
|
||||
win.reloadSessions()
|
||||
win.replay()
|
||||
}
|
||||
|
||||
// ---- the shell -------------------------------------------------------
|
||||
/* 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
|
||||
objectName: "appBar"
|
||||
implicitHeight: Theme.headerHeight
|
||||
color: Theme.surface
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.border
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.pad
|
||||
anchors.rightMargin: Theme.gap
|
||||
spacing: Theme.gap
|
||||
|
||||
// The docked sidebar has its own title: only the sliding one needs
|
||||
// a way in.
|
||||
ToolButton {
|
||||
objectName: "navButton"
|
||||
visible: win.compact
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered && !win.compact
|
||||
ToolTip.text: "Settings and plan"
|
||||
contentItem: ThemeIcon {
|
||||
name: "application-menu"
|
||||
glyph: "☰"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: win.navOpen = !win.navOpen
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !win.compact
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
text: agent.workspace.length ? agent.workspace : "(no workspace)"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
objectName: "statusText"
|
||||
visible: win.statusText.length > 0 && !win.compact
|
||||
text: win.statusText
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
Layout.maximumWidth: 260
|
||||
}
|
||||
BusyIndicator {
|
||||
running: agent.running
|
||||
visible: agent.running
|
||||
implicitWidth: 22
|
||||
implicitHeight: 22
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "settingsButton"
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Provider, model and key"
|
||||
contentItem: ThemeIcon {
|
||||
name: "settings-configure"
|
||||
glyph: "⚙"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: win.openSettings()
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "clearButton"
|
||||
enabled: transcript.count > 0 && !agent.running
|
||||
implicitWidth: win.compact ? Theme.touchTarget : 56
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
|
||||
contentItem: Text {
|
||||
text: "Clear"
|
||||
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: {
|
||||
transcript.clear()
|
||||
win.statusText = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
ShellSidebar {
|
||||
id: dockedSidebar
|
||||
objectName: "dockedSidebar"
|
||||
visible: !win.compact
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: win.sidebarWide
|
||||
}
|
||||
|
||||
// Drag the divider: a mouse affordance that costs nothing on touch
|
||||
// (the handle is only there while the sidebar is docked).
|
||||
Item {
|
||||
id: divider
|
||||
visible: !win.compact
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: 5
|
||||
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: dividerMa.pressed ? Theme.accentDim
|
||||
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
|
||||
}
|
||||
MouseArea {
|
||||
id: dividerMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.SplitHCursor
|
||||
property real startX: 0
|
||||
property int startW: 0
|
||||
onPressed: function (mouse) {
|
||||
startX = mouse.x
|
||||
startW = win.sidebarWide
|
||||
}
|
||||
onPositionChanged: function (mouse) {
|
||||
if (pressed)
|
||||
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
|
||||
}
|
||||
onDoubleClicked: win.sidebarWidth = 0
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
ChatView {
|
||||
id: chat
|
||||
objectName: "chatView"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: transcript
|
||||
running: agent.running
|
||||
}
|
||||
|
||||
Composer {
|
||||
id: composer
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
running: agent.running
|
||||
compact: win.compact
|
||||
attachments: agent.pending
|
||||
onSubmitted: function (text) { win.submit(text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compact: the sidebar slides in over the transcript --------------
|
||||
/* Files dropped anywhere on the window: the engine copies them next to
|
||||
* the session and the next prompt carries them. The picker and Ctrl+V in
|
||||
* the composer land in the same list. */
|
||||
DropArea {
|
||||
id: dropZone
|
||||
objectName: "dropZone"
|
||||
anchors.fill: parent
|
||||
onDropped: function (drop) {
|
||||
if (!drop.hasUrls)
|
||||
return
|
||||
let note = ""
|
||||
let n = 0
|
||||
for (let i = 0; i < drop.urls.length; i++) {
|
||||
const r = agent.attachFile(composer.localPath(drop.urls[i]))
|
||||
if (r.length)
|
||||
note = r
|
||||
else
|
||||
n++
|
||||
}
|
||||
composer.note = note.length ? note
|
||||
: n + (n === 1 ? " file attached" : " files attached")
|
||||
drop.accept()
|
||||
}
|
||||
}
|
||||
|
||||
// While something is dragged over the window, say what will happen.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: dropZone.containsDrag
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: Theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "drop to attach"
|
||||
color: Theme.accent
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: scrim
|
||||
objectName: "navScrim"
|
||||
visible: win.navOpen && win.compact
|
||||
anchors.fill: parent
|
||||
color: "#000000"
|
||||
opacity: 0.45
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: win.navOpen = false
|
||||
onWheel: function (wheel) { wheel.accepted = true }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: navPanel
|
||||
objectName: "navPanel"
|
||||
visible: win.navOpen && win.compact
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: Math.min(320, win.width * 0.88)
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
x: win.navOpen && win.compact ? 0 : -width
|
||||
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: panelSidebar.implicitHeight
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ShellSidebar {
|
||||
id: panelSidebar
|
||||
width: parent.width
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConsentDialog {
|
||||
id: consent
|
||||
onAnswered: function (choice) { agent.answerPermission(choice) }
|
||||
}
|
||||
|
||||
SettingsDialog {
|
||||
id: settings
|
||||
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
|
||||
win.applySettings(provider, model, key, temperature, topP, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
AskDialog {
|
||||
id: ask
|
||||
onAnswered: function (text) { agent.answerQuestion(text) }
|
||||
}
|
||||
|
||||
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
|
||||
Shortcut {
|
||||
sequence: "Ctrl+L"
|
||||
enabled: !agent.running
|
||||
onActivated: {
|
||||
transcript.clear()
|
||||
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 ----------------------------------------------------
|
||||
/* The signal handlers only forward: every one of them has a function
|
||||
* here, so the same path can be driven from --uitest (which is how the
|
||||
* two-calls-of-one-tool case stays fixed). */
|
||||
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
|
||||
function engineThink(text) { win.appendDelta("think", "agent", text) }
|
||||
|
||||
function engineToolEvent(phase, id, name, detail) {
|
||||
win.closeOpenRows("tool")
|
||||
let i = win.findTool(id, name)
|
||||
if (phase === "open" || phase === "arg") {
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, "pending")
|
||||
if (detail.length)
|
||||
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
|
||||
return
|
||||
}
|
||||
if (phase === "ready" || phase === "run") {
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
|
||||
transcript.setProperty(i, "detail", detail)
|
||||
return
|
||||
}
|
||||
// done | denied
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase)
|
||||
if (detail.length && transcript.get(i).output.length === 0)
|
||||
transcript.setProperty(i, "output", detail)
|
||||
}
|
||||
|
||||
function engineToolResult(id, name, exitCode, output) {
|
||||
let i = win.findTool(id, name)
|
||||
if (i < 0) /* a result whose call is not in the view: its own card */
|
||||
i = win.openTool(id, name, "done")
|
||||
transcript.setProperty(i, "output", output)
|
||||
transcript.setProperty(i, "exitCode", exitCode)
|
||||
transcript.setProperty(i, "state", "done")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
|
||||
function engineTurnFinished(rc, err) {
|
||||
win.renderStreaming(); /* one last render while the row is still open */
|
||||
win.closeAllRows()
|
||||
if (err.length)
|
||||
transcript.append({ kind: "note", role: "error", text: err, html: "",
|
||||
complete: true, name: "", callId: "", state: "error",
|
||||
detail: "", output: "", exitCode: 0 })
|
||||
win.statusText = ""
|
||||
}
|
||||
|
||||
/* The images the prompt just carried (AgentCore::promptImages): the
|
||||
* composer has appended the user row, so they attach to it. */
|
||||
function enginePromptImages(images) {
|
||||
if (!images.length)
|
||||
return
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind === "message" && r.role === "user") {
|
||||
transcript.setProperty(i, "images", images)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Files a tool produced, on the card of the call that made them. The
|
||||
* engine reports the result first and the files after it, so the card is
|
||||
* normally already complete by the time they arrive -- findTool() only
|
||||
* looks at cards still running, which is why this looks for the call id
|
||||
* itself (and falls back to findTool for a call that has no id). */
|
||||
function engineToolImages(callId, images) {
|
||||
if (!images.length)
|
||||
return
|
||||
let i = -1
|
||||
if (callId.length) {
|
||||
for (let k = transcript.count - 1; k >= 0; k--) {
|
||||
const r = transcript.get(k)
|
||||
if (r.kind === "tool" && r.callId === callId) {
|
||||
i = k
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i = win.findTool("", "")
|
||||
}
|
||||
if (i >= 0)
|
||||
transcript.setProperty(i, "images", images)
|
||||
}
|
||||
|
||||
/* For --uitest: one transcript row, or null. */
|
||||
function rowAt(i) {
|
||||
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
|
||||
}
|
||||
|
||||
/* For --uitest: the same row as JSON (a QObject's return value does not
|
||||
* survive QMetaObject::invokeMethod, a string does). */
|
||||
function rowJson(i) {
|
||||
return JSON.stringify(rowAt(i))
|
||||
}
|
||||
|
||||
/* While a turn runs the open answer is re-rendered a few times a second:
|
||||
* markdown text fills in formatted rather than at the end of the turn. */
|
||||
Timer {
|
||||
interval: 120
|
||||
repeat: true
|
||||
running: agent.running
|
||||
onTriggered: win.renderStreaming()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: agent
|
||||
|
||||
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
||||
function onSessionListChanged() { win.reloadSessions() }
|
||||
|
||||
function onAssistantDelta(text) { win.engineAssistant(text) }
|
||||
function onThinkDelta(text) { win.engineThink(text) }
|
||||
|
||||
function onStatusChanged(text) { win.statusText = text }
|
||||
|
||||
function onToolEvent(phase, id, name, detail) {
|
||||
win.engineToolEvent(phase, id, name, detail)
|
||||
}
|
||||
|
||||
function onToolResult(id, name, exitCode, output) {
|
||||
win.engineToolResult(id, name, exitCode, output)
|
||||
}
|
||||
|
||||
function onToolImages(callId, images) {
|
||||
win.engineToolImages(callId, images)
|
||||
}
|
||||
|
||||
function onPromptImages(images) {
|
||||
win.enginePromptImages(images)
|
||||
}
|
||||
|
||||
function onPermissionRequested(tool, detail, preview) {
|
||||
consent.tool = tool
|
||||
consent.detail = detail
|
||||
consent.preview = preview
|
||||
consent.open()
|
||||
}
|
||||
|
||||
function onQuestionAsked(question, options) {
|
||||
ask.question = question
|
||||
ask.optionText = options
|
||||
ask.open()
|
||||
}
|
||||
|
||||
/* Nobody answered (the app was in the background, or the dialog sat
|
||||
* there too long): the turn went on without it, so put the dialog
|
||||
* away instead of leaving a question that is no longer being asked. */
|
||||
function onPermissionExpired(tool) { consent.close() }
|
||||
function onQuestionExpired() { ask.close() }
|
||||
|
||||
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||
|
||||
function onRunningChanged() {
|
||||
if (!agent.running)
|
||||
win.closeAllRows()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One message in the transcript: the prompt, the answer, or an error. The
|
||||
// answer arrives as plain text while it streams and is rendered from markdown
|
||||
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string role: "agent" // user | agent | error
|
||||
property string text: ""
|
||||
property string html: ""
|
||||
property bool complete: true
|
||||
property string images: ""
|
||||
|
||||
radius: Theme.radius
|
||||
color: role === "user" ? Theme.surfaceAlt : Theme.surface
|
||||
border.width: 1
|
||||
border.color: role === "error" ? Theme.err
|
||||
: role === "user" ? Theme.border : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.role === "user" ? Theme.accent
|
||||
: card.role === "error" ? Theme.err : Theme.ok
|
||||
}
|
||||
Text {
|
||||
text: card.role === "user" ? "You"
|
||||
: card.role === "error" ? "Error" : "CST-agent"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
visible: !card.complete
|
||||
text: "streaming…"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.text.length > 0
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Copy"
|
||||
contentItem: ThemeIcon {
|
||||
name: "edit-copy"
|
||||
glyph: "⧉"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: {
|
||||
plain.text = card.text
|
||||
plain.selectAll()
|
||||
plain.copy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden holder so "copy" always has the plain text, rtf or not.
|
||||
TextEdit {
|
||||
id: plain
|
||||
visible: false
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: streamText
|
||||
visible: card.html.length === 0
|
||||
Layout.fillWidth: true
|
||||
text: card.text
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: card.role === "error" ? Theme.err : Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectionColor: Theme.accentDim
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: card.html.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: card.html
|
||||
textFormat: Text.RichText
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
|
||||
}
|
||||
|
||||
// What the prompt carried: pictures from the store, not their names.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A one-line note from the engine (status text, a refusal, a hint).
|
||||
RowLayout {
|
||||
id: note
|
||||
property string text: ""
|
||||
property string level: "info" // info | warn | error
|
||||
spacing: Theme.gap
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 6
|
||||
width: 5
|
||||
height: 5
|
||||
radius: 3
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: note.text
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One stored session in the sidebar: its name (a rename, else its first
|
||||
// prompt), how much is in it and when it was last written, and a way to drop
|
||||
// it. The open one is marked. A file of its own because the sidebar lists them
|
||||
// through a Repeater -- an inline component would not see modelData there.
|
||||
Rectangle {
|
||||
id: srow
|
||||
property var entry: null
|
||||
readonly property bool open: entry !== null && entry.current === true
|
||||
|
||||
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
|
||||
implicitHeight: rowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: srow.open ? Theme.accentDim
|
||||
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
|
||||
border.width: 1
|
||||
border.color: srow.open ? Theme.accent : "transparent"
|
||||
|
||||
signal activated(string id)
|
||||
signal removeRequested(string id)
|
||||
|
||||
// "3h ago"; the entries carry ms since the epoch.
|
||||
function ago(ms) {
|
||||
if (!ms)
|
||||
return "never"
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60)
|
||||
return "just now"
|
||||
if (s < 3600)
|
||||
return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400)
|
||||
return Math.floor(s / 3600) + "h ago"
|
||||
return Math.floor(s / 86400) + "d ago"
|
||||
}
|
||||
|
||||
HoverHandler { id: rowHover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: srow.activated(srow.entry.id)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
ColumnLayout {
|
||||
id: rowCol
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
|
||||
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
|
||||
: "(no prompt yet)"
|
||||
color: srow.open ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: {
|
||||
if (srow.entry === null)
|
||||
return ""
|
||||
const n = srow.entry.messages
|
||||
const when = srow.ago(srow.entry.mtime)
|
||||
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
|
||||
: when
|
||||
}
|
||||
color: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Delete this session and its transcript"
|
||||
contentItem: ThemeIcon {
|
||||
name: "edit-delete"
|
||||
glyph: "×"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.err
|
||||
: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: srow.removeRequested(srow.entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Provider, model and key: what the engine needs before it can talk to a model
|
||||
// at all. Everything here goes through AgentCore, which moves the engine's own
|
||||
// config (its session stays where it is) and remembers the choice. A key is
|
||||
// verified against the provider before it is saved and is never read back into
|
||||
// this window -- the dialog only ever says where the key it will use comes
|
||||
// from.
|
||||
//
|
||||
// Three things live under "Advanced options", because they are for the odd
|
||||
// provider or the odd model: the provider's own entry (name, base URL, key
|
||||
// variable, models -- the engine keeps those in <config>/models.json, which is
|
||||
// how a provider the presets do not know is added or an existing one is
|
||||
// pointed somewhere else) and the generation parameters (temperature, top-p,
|
||||
// max tokens), which are remembered per provider.
|
||||
Dialog {
|
||||
id: dlg
|
||||
objectName: "settingsDialog"
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
padding: Theme.pad
|
||||
|
||||
property var providers: [] // AgentCore::providers()
|
||||
property var choices: [] // AgentCore::modelChoices(picked)
|
||||
property string picked: ""
|
||||
property string model: ""
|
||||
property string note: "" // what the last action answered
|
||||
property string keyEnv: ""
|
||||
property string keyFrom: ""
|
||||
property bool hasKey: false
|
||||
property bool advanced: false
|
||||
property string modelFilter: "" // narrows a long model list
|
||||
/* The advanced form: the picked provider's entry, editable. */
|
||||
property string advName: ""
|
||||
property string advBase: ""
|
||||
property string advKeyEnv: ""
|
||||
property string advModel: ""
|
||||
property string advModels: ""
|
||||
property string tempText: ""
|
||||
property string topPText: ""
|
||||
property string maxTokText: ""
|
||||
|
||||
signal applyRequested(string provider, string model, string key,
|
||||
real temperature, real topP, int maxTokens)
|
||||
|
||||
width: Math.min(660, parent ? parent.width - 32 : 660)
|
||||
height: Math.min(640, parent ? parent.height - 40 : 640)
|
||||
|
||||
/* The Basic style paints a light panel; this window is dark, so the
|
||||
* dialog brings its own surface like every other one here. */
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
radius: Theme.radius
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
|
||||
// A long provider list (a router lists hundreds) is a wall of chips: the
|
||||
// filter narrows it and the rest is one line ("+N more").
|
||||
readonly property int chipCap: 40
|
||||
readonly property var chips: {
|
||||
const f = modelFilter.trim().toLowerCase()
|
||||
const out = []
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const c = String(choices[i])
|
||||
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
|
||||
out.push(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
|
||||
|
||||
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
|
||||
* which is not the same as sending 0). */
|
||||
function num(text) {
|
||||
const v = parseFloat(String(text).replace(",", "."))
|
||||
return isFinite(v) && v > 0 ? v : 0
|
||||
}
|
||||
|
||||
function pickedEntry() {
|
||||
for (let i = 0; i < providers.length; i++)
|
||||
if (providers[i].name === dlg.picked)
|
||||
return providers[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/* Fill it in: the presets, the provider the engine is on now, and the
|
||||
* model that provider last used. */
|
||||
function load(list, provider, currentModel) {
|
||||
providers = list
|
||||
picked = provider
|
||||
model = currentModel
|
||||
note = ""
|
||||
for (let i = 0; i < list.length; i++)
|
||||
if (list[i].current === true)
|
||||
picked = list[i].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
modelFilter = ""
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* Where the picked provider's key would come from and what it is set to.
|
||||
* From the entries -- a key itself never reaches the QML side. */
|
||||
function syncPick() {
|
||||
hasKey = false
|
||||
keyFrom = ""
|
||||
keyEnv = ""
|
||||
const p = pickedEntry()
|
||||
if (p) {
|
||||
hasKey = p.hasKey === true
|
||||
keyFrom = p.keyFrom
|
||||
keyEnv = p.keyEnv
|
||||
/* The advanced form describes the picked provider: edit it, or
|
||||
* type another name to add one. */
|
||||
advName = p.name
|
||||
advBase = p.base
|
||||
advKeyEnv = p.keyEnv
|
||||
advModel = p.model
|
||||
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
|
||||
}
|
||||
const g = picked.length ? agent.genParams(picked) : null
|
||||
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
|
||||
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
|
||||
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
|
||||
}
|
||||
|
||||
function keyState() {
|
||||
if (hasKey)
|
||||
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
|
||||
: "key from the environment: " + keyFrom
|
||||
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
|
||||
: "no key yet — paste one here"
|
||||
}
|
||||
|
||||
function usageLine() {
|
||||
const u = agent.usage()
|
||||
if (!u.requests)
|
||||
return "no tokens spent in this session yet"
|
||||
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
|
||||
" · " + u.prompt + " in · " + u.out + " out"
|
||||
if (u.priced)
|
||||
s += " · " + u.cost.toFixed(4) + " " + u.currency
|
||||
return s
|
||||
}
|
||||
|
||||
/* Write the form's entry into the engine's user file and list it again. */
|
||||
function saveProvider() {
|
||||
/* The fields are the truth here, not the properties: a hand-typed
|
||||
* edit is what the user wants saved. */
|
||||
const name = advNameField.text.trim()
|
||||
const err = agent.addProvider(name, advBaseField.text.trim(),
|
||||
advKeyEnvField.text.trim(),
|
||||
advModelField.text.trim(),
|
||||
advModelsField.text)
|
||||
if (err.length) {
|
||||
note = err
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
picked = name
|
||||
choices = agent.modelChoices(name)
|
||||
syncPick()
|
||||
note = "saved " + name + " to models.json — it is in the list above now"
|
||||
}
|
||||
|
||||
function removeProvider() {
|
||||
const typed = advNameField.text.trim()
|
||||
const name = typed.length ? typed : picked
|
||||
const rc = agent.removeProvider(name)
|
||||
if (rc < 0) {
|
||||
note = "could not remove " + name
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
note = rc === 0
|
||||
? name + " is not in models.json (a preset of that name stays)"
|
||||
: "removed " + name + " from models.json"
|
||||
if (rc === 1 && providers.length > 0)
|
||||
picked = providers[0].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* The form's fields follow the picked provider (and a user edit breaks
|
||||
* the binding, so the push is imperative). */
|
||||
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
|
||||
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
|
||||
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
|
||||
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
|
||||
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
|
||||
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
|
||||
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
|
||||
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
|
||||
|
||||
// One labelled field of the advanced form.
|
||||
component AdvField: ColumnLayout {
|
||||
property string label: ""
|
||||
property alias text: field.text
|
||||
property alias placeholder: field.placeholderText
|
||||
property int hintWidth: 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: parent.label
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TextField {
|
||||
id: field
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: field.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
objectName: "settingsPanel"
|
||||
spacing: Theme.gap
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider, model, key"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* Everything scrolls, so the buttons stay where they are and a long
|
||||
* model list cannot push them out of the dialog. */
|
||||
Flickable {
|
||||
id: body
|
||||
objectName: "settingsBody"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: bodyCol.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: bodyCol
|
||||
width: parent.width
|
||||
spacing: Theme.gap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: dlg.providers
|
||||
delegate: Rectangle {
|
||||
id: prow
|
||||
required property var modelData
|
||||
readonly property bool selected: dlg.picked === modelData.name
|
||||
|
||||
objectName: "providerRow:" + modelData.name
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: prowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: prow.selected ? Theme.accentDim
|
||||
: prowHover.hovered ? Theme.surfaceAlt
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: prow.selected ? Theme.accent : "transparent"
|
||||
|
||||
HoverHandler { id: prowHover }
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
dlg.picked = prow.modelData.name
|
||||
modelField.text = prow.modelData.model
|
||||
dlg.choices = agent.modelChoices(prow.modelData.name)
|
||||
dlg.modelFilter = ""
|
||||
dlg.syncPick()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: prowCol
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: prow.modelData.name
|
||||
color: prow.selected ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: prow.selected
|
||||
}
|
||||
Text {
|
||||
visible: prow.modelData.current === true
|
||||
text: "in use"
|
||||
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: prow.modelData.custom === true
|
||||
? "custom" : prow.modelData.source
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
text: prow.modelData.base + " · " +
|
||||
(prow.modelData.hasKey === true
|
||||
? (prow.modelData.keyFrom === "saved"
|
||||
? "key saved"
|
||||
: "key from " + prow.modelData.keyFrom)
|
||||
: (prow.modelData.keyEnv.length
|
||||
? "no key (" + prow.modelData.keyEnv + ")"
|
||||
: "no key"))
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Model"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: modelField
|
||||
objectName: "settingsModel"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.model
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "model name"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: modelField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 90
|
||||
elide: Text.ElideRight
|
||||
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
/* A router can list hundreds of models: filter, cap, say how
|
||||
* many are left instead of filling the dialog. */
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.choices.length > 12
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: filterField
|
||||
objectName: "settingsFilter"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.modelFilter
|
||||
implicitHeight: Theme.segmentHeight
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "filter " + dlg.choices.length + " models"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
onTextChanged: dlg.modelFilter = text
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: filterField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 110
|
||||
elide: Text.ElideRight
|
||||
text: dlg.chips.length + " shown"
|
||||
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: dlg.choices.length > 0
|
||||
|
||||
Repeater {
|
||||
model: dlg.chips.slice(0, dlg.chipCap)
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
readonly property string name: String(chip.modelData)
|
||||
|
||||
objectName: "modelChoice:" + chip.name
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 26
|
||||
radius: Theme.radiusSm
|
||||
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: modelField.text === chip.name ? Theme.accent
|
||||
: Theme.border
|
||||
HoverHandler { id: chipHover }
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: chip.name
|
||||
color: modelField.text === chip.name ? Theme.text
|
||||
: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TapHandler { onTapped: modelField.text = chip.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: keyField
|
||||
objectName: "settingsKey"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
echoMode: TextInput.Password
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "API key (leave empty to keep what is there)"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
||||
onAccepted: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dlg.keyState()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* --- advanced ------------------------------------------- */
|
||||
ToolButton {
|
||||
id: advToggle
|
||||
objectName: "settingsAdvanced"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
onClicked: dlg.advanced = !dlg.advanced
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
ThemeIcon {
|
||||
name: dlg.advanced ? "arrow-down" : "arrow-right"
|
||||
glyph: dlg.advanced ? "▾" : "▸"
|
||||
size: 14
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: "Advanced options"
|
||||
color: advToggle.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: "custom provider · model parameters"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.advanced
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
|
||||
"A name the presets know is overridden, a new one is added."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: Theme.gap
|
||||
rowSpacing: 4
|
||||
|
||||
AdvField {
|
||||
id: advNameField
|
||||
objectName: "advName"
|
||||
label: "Name"
|
||||
placeholder: "myllm"
|
||||
Layout.preferredWidth: 180
|
||||
}
|
||||
AdvField {
|
||||
id: advBaseField
|
||||
objectName: "advBase"
|
||||
label: "Base URL"
|
||||
placeholder: "https://api.example.com/v1"
|
||||
}
|
||||
AdvField {
|
||||
id: advKeyEnvField
|
||||
objectName: "advKeyEnv"
|
||||
label: "Key variable"
|
||||
placeholder: "MYLLM_API_KEY"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelField
|
||||
objectName: "advModel"
|
||||
label: "Default model"
|
||||
placeholder: "the model to start with"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelsField
|
||||
objectName: "advModels"
|
||||
label: "Models it offers (comma separated, optional)"
|
||||
placeholder: "m1, m2"
|
||||
Layout.columnSpan: 2
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Button {
|
||||
id: saveProviderBtn
|
||||
objectName: "settingsSaveProvider"
|
||||
text: "Save provider"
|
||||
implicitHeight: Theme.touchTarget
|
||||
enabled: advNameField.text.trim().length > 0 &&
|
||||
advBaseField.text.trim().length > 0
|
||||
onClicked: dlg.saveProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: saveProviderBtn.text
|
||||
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: removeProviderBtn
|
||||
objectName: "settingsRemoveProvider"
|
||||
text: "Remove from models.json"
|
||||
implicitHeight: Theme.touchTarget
|
||||
readonly property var entry: dlg.pickedEntry()
|
||||
enabled: entry !== null && entry.custom === true
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
|
||||
onClicked: dlg.removeProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: removeProviderBtn.hovered && removeProviderBtn.enabled
|
||||
? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: removeProviderBtn.text
|
||||
color: removeProviderBtn.enabled ? Theme.textDim
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: "Model parameters — remembered for " +
|
||||
(dlg.picked.length ? dlg.picked : "this provider") +
|
||||
", sent with every request. Empty = the provider's own default."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
AdvField {
|
||||
id: tempField
|
||||
objectName: "advTemperature"
|
||||
label: "Temperature"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: topPField
|
||||
objectName: "advTopP"
|
||||
label: "Top-p"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: maxTokField
|
||||
objectName: "advMaxTokens"
|
||||
label: "Max tokens"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 150
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
objectName: "settingsNote"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.note.length ? dlg.note : dlg.usageLine()
|
||||
color: dlg.note.length ? Theme.warn : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Button {
|
||||
id: closeBtn
|
||||
objectName: "settingsClose"
|
||||
text: "Close"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.close()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: closeBtn.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: applyBtn
|
||||
objectName: "settingsApply"
|
||||
text: "Use this"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
|
||||
}
|
||||
contentItem: Text {
|
||||
text: applyBtn.text
|
||||
color: Theme.accentInk
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The model parameters belong to the provider that is picked, so they are
|
||||
* applied first: useProvider() then takes over what was remembered for
|
||||
* whichever provider it ends on. */
|
||||
function apply() {
|
||||
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
|
||||
dlg.num(tempField.text), dlg.num(topPField.text),
|
||||
Math.round(dlg.num(maxTokField.text)))
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
keyField.text = ""
|
||||
modelField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// What the engine is set to: where it runs, how it frames itself, when it
|
||||
// asks, whether it is planning. Everything here writes straight into the
|
||||
// engine's config (AgentCore), never into a copy the engine cannot see.
|
||||
//
|
||||
// The panel scrolls (a phone-sized window cannot show it all) and every
|
||||
// control keeps a finger-sized hit area.
|
||||
Rectangle {
|
||||
id: side
|
||||
property string workspace: ""
|
||||
property string provider: ""
|
||||
property string model: ""
|
||||
property string mode: "coding"
|
||||
property int approval: 1 // cst_approval_t
|
||||
property bool planMode: false
|
||||
property bool running: false
|
||||
property string status: ""
|
||||
property string todo: ""
|
||||
property var sessions: [] // what the store holds (AgentCore::sessions)
|
||||
property string sessionId: ""
|
||||
|
||||
signal modeRequested(string mode)
|
||||
signal approvalRequested(int approval)
|
||||
signal planModeRequested(bool on)
|
||||
signal sessionRequested(string id)
|
||||
signal sessionNewRequested()
|
||||
signal sessionDeleteRequested(string id)
|
||||
|
||||
readonly property alias contentColumn: col
|
||||
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: col.implicitHeight + Theme.pad * 2
|
||||
|
||||
// One selectable row of a segmented control.
|
||||
component Seg: Button {
|
||||
id: seg
|
||||
property bool current: false
|
||||
flat: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
padding: 0
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
|
||||
border.width: 1
|
||||
border.color: seg.current ? Theme.accent : Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: seg.text
|
||||
color: seg.current ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: col.implicitHeight + Theme.pad * 2
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
x: Theme.pad
|
||||
y: Theme.pad
|
||||
width: parent.width - Theme.pad * 2
|
||||
spacing: Theme.gapL
|
||||
|
||||
// --- where ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
visible: side.running
|
||||
text: "turn running"
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.workspace.length ? side.workspace : "(no workspace)"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
ToolTip.visible: wsHover.hovered
|
||||
ToolTip.text: side.workspace
|
||||
HoverHandler { id: wsHover }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.provider.length > 0 || side.model.length > 0
|
||||
text: (side.provider.length ? side.provider : "?") +
|
||||
(side.model.length ? " · " + side.model : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessionId.length > 0
|
||||
text: "session " + side.sessionId
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.status.length > 0
|
||||
text: side.status
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// --- which conversation ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: "Sessions"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
// Starting a session while a turn runs would move the
|
||||
// engine out from under it: the whole block is off then.
|
||||
ToolButton {
|
||||
id: newSession
|
||||
objectName: "newSessionButton"
|
||||
implicitWidth: 68
|
||||
implicitHeight: Theme.segmentHeight
|
||||
enabled: !side.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a new session"
|
||||
contentItem: RowLayout {
|
||||
spacing: 4
|
||||
ThemeIcon {
|
||||
name: "list-add"
|
||||
glyph: "+"
|
||||
size: 14
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
text: "new"
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: side.sessionNewRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: side.sessions
|
||||
delegate: SessionRow {
|
||||
Layout.fillWidth: true
|
||||
entry: modelData
|
||||
onActivated: function (id) { side.sessionRequested(id) }
|
||||
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessions.length === 0
|
||||
text: "nothing stored here yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- how it frames itself ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
objectName: "modeCoding"
|
||||
Layout.fillWidth: true
|
||||
text: "coding"
|
||||
current: side.mode !== "general"
|
||||
onClicked: side.modeRequested("coding")
|
||||
}
|
||||
Seg {
|
||||
objectName: "modeGeneral"
|
||||
Layout.fillWidth: true
|
||||
text: "general"
|
||||
current: side.mode === "general"
|
||||
onClicked: side.modeRequested("general")
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.mode === "general" ? "documents, data, everyday files"
|
||||
: "code framed: repos, builds, tests"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- when it asks ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "ask"
|
||||
current: side.approval === 0
|
||||
onClicked: side.approvalRequested(0)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "auto"
|
||||
current: side.approval === 1
|
||||
onClicked: side.approvalRequested(1)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "yolo"
|
||||
current: side.approval === 2
|
||||
onClicked: side.approvalRequested(2)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.approval === 0 ? "every tool asks first"
|
||||
: side.approval === 2 ? "never asks — writes go through"
|
||||
: "sandbox and rules decide, asks on doubt"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
spacing: Theme.gap
|
||||
Switch {
|
||||
id: planSwitch
|
||||
objectName: "planSwitch"
|
||||
checked: side.planMode
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: side.planModeRequested(checked)
|
||||
/* The Basic style's indicator is a dark pill with a dark
|
||||
* knob here, so it is drawn: thumb-sized, accent when
|
||||
* on, clearly off when not. */
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 22
|
||||
x: planSwitch.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 11
|
||||
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: planSwitch.checked ? Theme.accent : Theme.border
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
y: 2
|
||||
x: planSwitch.checked ? parent.width - width - 3 : 3
|
||||
radius: 8
|
||||
color: planSwitch.checked ? Theme.text : Theme.textDim
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: "Plan mode"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.planMode
|
||||
text: "read-only tools and a planning note"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- the plan the engine keeps ---
|
||||
TodoPanel {
|
||||
Layout.fillWidth: true
|
||||
text: side.todo
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "engine in-process · v" + Qt.application.version
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// One place for the look: colors, spacing, fonts. The window, the cards and
|
||||
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
|
||||
// Kirigami.Theme) is a single file.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
|
||||
readonly property color bg: "#15181a"
|
||||
readonly property color surface: "#1e2225"
|
||||
readonly property color surfaceAlt: "#262b2f"
|
||||
readonly property color surfaceHi: "#313840"
|
||||
readonly property color border: "#343b41"
|
||||
readonly property color text: "#eff0f1"
|
||||
readonly property color textDim: "#9aa2a8"
|
||||
/* Faint is still text: small print (hints, secondary lines, a selected
|
||||
* row's detail) has to stay readable on both the background and the
|
||||
* accent fill, so it is a step lighter than it looks in a mockup. */
|
||||
readonly property color textFaint: "#8d959c"
|
||||
readonly property color accent: "#3daee9"
|
||||
readonly property color accentDim: "#1d6f95"
|
||||
/* What is written *on* the accent fill (a selection, the open session).
|
||||
* Named "accentText...", not "onAccent...": QML reads any `onFoo` as a
|
||||
* signal handler and refuses the assignment. */
|
||||
readonly property color accentText: "#ffffff"
|
||||
readonly property color accentTextDim: "#dceefb"
|
||||
/* The dark ink a filled button carries (accent, ok, err are all light). */
|
||||
readonly property color accentInk: "#12161a"
|
||||
readonly property color ok: "#27ae60"
|
||||
readonly property color warn: "#f67400"
|
||||
readonly property color err: "#da4453"
|
||||
/* Green on the accent fill is mud: the "in use" mark needs its own ink. */
|
||||
readonly property color okOnAccent: "#d8f5e3"
|
||||
|
||||
readonly property int radius: 6
|
||||
readonly property int radiusSm: 3
|
||||
readonly property int gap: 8
|
||||
readonly property int gapL: 14
|
||||
readonly property int pad: 12
|
||||
readonly property int padL: 16
|
||||
|
||||
readonly property int bodySize: 14
|
||||
readonly property int smallSize: 12
|
||||
readonly property int monoSize: 13
|
||||
readonly property string mono: "monospace"
|
||||
|
||||
readonly property int sidebarWidth: 268
|
||||
readonly property int contentMax: 1100
|
||||
|
||||
// Layout: below the breakpoint the sidebar slides over the transcript
|
||||
// instead of taking a column, and controls stay thumb-sized above it
|
||||
// (a finger needs ~34-40 px, a mouse is happy with 26).
|
||||
readonly property int compactBreakpoint: 880
|
||||
readonly property int touchTarget: 34
|
||||
readonly property int segmentHeight: 30
|
||||
readonly property int headerHeight: 42
|
||||
readonly property int jumpButton: 40
|
||||
|
||||
// Which Breeze icon stands for a tool: the card shows at a glance what
|
||||
// kind of thing ran. Unknown names still get one (no glyph fallbacks for
|
||||
// tools -- a missing icon just leaves the name alone).
|
||||
function toolIcon(name) {
|
||||
const n = String(name).toLowerCase()
|
||||
if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0)
|
||||
return "document-edit"
|
||||
if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0)
|
||||
return "text-x-generic"
|
||||
if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 ||
|
||||
n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0)
|
||||
return "folder-open"
|
||||
if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0)
|
||||
return "edit-find"
|
||||
if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 ||
|
||||
n.indexOf("command") >= 0 || n.indexOf("run") >= 0)
|
||||
return "code-context"
|
||||
if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0)
|
||||
return "internet-services"
|
||||
if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0)
|
||||
return "format-list-unordered"
|
||||
if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0)
|
||||
return "help-hint"
|
||||
return "code-context"
|
||||
}
|
||||
|
||||
// The tool output / diff line colors, used by ToolCard.
|
||||
function lineColor(line) {
|
||||
if (line.startsWith("@@"))
|
||||
return accent
|
||||
if (line.startsWith("+++") || line.startsWith("---"))
|
||||
return textDim
|
||||
if (line.startsWith("+"))
|
||||
return ok
|
||||
if (line.startsWith("-"))
|
||||
return err
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import QtQuick
|
||||
|
||||
// One icon, from the desktop's icon theme (image://csttheme/, see
|
||||
// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own.
|
||||
//
|
||||
// `glyph` is the last resort: a machine with no icon theme at all gets the
|
||||
// unicode character the app used before, rather than an empty hole. Every
|
||||
// place that shows an icon names its glyph, so nothing can go blank.
|
||||
Item {
|
||||
id: root
|
||||
property string name: ""
|
||||
property string glyph: ""
|
||||
property color color: Theme.textDim
|
||||
property int size: 16
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
|
||||
/* "#rrggbb": a color typed to a string in QML carries alpha and a
|
||||
* leading '#', which the provider's query would choke on. */
|
||||
function hex(c) {
|
||||
function two(v) {
|
||||
const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
|
||||
return s.length < 2 ? "0" + s : s
|
||||
}
|
||||
return "#" + two(c.r) + two(c.g) + two(c.b)
|
||||
}
|
||||
|
||||
Image {
|
||||
id: img
|
||||
anchors.fill: parent
|
||||
source: root.name.length
|
||||
? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color))
|
||||
: ""
|
||||
sourceSize.width: root.size
|
||||
sourceSize.height: root.size
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: img.status === Image.Error && root.glyph.length > 0
|
||||
text: root.glyph
|
||||
color: root.color
|
||||
font.pixelSize: Math.round(root.size * 0.85)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The model's reasoning stream, collapsed by default: it is context, not the
|
||||
// answer.
|
||||
Rectangle {
|
||||
id: block
|
||||
property string text: ""
|
||||
property bool complete: false
|
||||
property bool expanded: false
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
opacity: 0.9
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
leftMargin: Theme.pad
|
||||
rightMargin: Theme.pad
|
||||
topMargin: Theme.pad / 2
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
/* A handler, not a MouseArea: a layout decides the geometry of its
|
||||
* children, and anchoring one of them is undefined behavior. */
|
||||
TapHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onTapped: block.expanded = !block.expanded
|
||||
}
|
||||
ThemeIcon {
|
||||
name: block.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: block.expanded ? "▾" : "▸"
|
||||
size: Theme.smallSize + 2
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: block.complete ? "thinking" : "thinking…"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
visible: !block.expanded
|
||||
text: block.text.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
visible: block.expanded
|
||||
text: block.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.italic: true
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's todo list (on_todo), rendered as a small checklist. The text
|
||||
// is markdown-ish: "- [x] thing" lines.
|
||||
ColumnLayout {
|
||||
id: panel
|
||||
property string text: ""
|
||||
|
||||
function entries() {
|
||||
return text.split("\n")
|
||||
.map(function (l) { return l.trim() })
|
||||
.filter(function (l) { return l.length > 0 })
|
||||
.map(function (l) {
|
||||
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
|
||||
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
|
||||
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
|
||||
})
|
||||
}
|
||||
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "Plan"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: panel.entries()
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
name: modelData.done ? "task-complete" : "box"
|
||||
glyph: modelData.done ? "✓" : "○"
|
||||
size: Theme.smallSize + 2
|
||||
color: modelData.done ? Theme.ok : Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.label
|
||||
color: modelData.done ? Theme.textFaint : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.strikeout: modelData.done
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: panel.entries().length === 0
|
||||
text: "no plan yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A tool call: what ran, whether it is still running, and what came back.
|
||||
// Long output stays folded until asked for, diff lines are colored.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string name: ""
|
||||
property string state: "pending" // pending | ready | running | done | denied
|
||||
// | unfinished (replayed, no result)
|
||||
property string detail: ""
|
||||
property string output: ""
|
||||
property int exitCode: 0
|
||||
property bool hasResult: false
|
||||
property bool expanded: false
|
||||
property string images: ""
|
||||
|
||||
readonly property bool running: state === "pending" || state === "ready" || state === "running"
|
||||
readonly property color stateColor: state === "denied" ? Theme.err
|
||||
: state === "unfinished" ? Theme.textFaint
|
||||
: running ? Theme.warn
|
||||
: hasResult && exitCode !== 0 ? Theme.err
|
||||
: Theme.ok
|
||||
readonly property string stateText: state === "pending" ? "reading"
|
||||
: state === "ready" ? "queued"
|
||||
: state === "running" ? "running"
|
||||
: state === "denied" ? "denied"
|
||||
: state === "unfinished" ? "no result"
|
||||
: 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() {
|
||||
const all = output.split("\n")
|
||||
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: expanded ? Theme.stateColor : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
BusyIndicator {
|
||||
visible: card.running
|
||||
running: card.running
|
||||
implicitWidth: 16
|
||||
implicitHeight: 16
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
visible: !card.running
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.stateColor
|
||||
}
|
||||
/* What kind of tool this was, from the icon theme (Theme.toolIcon
|
||||
* maps the engine's names onto Breeze ones). */
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
name: Theme.toolIcon(card.name)
|
||||
size: 16
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: card.name
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Theme.radiusSm
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: card.stateColor
|
||||
implicitWidth: chip.implicitWidth + 10
|
||||
implicitHeight: chip.implicitHeight + 2
|
||||
Text {
|
||||
id: chip
|
||||
objectName: "toolChip"
|
||||
anchors.centerIn: parent
|
||||
text: card.chipText()
|
||||
color: card.stateColor
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: card.detail.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.output.length > 0
|
||||
contentItem: ThemeIcon {
|
||||
name: card.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: card.expanded ? "▾" : "▸"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: card.expanded = !card.expanded
|
||||
}
|
||||
}
|
||||
|
||||
// What the tool made (view_image, an MCP image): shown as it is, with
|
||||
// the output left folded -- the picture is the result.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
|
||||
// Folded view: the first line of the result, so a done card still says
|
||||
// something without being opened.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !card.expanded && card.output.length > 0
|
||||
text: {
|
||||
const l = card.output.split("\n")
|
||||
return l[0] + (l.length > 1 ? " …" : "")
|
||||
}
|
||||
elide: Text.ElideRight
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: card.expanded && card.output.length > 0
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: card.expanded ? card.lines() : []
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.length ? modelData : " "
|
||||
color: Theme.lineColor(modelData)
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
wrapMode: Text.NoWrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.topMargin: 4
|
||||
visible: card.output.split("\n").length > 500
|
||||
text: "output truncated for the view"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* AgentCore: the Qt wrapper around one cst_agent_t.
|
||||
*
|
||||
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
|
||||
* is turned into a Qt signal, and the two hooks that need an answer
|
||||
* (on_permission, on_ask_user) block the worker until the GUI replies. No
|
||||
* HTTP, no socket: the GUI owns the engine in its own process. */
|
||||
|
||||
#ifndef CST_APP_AGENTCORE_H
|
||||
#define CST_APP_AGENTCORE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
#include <QWaitCondition>
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_tools.h"
|
||||
|
||||
class QThread;
|
||||
|
||||
class AgentCore : public QObject {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
|
||||
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
|
||||
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
|
||||
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
|
||||
Q_PROPERTY(QString model READ model NOTIFY configChanged)
|
||||
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
|
||||
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
|
||||
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
|
||||
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
|
||||
|
||||
public:
|
||||
explicit AgentCore(QObject *parent = nullptr);
|
||||
~AgentCore() override;
|
||||
|
||||
/* Start the engine on a workspace. provider/model/apiKey may be empty to
|
||||
* fall back to the saved credentials and the provider's default model. */
|
||||
bool open(const QString &workspace, const QString &provider,
|
||||
const QString &model, const QString &apiKey, QString *err);
|
||||
void close();
|
||||
|
||||
bool running() const { return m_running; }
|
||||
QString workspace() const { return m_workspace; }
|
||||
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
|
||||
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
|
||||
QString model() const { return QString::fromUtf8(m_cfg.model); }
|
||||
int approval() const { return m_cfg.approval; }
|
||||
bool planMode() const { return m_cfg.plan_mode != 0; }
|
||||
QString todo() const { return m_todo; }
|
||||
QString sessionId() const;
|
||||
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
||||
* message looks the same here as it does in the WebUI. */
|
||||
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
||||
|
||||
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
|
||||
|
||||
/* Stored sessions, newest first; one map each: id, title (a rename, else
|
||||
* empty), firstUser, messages, mtime (ms since the epoch), current. */
|
||||
Q_INVOKABLE QVariantList sessions() const;
|
||||
/* The open session as transcript rows, in exactly the shape the live
|
||||
* signals build (kind, role, text, name, state, detail, output, exitCode,
|
||||
* complete, images), so a reopened session renders like the turn that wrote
|
||||
* it. */
|
||||
Q_INVOKABLE QVariantList history() const;
|
||||
/* None of these run during a turn; all return false when one does. */
|
||||
Q_INVOKABLE bool newSession();
|
||||
Q_INVOKABLE bool switchSession(const QString &id);
|
||||
Q_INVOKABLE bool deleteSession(const QString &id);
|
||||
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
|
||||
|
||||
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
|
||||
|
||||
/* The presets: name, base, model (remembered, else the preset's), hasKey
|
||||
* and keyFrom ("saved", else the environment variable), keyEnv, source and
|
||||
* current, custom (the user's own provider, kept in the engine's user
|
||||
* model file). Never a key: the engine keeps those out of the UI's reach. */
|
||||
Q_INVOKABLE QVariantList providers() const;
|
||||
/* A provider the engine's presets do not know: it goes into the user model
|
||||
* file (<config>/cst-agent/models.json), which is where the registry keeps
|
||||
* them, and the registry is reloaded, so the next providers() call lists
|
||||
* it. `modelsCsv` is a comma-separated model list (may be empty).
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
|
||||
const QString &keyEnv,
|
||||
const QString &defaultModel,
|
||||
const QString &modelsCsv);
|
||||
/* Drop a provider that came from that file (1 = it was there, 0 = it was
|
||||
* not, -1 = with a reason). A preset of the same name stays. */
|
||||
Q_INVOKABLE int removeProvider(const QString &name);
|
||||
/* The generation parameters remembered for a provider (0 = not set, the
|
||||
* field is not sent at all). */
|
||||
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
|
||||
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
|
||||
* apply them to the engine when that provider is the live one.
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
|
||||
double topP, int maxTokens);
|
||||
/* Model candidates for a provider: its own list, what was fetched into the
|
||||
* cache, and the remembered pick. No network. */
|
||||
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
|
||||
/* "1M · vision" for the model picker, "" when nothing is known. */
|
||||
Q_INVOKABLE QString modelInfo(const QString &model) const;
|
||||
/* Switch provider, model and key in one call. The engine's own config moves
|
||||
* (its session does not), the choice is remembered, and an empty return
|
||||
* means it worked; anything else is a reason to show. A key is verified
|
||||
* against the provider's /models when one is given. */
|
||||
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
|
||||
const QString &key);
|
||||
/* Tokens of this session and of the last turn, plus the cost when priced. */
|
||||
Q_INVOKABLE QVariantMap usage() const;
|
||||
|
||||
/* --- attachments (see cst_attach.h) ------------------------------------- */
|
||||
|
||||
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
|
||||
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
|
||||
QStringList pendingAttachments() const;
|
||||
/* Copy a file next to the session and give it to the next prompt. "" when
|
||||
* it worked, else why not. */
|
||||
Q_INVOKABLE QString attachFile(const QString &path);
|
||||
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
|
||||
* reason -- "no image on the clipboard" -- when the editor should have the
|
||||
* keystroke instead. */
|
||||
Q_INVOKABLE QString attachClipboard();
|
||||
/* Whether Ctrl+V should become an attachment at all (an image is on the
|
||||
* clipboard) or stay with the editor (text). */
|
||||
Q_INVOKABLE bool clipboardHasImage() const;
|
||||
Q_INVOKABLE void clearPending();
|
||||
/* The images among the pending attachments, in the engine's order, in the
|
||||
* same text history() and toolImages() carry (see imagesText()): one line
|
||||
* per picture, "<stored name>|<name>|<origin>|<w>x<h>". Empty for a PDF or
|
||||
* a text file, and for a picture whose stored name the engine did not
|
||||
* report. */
|
||||
Q_INVOKABLE QString pendingImages() const;
|
||||
/* Where the current session keeps its stored images: the directory
|
||||
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
|
||||
* open. It follows the session. */
|
||||
Q_INVOKABLE QString attachmentDir() const;
|
||||
/* A file card's two buttons: the stored file goes to the system -- a phone
|
||||
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
|
||||
* it to its own application or to the clipboard. "" when it went through
|
||||
* (the header gets the line from statusChanged), else what to say. The name
|
||||
* is the stored one the card carries; anything else is refused. */
|
||||
Q_INVOKABLE QString openArtifact(const QString &rel);
|
||||
Q_INVOKABLE QString shareArtifact(const QString &rel);
|
||||
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
|
||||
Q_INVOKABLE int seesImages() const;
|
||||
|
||||
/* The engine, for the parts that need it (tool registration). */
|
||||
cst_agent_t *agent() const { return m_agent; }
|
||||
const cst_agent_config_t *config() const { return &m_cfg; }
|
||||
|
||||
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
||||
Q_INVOKABLE void send(const QString &prompt);
|
||||
Q_INVOKABLE void cancel();
|
||||
|
||||
/* Consent: answers map to CST_PERM_*. */
|
||||
Q_INVOKABLE void answerPermission(int choice);
|
||||
Q_INVOKABLE void answerQuestion(const QString &answer);
|
||||
|
||||
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
|
||||
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
|
||||
Q_INVOKABLE void setPlanMode(bool on);
|
||||
|
||||
/* Tools the front end implements itself. The engine copies the definition
|
||||
* (strings included), so a stack value is fine; ud must outlive it.
|
||||
* owner is what unregisterOwner() later matches. */
|
||||
bool registerTool(const cst_tool_def_t *def);
|
||||
void unregisterOwner(const QString &owner);
|
||||
/* Re-probe platform tools after a service appeared or went away. */
|
||||
void reloadToolMeta() { cst_tool_meta_reload(); }
|
||||
|
||||
/* Answer every consent question with "once" (the C smoke test, headless
|
||||
* runs, a --yes flag). */
|
||||
void setAutoApprove(bool on) { m_autoApprove = on; }
|
||||
|
||||
signals:
|
||||
void configChanged();
|
||||
void approvalChanged();
|
||||
void planModeChanged();
|
||||
void sessionChanged(); /* another session is open now */
|
||||
void sessionListChanged(); /* the store gained, lost or renamed one */
|
||||
void pendingChanged(); /* attachments for the next prompt changed */
|
||||
void statusChanged(const QString &text);
|
||||
void assistantDelta(const QString &text);
|
||||
void thinkDelta(const QString &text);
|
||||
/* A call is identified by its id: the same tool can run twice in one turn,
|
||||
* and only the id tells the two cards apart. Text protocols carry ids too
|
||||
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
|
||||
void toolEvent(const QString &phase, const QString &id, const QString &name,
|
||||
const QString &detail);
|
||||
void toolResult(const QString &id, const QString &name, int exitCode,
|
||||
const QString &output);
|
||||
/* Images a tool produced (view_image, an MCP image), right after its
|
||||
* result, as the text ImageStrip.qml decodes. */
|
||||
void toolImages(const QString &callId, const QString &images);
|
||||
/* The images the prompt that was just sent carries, in the same shape,
|
||||
* fired before the turn's first delta. */
|
||||
void promptImages(const QString &images);
|
||||
void todoChanged(const QString &text);
|
||||
void permissionRequested(const QString &tool, const QString &detail,
|
||||
const QString &preview);
|
||||
void questionAsked(const QString &question, const QString &options);
|
||||
/* The worker asked and nobody answered in time (or the app was in the
|
||||
* background, where the dialog cannot be seen): the turn went on with a
|
||||
* refusal, and the window puts its dialog away again. */
|
||||
void permissionExpired(const QString &tool);
|
||||
void questionExpired();
|
||||
void turnFinished(int rc, const QString &err);
|
||||
void runningChanged();
|
||||
void workspaceChanged();
|
||||
void modeChanged();
|
||||
|
||||
private:
|
||||
friend class Turn;
|
||||
void finishTurn(int rc, const QString &err);
|
||||
void setRunning(bool on);
|
||||
/* Take the engine's config over m_cfg again (a switch changed it) and say
|
||||
* so. */
|
||||
void syncConfig();
|
||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||
void afterSessionChange();
|
||||
/* True when the app is not on screen: a phone in the background (Qt
|
||||
* suspends it), or a window that is not there at all. A dialog opened now
|
||||
* would be invisible, so consent is refused instead of waited for. */
|
||||
bool backgrounded() const;
|
||||
|
||||
/* Hook trampolines (C, run on the worker thread). */
|
||||
static void hookStatus(const char *msg, void *ud);
|
||||
static void hookAssistant(const char *text, size_t len, void *ud);
|
||||
static void hookThink(const char *text, size_t len, void *ud);
|
||||
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
||||
const char *detail, void *ud);
|
||||
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||
size_t len, int exit_code, void *ud);
|
||||
static void hookToolImages(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud);
|
||||
static void hookTodo(const char *text, void *ud);
|
||||
static int hookPermission(const char *tool, const char *detail,
|
||||
const char *preview, void *ud);
|
||||
static int hookAskUser(const char *question, const char *options, char *ans,
|
||||
size_t ans_sz, void *ud);
|
||||
/* Post a lambda onto the GUI thread. */
|
||||
template <typename F>
|
||||
void post(F f) {
|
||||
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
cst_agent_t *m_agent = nullptr;
|
||||
cst_agent_config_t m_cfg;
|
||||
cst_agent_hooks_t m_hooks;
|
||||
QString m_workspace;
|
||||
QThread *m_turn = nullptr;
|
||||
bool m_running = false;
|
||||
bool m_closing = false;
|
||||
|
||||
/* consent handshake between the worker and the GUI thread */
|
||||
QMutex m_askMu;
|
||||
QWaitCondition m_askCv;
|
||||
int m_permAnswer = CST_PERM_DENY;
|
||||
bool m_permPending = false;
|
||||
QString m_question;
|
||||
QString m_todo;
|
||||
/* 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:
|
||||
* the engine's own description says what it is, not where it came from. */
|
||||
QStringList m_attachNames;
|
||||
QByteArray m_answer;
|
||||
bool m_questionPending = false;
|
||||
bool m_autoApprove = false;
|
||||
};
|
||||
|
||||
#endif /* CST_APP_AGENTCORE_H */
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "ToolProvider.h"
|
||||
|
||||
#include "AgentCore.h"
|
||||
|
||||
namespace app {
|
||||
|
||||
/* Keep these in one place: every platform tool of the front end uses one of
|
||||
* them, so the engine's availability gate and the policy UI agree. */
|
||||
const char *const kDesktopOnly[] = {"desktop", NULL};
|
||||
const char *const kAndroidOnly[] = {"android", NULL};
|
||||
|
||||
int registerPlatformTools(AgentCore *core) {
|
||||
if (!core)
|
||||
return 0;
|
||||
int n = 0;
|
||||
#if defined(__ANDROID__)
|
||||
n += platformRegisterAndroid(core);
|
||||
#elif defined(__APPLE__)
|
||||
n += platformRegisterMac(core);
|
||||
#elif defined(_WIN32)
|
||||
n += platformRegisterWindows(core);
|
||||
#elif defined(__linux__)
|
||||
n += platformRegisterLinux(core);
|
||||
#endif
|
||||
/* Qt is not a platform API: these exist wherever the window does. */
|
||||
n += registerQtTools(core);
|
||||
/* A service may have appeared or gone since the last probe (a desktop
|
||||
* session starting, the notification daemon coming up). */
|
||||
cst_tool_meta_reload();
|
||||
return n;
|
||||
}
|
||||
|
||||
void unregisterPlatformTools(AgentCore *core) {
|
||||
if (!core)
|
||||
return;
|
||||
core->unregisterOwner(QStringLiteral("app:linux"));
|
||||
core->unregisterOwner(QStringLiteral("app:windows"));
|
||||
core->unregisterOwner(QStringLiteral("app:macos"));
|
||||
core->unregisterOwner(QStringLiteral("app:android"));
|
||||
core->unregisterOwner(QStringLiteral("app:qt"));
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,126 @@
|
||||
/* How the front end hands a tool to the engine.
|
||||
*
|
||||
* A tool implemented in C++ is an ordinary cst_tool_def_t: the engine owns a
|
||||
* copy of the definition, so a static (or stack) value is enough, and
|
||||
* `ud`/`run_ex` keep pointing at the front end's state. The callback runs on
|
||||
* the turn's worker thread -- touch Qt objects only through queued signals.
|
||||
*
|
||||
* See app/src/platform/linux/NotifyTool.cpp for a worked example, and
|
||||
* tests/test_embed.c for the same contract without Qt.
|
||||
*/
|
||||
|
||||
#ifndef CST_APP_TOOLPROVIDER_H
|
||||
#define CST_APP_TOOLPROVIDER_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "cst_tools.h"
|
||||
|
||||
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
||||
* travels through this header, so the class stays incomplete here. */
|
||||
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 {
|
||||
|
||||
/* Fill a definition in one place, so every front-end tool carries the same
|
||||
* metadata (class + policy name + platform list + probe). */
|
||||
inline void setMeta(cst_tool_def_t *def, const char *name, const char *desc,
|
||||
cst_tool_perm_t perm, const char *perm_name,
|
||||
const char *const *platforms,
|
||||
int (*available)(char *, size_t)) {
|
||||
def->name = name;
|
||||
def->desc = desc;
|
||||
def->perm = perm;
|
||||
def->perm_name = perm_name;
|
||||
def->platforms = platforms;
|
||||
def->available = available;
|
||||
}
|
||||
|
||||
/* Tools that only work on the phone. */
|
||||
extern const char *const kAndroidOnly[];
|
||||
/* Tools that only work on a desktop (linux / win / macos). */
|
||||
extern const char *const kDesktopOnly[];
|
||||
|
||||
/* The platform tools this app provides. Called once the engine exists;
|
||||
* returns how many were registered. */
|
||||
int registerPlatformTools(AgentCore *core);
|
||||
/* The tools this front end provides that are the same on every platform
|
||||
* (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt".
|
||||
* Called next to registerPlatformTools(). */
|
||||
int registerQtTools(AgentCore *core);
|
||||
/* 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:*"). */
|
||||
void unregisterPlatformTools(AgentCore *core);
|
||||
|
||||
/* One entry point per platform, implemented by the file under
|
||||
* src/platform/<os>/; ToolProvider.cpp picks one at compile time. */
|
||||
int platformRegisterLinux(AgentCore *core);
|
||||
int platformRegisterMac(AgentCore *core);
|
||||
int platformRegisterWindows(AgentCore *core);
|
||||
int platformRegisterAndroid(AgentCore *core);
|
||||
|
||||
/* Hand a file of the session's store to the system: a phone opens it with
|
||||
* ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever
|
||||
* the desktop uses. `title` is the name a person knows the file by (the
|
||||
* stored copy is named by its id, which means nothing outside), used for the
|
||||
* status line. Both return "" on success and a reason to show the user
|
||||
* otherwise; *status (optional) gets the line to put in the header.
|
||||
* CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without
|
||||
* opening anything for real). */
|
||||
QString openLocalFile(const QString &path, const QString &title, QString *status);
|
||||
/* The same file through the share sheet where there is one (Android); a
|
||||
* desktop has no share sheet, so the path goes to the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status);
|
||||
/* Hold the machine while a turn runs, so it survives the app leaving the
|
||||
* screen: a phone starts a foreground service with a partial wake lock (the
|
||||
* process is one Android will not freeze or kill, and the CPU keeps going
|
||||
* with the screen off) and shows a notification naming the work; a desktop
|
||||
* does nothing -- the window *is* the app there. Returns "" when the hold is
|
||||
* in place (or when the platform has nothing to do), a reason otherwise; the
|
||||
* turn runs either way. `text` is what the phone's notification says. */
|
||||
QString holdForeground(const QString &text);
|
||||
/* Drop it again, at the end of the turn (a phone: the notification goes). */
|
||||
void releaseForeground();
|
||||
|
||||
/* Turn what a file chooser handed over into a path the engine can open. A
|
||||
* desktop picker gives a path and nothing happens; a phone's is a SAF
|
||||
* content:// URI, which the app copies into its own directory first (the
|
||||
* grant travels with the URI, so no permission is involved -- and without
|
||||
* the copy the engine, which opens files by path, would fail with "cannot
|
||||
* read"). Returns the path to read, or "" with *err filled. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err);
|
||||
/* Does this app have "all files access" (Android 11+)? A desktop always
|
||||
* does. Without it the app still attaches whatever the picker handed it; what
|
||||
* it cannot do is read shared storage by path. */
|
||||
bool hasAllFilesAccess();
|
||||
/* Ask for it, through the one screen Android offers (there is no runtime
|
||||
* dialog for it). A desktop, a phone that already has it or one too old to
|
||||
* know about it returns "" and nothing opens. */
|
||||
QString requestAllFilesAccess();
|
||||
/* Take the whole screen. A phone's status bar -- and a tablet's taskbar, which
|
||||
* is a status bar with icons in it -- is drawn *over* the window, and the
|
||||
* window here draws from its own first pixel, so the bars sit on the app's top
|
||||
* row and the controls there cannot be touched. Hiding them is what "full
|
||||
* screen" means on a phone; a desktop has no such bars to ask about and
|
||||
* returns "". Asking is repeatable and cheap: the bars come back with a swipe
|
||||
* from the edge or a switch to another app, so the front end asks again every
|
||||
* time the window comes to the front. Returns "" once they are gone, a reason
|
||||
* otherwise (which the front end logs). */
|
||||
QString requestFullscreen();
|
||||
/* The arguments the Android activity was started with (the
|
||||
* "applicationArguments" intent extra): the way
|
||||
* `cst-agent-app --uitest --workspace DIR` is reachable on a device, since
|
||||
* 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
|
||||
|
||||
#endif /* CST_APP_TOOLPROVIDER_H */
|
||||
@@ -0,0 +1,378 @@
|
||||
/* cst-agent-app: the Qt front end.
|
||||
*
|
||||
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
|
||||
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
|
||||
* cst-agent-app --selftest embedding checks, no window, no network
|
||||
*
|
||||
* The engine runs in this process (see app/README.md); nothing listens on a
|
||||
* port and no second process is started. */
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QQuickWindow>
|
||||
#include <QStandardPaths>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTextStream>
|
||||
#include <QUrl>
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
#include <QJniObject>
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
#endif
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
#include "ui/UiTest.h"
|
||||
#include "ui/BackKey.h"
|
||||
#include "ui/SessionImages.h"
|
||||
#include "ui/ThemeIcons.h"
|
||||
#include "cst_version.h"
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* Where an Android app is allowed to write: its own data directory. There is
|
||||
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
|
||||
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
|
||||
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
|
||||
* of them are pointed here -- once, before the engine exists. */
|
||||
QString androidRoot() {
|
||||
const QString root =
|
||||
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
|
||||
: root;
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
int runSelftest() {
|
||||
QTextStream out(stdout);
|
||||
int bad = 0;
|
||||
QTemporaryDir tmp, cfg;
|
||||
if (!tmp.isValid() || !cfg.isValid()) {
|
||||
out << "selftest: cannot create a scratch dir\n";
|
||||
return 1;
|
||||
}
|
||||
/* The window passes no --api-key, so the key the user saved for the provider
|
||||
* has to come back from <config>/cst-agent/credentials -- the file the
|
||||
* settings dialog writes. Planted in a scratch config dir, never in the
|
||||
* real one. */
|
||||
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
|
||||
QDir().mkpath(cfgdir);
|
||||
QFile creds(cfgdir + QStringLiteral("/credentials"));
|
||||
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
|
||||
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
|
||||
out << "selftest: cannot write a scratch credentials file\n";
|
||||
return 1;
|
||||
}
|
||||
creds.close();
|
||||
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
|
||||
&err)) {
|
||||
out << "selftest: engine did not start: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
const cst_agent_config_t *live =
|
||||
core.agent() ? cst_agent_config(core.agent()) : nullptr;
|
||||
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
|
||||
out << "selftest: the saved key did not reach the engine config\n";
|
||||
bad++;
|
||||
} else {
|
||||
out << "selftest: the saved key is in the engine config\n";
|
||||
}
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
|
||||
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
|
||||
&err)) {
|
||||
out << "selftest: engine did not start: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
out << "selftest: engine started in-process\n";
|
||||
if (core.agent() == nullptr) {
|
||||
out << "selftest: no agent handle\n";
|
||||
return 1;
|
||||
}
|
||||
/* the front end's own tools go through the engine's registry */
|
||||
const int n = app::registerPlatformTools(&core);
|
||||
out << "selftest: platform tools registered: " << n << "\n";
|
||||
const cst_tool_def_t *t = cst_tool_find("notification_send");
|
||||
if (!t) {
|
||||
out << "selftest: notification_send is not in the registry\n";
|
||||
bad++;
|
||||
} else {
|
||||
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
|
||||
out << "selftest: notification_send has no app owner\n";
|
||||
bad++;
|
||||
}
|
||||
char why[160] = "";
|
||||
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
|
||||
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* The engine's own deliver_file: the file a tool hands to the person. */
|
||||
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
|
||||
if (!dv) {
|
||||
out << "selftest: deliver_file is not in the registry\n";
|
||||
bad++;
|
||||
} else {
|
||||
char why[160] = "";
|
||||
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
|
||||
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* the mode comes from the engine, not from the UI */
|
||||
core.setMode(QStringLiteral("general"));
|
||||
if (core.mode() != QLatin1String("general")) {
|
||||
out << "selftest: mode did not stick\n";
|
||||
bad++;
|
||||
}
|
||||
app::unregisterPlatformTools(&core);
|
||||
if (cst_tool_find("notification_send") != nullptr) {
|
||||
out << "selftest: platform tools were not dropped\n";
|
||||
bad++;
|
||||
}
|
||||
core.close();
|
||||
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
|
||||
out.flush();
|
||||
return bad ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* --selftest stays headless: no window, no QML, no display needed. */
|
||||
bool selftest = false;
|
||||
for (int i = 1; i < argc; i++)
|
||||
if (strcmp(argv[i], "--selftest") == 0)
|
||||
selftest = true;
|
||||
if (selftest) {
|
||||
QCoreApplication app(argc, argv);
|
||||
return runSelftest();
|
||||
}
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
/* The window styles its own controls; the platform style only adds noise
|
||||
* here (the KDE Breeze plugin logs a line per registered type and trips
|
||||
* over the composer's TextArea). An explicit choice still wins. */
|
||||
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
|
||||
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
|
||||
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(
|
||||
QStringLiteral("CST-agent desktop front end (engine in-process)"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
|
||||
QStringLiteral("workspace directory"),
|
||||
QStringLiteral("dir"));
|
||||
QCommandLineOption providerOpt(QStringLiteral("provider"),
|
||||
QStringLiteral("provider name"),
|
||||
QStringLiteral("name"));
|
||||
QCommandLineOption modelOpt(QStringLiteral("model"),
|
||||
QStringLiteral("model name"), QStringLiteral("name"));
|
||||
QCommandLineOption planOpt(QStringLiteral("plan"),
|
||||
QStringLiteral("start in plan mode"));
|
||||
QCommandLineOption uitestOpt(
|
||||
QStringLiteral("uitest"),
|
||||
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
|
||||
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
||||
QStringLiteral("initial window size, e.g. 480x900"),
|
||||
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(providerOpt);
|
||||
parser.addOption(modelOpt);
|
||||
parser.addOption(planOpt);
|
||||
parser.addOption(uitestOpt);
|
||||
parser.addOption(sizeOpt);
|
||||
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);
|
||||
if (ws.isEmpty()) {
|
||||
#if defined(Q_OS_ANDROID)
|
||||
ws = androidRoot() + QStringLiteral("/workspace");
|
||||
#else
|
||||
ws = QDir::currentPath();
|
||||
#endif
|
||||
}
|
||||
ws = QFileInfo(ws).absoluteFilePath();
|
||||
|
||||
/* --uitest drives a scratch workspace when none was given: the session
|
||||
* checks create and delete sessions, which is not something to do in
|
||||
* whatever directory the app happened to be started from. */
|
||||
std::unique_ptr<QTemporaryDir> scratch;
|
||||
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
||||
scratch.reset(new QTemporaryDir);
|
||||
if (scratch->isValid()) {
|
||||
ws = scratch->path();
|
||||
/* settings.json and credentials live in <config>/cst-agent: point them
|
||||
* at the scratch dir too, so a test run never rewrites the user's own
|
||||
* provider choice or key. */
|
||||
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
|
||||
}
|
||||
}
|
||||
|
||||
/* 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)
|
||||
/* Before the engine is created: its settings, credentials and session store
|
||||
* all resolve their paths from the environment. */
|
||||
const QString root = androidRoot();
|
||||
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
|
||||
QDir().mkpath(root + QLatin1Char('/') + sub);
|
||||
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
|
||||
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
|
||||
qputenv("HOME", root.toUtf8());
|
||||
#endif
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
|
||||
QString(), &err)) {
|
||||
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
app::registerPlatformTools(&core); /* the app's own tools */
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
|
||||
* the tool is hidden while it is not granted (its probe says so): ask once,
|
||||
* here, so the tool is there for the first turn rather than a prompt that
|
||||
* shows up mid-answer. Denying it only costs the tool. */
|
||||
if (QNativeInterface::QAndroidApplication::context().isValid() &&
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
|
||||
.result() != QtAndroidPrivate::Authorized) {
|
||||
QtAndroidPrivate::requestPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
}
|
||||
/* "All files access" is the other half of storage: it is what lets the
|
||||
* engine read a path in shared storage (a file the user names, not one the
|
||||
* picker hands over -- that one is copied into the app either way, see
|
||||
* AttachFile.java). Android has no runtime dialog for it, only the Settings
|
||||
* screen, so it is opened once (a marker file remembers) and never
|
||||
* insisted on: without it everything still works, one picked file at a
|
||||
* time. The line below is what logcat shows when a device behaves oddly. */
|
||||
const bool allFiles = app::hasAllFilesAccess();
|
||||
qInfo("storage: all files access = %s", allFiles ? "yes" : "no");
|
||||
if (!allFiles) {
|
||||
const QString marker = androidRoot() + QStringLiteral("/.all-files-asked");
|
||||
if (!QFileInfo::exists(marker)) {
|
||||
const QString why = app::requestAllFilesAccess();
|
||||
if (!why.isEmpty())
|
||||
qWarning("all files access: %s", qUtf8Printable(why));
|
||||
QFile asked(marker);
|
||||
if (asked.open(QIODevice::WriteOnly))
|
||||
asked.write("1\n");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (parser.isSet(planOpt))
|
||||
core.setPlanMode(true);
|
||||
|
||||
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
|
||||
if (parser.isSet(uitestOpt))
|
||||
return app::runUiTest(core, parser.value(sizeOpt));
|
||||
|
||||
/* The layout is responsive; --size is how it gets exercised without a
|
||||
* different screen (see app/README.md). */
|
||||
int winW = 0, winH = 0;
|
||||
const QString size = parser.value(sizeOpt);
|
||||
if (!size.isEmpty()) {
|
||||
const int x = size.indexOf(QLatin1Char('x'));
|
||||
bool okW = false, okH = false;
|
||||
if (x > 0) {
|
||||
winW = size.left(x).toInt(&okW);
|
||||
winH = size.mid(x + 1).toInt(&okH);
|
||||
}
|
||||
if (!okW || !okH || winW < 320 || winH < 360) {
|
||||
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
/* image://csttheme/ plus the desktop's icon theme behind it. */
|
||||
app::installThemeIcons(&engine);
|
||||
/* image://cstattach/: the session's own store, so the pictures a tool made
|
||||
* (view_image, an MCP image) and the ones the user attached are shown, not
|
||||
* only named. The directory follows the session. */
|
||||
app::SessionImages *images = app::installSessionImages(&engine);
|
||||
images->setDir(core.attachmentDir());
|
||||
QObject::connect(&core, &AgentCore::sessionChanged, images,
|
||||
[images, &core] { images->setDir(core.attachmentDir()); });
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
|
||||
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
|
||||
QObject::connect(
|
||||
&engine, &QQmlApplicationEngine::objectCreated, &app,
|
||||
[url](QObject *obj, const QUrl &objUrl) {
|
||||
if (!obj && url == objUrl)
|
||||
QCoreApplication::exit(-1);
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
engine.load(url);
|
||||
if (engine.rootObjects().isEmpty())
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
/* Platform tools on Android, implemented by the app: the same contract as
|
||||
* app/src/platform/linux/NotifyTool.cpp, with Android APIs behind it.
|
||||
*
|
||||
* The engine only ever sees the capability: `platforms = kAndroidOnly` (the
|
||||
* engine reports "android" when it was compiled with the NDK, see
|
||||
* src/tools/registry.c: cst_platform_name()) plus an `available` probe decide
|
||||
* whether a tool reaches the prompt and tools[] at all -- a Termux build on
|
||||
* the same device reports "termux" and gets the android tools too, an APK
|
||||
* never gets the desktop ones. `owner = "app:android"` is what
|
||||
* unregisterPlatformTools() drops again.
|
||||
*
|
||||
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never
|
||||
* on the GUI thread. JNI calls are fine from any thread (Qt attaches it),
|
||||
* Qt GUI objects are not -- a tool that needs the widget tree
|
||||
* has to hop back with a blocking queued call (onGuiThread() below).
|
||||
*
|
||||
* The clipboard tools keep the names of the engine's built-ins: those are
|
||||
* gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
|
||||
* name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
|
||||
* model sees one capability either way. */
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QFuture>
|
||||
#include <QGuiApplication>
|
||||
#include <QJniEnvironment>
|
||||
#include <QJniObject>
|
||||
#include <QMetaObject>
|
||||
#include <QSemaphore>
|
||||
#include <QStandardPaths>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QThread>
|
||||
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* --- small helpers ------------------------------------------------------- */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
||||
r->out = strdup(text.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc;
|
||||
}
|
||||
|
||||
/* How long the worker is willing to wait for the GUI thread to pick its work
|
||||
* up. The hop itself is always waited for to the end (the closure writes into
|
||||
* the caller's stack), so what this bounds is the case that makes a turn look
|
||||
* stuck: a GUI thread that is not getting to the event loop at all. */
|
||||
constexpr int kGuiStallMs = 3000;
|
||||
|
||||
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
||||
* run on the GUI thread; the turn's worker thread waits for it.
|
||||
*
|
||||
* 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>
|
||||
bool onGuiThread(Fn &&fn) {
|
||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||
fn();
|
||||
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),
|
||||
Qt::BlockingQueuedConnection);
|
||||
return true;
|
||||
}
|
||||
|
||||
jint sdkInt() {
|
||||
static const jint sdk = QJniObject::getStaticField<jint>(
|
||||
"android/os/Build$VERSION", "SDK_INT");
|
||||
return sdk;
|
||||
}
|
||||
|
||||
QJniObject appContext() {
|
||||
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 --------------------------------------------------- */
|
||||
|
||||
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
||||
* that the permission does not exist and they always work. main.cpp asks for
|
||||
* it once at startup, so the probe is normally green on a device where the
|
||||
* user said yes. */
|
||||
int notifyProbe(char *why, size_t why_sz) {
|
||||
if (sdkInt() < 33)
|
||||
return 0;
|
||||
const QFuture<QtAndroidPrivate::PermissionResult> f =
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
if (f.result() == QtAndroidPrivate::Authorized)
|
||||
return 0;
|
||||
snprintf(why, why_sz,
|
||||
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
|
||||
"first time the app runs)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool postNotification(const QString &title, const QString &message,
|
||||
QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject manager = ctx.callObjectMethod(
|
||||
"getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
QJniObject::fromString(QStringLiteral("notification")).object<jstring>());
|
||||
if (!manager.isValid()) {
|
||||
*err = QStringLiteral("no NotificationManager");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString channelId = QStringLiteral("cst-agent");
|
||||
if (sdkInt() >= 26) {
|
||||
QJniObject channel(
|
||||
"android/app/NotificationChannel",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;I)V",
|
||||
QJniObject::fromString(channelId).object<jstring>(),
|
||||
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
|
||||
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
|
||||
manager.callMethod<void>("createNotificationChannel",
|
||||
"(Landroid/app/NotificationChannel;)V",
|
||||
channel.object<jobject>());
|
||||
}
|
||||
|
||||
/* The small icon. The R classes are not reachable from here, so the id is
|
||||
* looked up by name, and build() throws IllegalArgumentException ("Invalid
|
||||
* notification (no valid small icon)") when there is none at all: every
|
||||
* candidate that can be there is tried -- the framework drawables first,
|
||||
* then the launcher icon the APK itself carries (AGP puts it in mipmap, a
|
||||
* hand made APK in drawable/icon). */
|
||||
QJniObject res = ctx.callObjectMethod("getResources",
|
||||
"()Landroid/content/res/Resources;");
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
||||
"()Ljava/lang/String;");
|
||||
jint icon = 0;
|
||||
if (res.isValid()) {
|
||||
auto idOf = [&res, &pkg](const char *name, const char *type,
|
||||
const char *owner) {
|
||||
return res.callMethod<jint>(
|
||||
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
|
||||
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
|
||||
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
|
||||
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
|
||||
: pkg.object<jstring>());
|
||||
};
|
||||
for (const char *n : {"ic_dialog_info", "ic_popup_reminder",
|
||||
"stat_notify_chat", "sym_def_app_icon"}) {
|
||||
icon = idOf(n, "drawable", "android");
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) {
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "mipmap", nullptr);
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "drawable", nullptr);
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (icon == 0) {
|
||||
*err = QStringLiteral("no small icon: it is in neither the framework "
|
||||
"resources nor the APK's own");
|
||||
return false;
|
||||
}
|
||||
/* Tapping it opens the app again (the launch intent of the package). */
|
||||
QJniObject pm = ctx.callObjectMethod(
|
||||
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
||||
/* The builder, the build and the notify happen in Java (Notifier.java):
|
||||
* a Java exception is invisible to C++ once Qt's JNI wrapper has cleared
|
||||
* it, so on the phone this only ever said "failed, no exception detail"
|
||||
* while the shade stayed empty. Java catches it and hands back the line to
|
||||
* show. */
|
||||
QJniObject launch;
|
||||
if (pm.isValid() && pkg.isValid())
|
||||
launch = pm.callObjectMethod(
|
||||
"getLaunchIntentForPackage",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>());
|
||||
QJniObject posted = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/Notifier", "post",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;"
|
||||
"Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;",
|
||||
ctx.object<jobject>(),
|
||||
QJniObject::fromString(channelId).object<jstring>(),
|
||||
QJniObject::fromString(title).object<jstring>(),
|
||||
QJniObject::fromString(message).object<jstring>(), icon,
|
||||
launch.isValid() ? launch.object<jobject>() : nullptr,
|
||||
static_cast<jint>(1));
|
||||
const QString java =
|
||||
posted.isValid() ? posted.toString()
|
||||
: QStringLiteral("the Notifier helper is not in this APK");
|
||||
if (!java.isEmpty()) {
|
||||
*err = QStringLiteral("cannot post the notification: %1").arg(java);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Runs on the turn's worker thread: no GUI objects here. */
|
||||
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 *message = argOf(call, "message");
|
||||
const char *title = argOf(call, "title");
|
||||
if (!message || !message[0]) {
|
||||
setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = title && title[0] ? QString::fromUtf8(title)
|
||||
: QStringLiteral("CST-agent");
|
||||
const QString m = QString::fromUtf8(message);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = postNotification(t, m, &err); });
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("notification sent"), 0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- clipboard_read / clipboard_write ----------------------------------- */
|
||||
|
||||
int clipboardProbe(char *why, size_t why_sz) {
|
||||
if (QGuiApplication::clipboard())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no QClipboard (no GUI application)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int clipboardReadRun(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 *cap = argOf(call, "max_bytes");
|
||||
const long max =
|
||||
cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L;
|
||||
QString text;
|
||||
bool have = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
text = cb->text();
|
||||
have = true;
|
||||
}
|
||||
});
|
||||
if (!have) {
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
QByteArray body = text.toUtf8();
|
||||
const bool cut = max > 0 && body.size() > max;
|
||||
if (cut)
|
||||
body.truncate(static_cast<int>(max));
|
||||
QByteArray out =
|
||||
QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) +
|
||||
QByteArrayLiteral(" bytes") +
|
||||
(cut ? QByteArrayLiteral(", truncated") : QByteArray()) +
|
||||
QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n");
|
||||
setOut(r, out, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clipboardWriteRun(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 *text = argOf(call, "text");
|
||||
if (!text) {
|
||||
setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = QString::fromUtf8(text);
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
cb->setText(t);
|
||||
ok = true;
|
||||
}
|
||||
});
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("clipboard: replaced with ") +
|
||||
QByteArray::number(t.toUtf8().size()) +
|
||||
QByteArrayLiteral(" bytes"),
|
||||
0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- share --------------------------------------------------------------- */
|
||||
|
||||
/* A path inside the workspace, resolved the way the engine's file tools do:
|
||||
* relative to the workspace and never outside it. */
|
||||
QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) {
|
||||
if (!ctx || !ctx->workspace_root || !rel || !rel[0])
|
||||
return QString();
|
||||
const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root));
|
||||
const QString full = QDir::cleanPath(root + QLatin1Char('/') +
|
||||
QString::fromUtf8(rel));
|
||||
if (full != root && !full.startsWith(root + QLatin1Char('/')))
|
||||
return QString();
|
||||
return full;
|
||||
}
|
||||
|
||||
QString mimeOf(const QString &path) {
|
||||
const QString ext = QFileInfo(path).suffix().toLower();
|
||||
if (ext == QLatin1String("png")) return QStringLiteral("image/png");
|
||||
if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg"))
|
||||
return QStringLiteral("image/jpeg");
|
||||
if (ext == QLatin1String("webp")) return QStringLiteral("image/webp");
|
||||
if (ext == QLatin1String("gif")) return QStringLiteral("image/gif");
|
||||
if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf");
|
||||
if (ext == QLatin1String("json")) return QStringLiteral("application/json");
|
||||
if (ext == QLatin1String("txt") || ext == QLatin1String("md") ||
|
||||
ext == QLatin1String("log"))
|
||||
return QStringLiteral("text/plain");
|
||||
return QStringLiteral("*/*");
|
||||
}
|
||||
|
||||
/* ACTION_SEND through the system share sheet. `text` shares a sentence (the
|
||||
* usual case: the user takes it to another app), `file` shares one file out of
|
||||
* the workspace by way of the FileProvider Qt's manifest template declares
|
||||
* (androidx.core.content.FileProvider, authority <package>.qtprovider). */
|
||||
bool shareText(const QString &text, const QString &subject, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
send.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("text/plain"))
|
||||
.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
if (!subject.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(subject).object<jstring>());
|
||||
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
|
||||
.object<jstring>());
|
||||
/* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000) /* NEW_TASK */);
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The content:// uri of a file inside the app's own data directory. The
|
||||
* FileProvider and its paths xml come from Qt's manifest template
|
||||
* (authority <package>.qtprovider, files-path "/"), which covers the whole
|
||||
* workspace and the session store. */
|
||||
QJniObject contentUriFor(const QString &path, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return QJniObject();
|
||||
}
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;");
|
||||
const QString authority = pkg.toString() + QStringLiteral(".qtprovider");
|
||||
QJniObject file("java/io/File", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
QJniObject uri = QJniObject::callStaticObjectMethod(
|
||||
"androidx/core/content/FileProvider", "getUriForFile",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
|
||||
ctx.object<jobject>(),
|
||||
QJniObject::fromString(authority).object<jstring>(),
|
||||
file.object<jobject>());
|
||||
if (!uri.isValid())
|
||||
*err = QStringLiteral("the FileProvider did not hand out a uri for ") + path;
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool shareFile(const QString &path, const QString &text, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, err);
|
||||
if (!uri.isValid())
|
||||
return false;
|
||||
QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
|
||||
.object<jstring>(),
|
||||
uri.object<jobject>());
|
||||
if (!text.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */));
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000));
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ud;
|
||||
const char *text = argOf(call, "text");
|
||||
const char *file = argOf(call, "file");
|
||||
const char *subject = argOf(call, "subject");
|
||||
if ((!text || !text[0]) && (!file || !file[0])) {
|
||||
setOut(r, QByteArrayLiteral("error: share needs text or file"), 1);
|
||||
return 0;
|
||||
}
|
||||
QString path;
|
||||
if (file && file[0]) {
|
||||
path = resolveInWorkspace(ctx, file);
|
||||
if (path.isEmpty() || !QFileInfo::exists(path)) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) +
|
||||
QByteArrayLiteral(" is not inside the workspace"),
|
||||
1);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const QString body = text ? QString::fromUtf8(text) : QString();
|
||||
const QString subjectLine = subject ? QString::fromUtf8(subject) : QString();
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
ok = path.isEmpty() ? shareText(body, subjectLine, &err)
|
||||
: shareFile(path, body, &err);
|
||||
});
|
||||
if (!ok) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
setOut(r, path.isEmpty()
|
||||
? QByteArrayLiteral("the share sheet is open with the text")
|
||||
: QByteArrayLiteral("the share sheet is open with ") +
|
||||
path.toUtf8(),
|
||||
0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int shareProbe(char *why, size_t why_sz) {
|
||||
if (appContext().isValid())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no Android activity to share from");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||
cst_tool_def_t g_notify;
|
||||
cst_tool_def_t g_clip_read;
|
||||
cst_tool_def_t g_clip_write;
|
||||
cst_tool_def_t g_share;
|
||||
|
||||
} // 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
|
||||
* permission granted to the app that picks it 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);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
err = QStringLiteral("no Android context");
|
||||
return;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, &err);
|
||||
if (!uri.isValid())
|
||||
return;
|
||||
QJniObject action =
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.VIEW"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"setDataAndType",
|
||||
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
|
||||
uri.object<jobject>(),
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ |
|
||||
0x00000001 /* GRANT_READ_URI */));
|
||||
/* Nothing installed for the type: say so rather than fail silently. */
|
||||
QJniObject pm = ctx.callObjectMethod(
|
||||
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
||||
QJniObject found =
|
||||
pm.isValid()
|
||||
? pm.callObjectMethod(
|
||||
"resolveActivity",
|
||||
"(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;",
|
||||
intent.object<jobject>(),
|
||||
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
|
||||
: QJniObject();
|
||||
if (!found.isValid()) {
|
||||
err = QStringLiteral("no app on the phone opens %1").arg(name);
|
||||
return;
|
||||
}
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
intent.object<jobject>());
|
||||
ok = true;
|
||||
});
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* The share sheet, the same way the share tool opens it. */
|
||||
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);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = shareFile(path, QString(), &err); });
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("share sheet: %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* An Intent aimed at one of our own classes, by name: the Java side is
|
||||
* reached through an explicit component, so no other app can call it (and no
|
||||
* intent filter has to exist). Empty action means "start". */
|
||||
QJniObject serviceIntent(const QJniObject &ctx, const QString &action) {
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
||||
"()Ljava/lang/String;");
|
||||
if (!pkg.isValid())
|
||||
return QJniObject();
|
||||
QJniObject comp(
|
||||
"android/content/ComponentName", "(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
pkg.object<jstring>(),
|
||||
QJniObject::fromString(
|
||||
QStringLiteral("xyz.whcself.cstagent.CstService"))
|
||||
.object<jstring>());
|
||||
if (!comp.isValid())
|
||||
return QJniObject();
|
||||
QJniObject intent("android/content/Intent");
|
||||
intent.callObjectMethod(
|
||||
"setComponent",
|
||||
"(Landroid/content/ComponentName;)Landroid/content/Intent;",
|
||||
comp.object<jobject>());
|
||||
if (!action.isEmpty())
|
||||
intent.callObjectMethod("setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(action).object<jstring>());
|
||||
return intent;
|
||||
}
|
||||
|
||||
/* A turn can outlive the screen, and a phone freezes or kills an app it
|
||||
* cannot see: CstService (app/android/src/xyz/whcself/cstagent/CstService.java)
|
||||
* is started as a foreground service for as long as one runs, and it takes the
|
||||
* partial wake lock that keeps the CPU awake with the screen off. The explicit
|
||||
* component means nothing outside the app can start it. */
|
||||
QString holdForeground(const QString &text) {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
||||
return QString(); /* a test run leaves the phone alone */
|
||||
QString err;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
err = QStringLiteral("no Android context");
|
||||
return;
|
||||
}
|
||||
QJniObject intent = serviceIntent(ctx, QString());
|
||||
if (!intent.isValid()) {
|
||||
err = QStringLiteral("no ComponentName for CstService");
|
||||
return;
|
||||
}
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("text")).object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
QJniObject started =
|
||||
sdkInt() >= 26
|
||||
? ctx.callObjectMethod(
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
||||
intent.object<jobject>())
|
||||
: ctx.callObjectMethod(
|
||||
"startService",
|
||||
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
||||
intent.object<jobject>());
|
||||
if (!started.isValid())
|
||||
err = QStringLiteral("the phone refused the foreground service");
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
void releaseForeground() {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
||||
return;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
QJniObject intent = serviceIntent(
|
||||
ctx, QStringLiteral("xyz.whcself.cstagent.STOP"));
|
||||
if (intent.isValid())
|
||||
ctx.callMethod<jboolean>("stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
intent.object<jobject>());
|
||||
});
|
||||
}
|
||||
|
||||
/* What the picker gave us -> a path the engine can open. A SAF URI is copied
|
||||
* into the app's own cache (the URI's grant covers the read; no permission is
|
||||
* involved), a plain path is left alone. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||
if (err)
|
||||
err->clear();
|
||||
if (pathOrUri.isEmpty())
|
||||
return QString();
|
||||
if (!pathOrUri.startsWith(QLatin1String("content://"))) {
|
||||
QString p = pathOrUri;
|
||||
if (p.startsWith(QLatin1String("file://")))
|
||||
p = p.mid(7);
|
||||
return p;
|
||||
}
|
||||
const QString dest = QStandardPaths::writableLocation(
|
||||
QStandardPaths::CacheLocation) +
|
||||
QStringLiteral("/picked");
|
||||
QString result, failure;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
failure = QStringLiteral("no Android context to copy the file with");
|
||||
return;
|
||||
}
|
||||
QJniObject out = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/AttachFile", "materialize",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)"
|
||||
"Ljava/lang/String;",
|
||||
ctx.object<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
|
||||
QJniObject::fromString(dest).object<jstring>());
|
||||
if (!out.isValid()) {
|
||||
failure = QStringLiteral("the phone could not hand that file over");
|
||||
return;
|
||||
}
|
||||
const QString s = out.toString();
|
||||
if (s.startsWith(QLatin1String("error: ")))
|
||||
failure = s.mid(7);
|
||||
else if (!s.isEmpty())
|
||||
result = s;
|
||||
/* "" means it was a path all along; the caller keeps what it had */
|
||||
});
|
||||
if (!failure.isEmpty()) {
|
||||
if (err)
|
||||
*err = failure;
|
||||
return QString();
|
||||
}
|
||||
return result.isEmpty() ? pathOrUri : result;
|
||||
}
|
||||
|
||||
bool hasAllFilesAccess() {
|
||||
bool granted = true;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
granted = QJniObject::callStaticMethod<jboolean>(
|
||||
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
|
||||
"(Landroid/content/Context;)Z", ctx.object<jobject>());
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
QString requestAllFilesAccess() {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_PERM"))
|
||||
return QString(); /* a test run touches no settings */
|
||||
QString err;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
QJniObject out = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess",
|
||||
"(Landroid/content/Context;)Ljava/lang/String;",
|
||||
ctx.object<jobject>());
|
||||
if (out.isValid()) {
|
||||
const QString s = out.toString();
|
||||
if (!s.isEmpty())
|
||||
err = s;
|
||||
}
|
||||
});
|
||||
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) {
|
||||
if (!core)
|
||||
return 0;
|
||||
int n = 0;
|
||||
|
||||
{
|
||||
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 in the phone's shade (what the user sees "
|
||||
"outside the app).",
|
||||
CST_TP_ASK, "notify", kAndroidOnly, notifyProbe);
|
||||
memcpy(g_notify.params, params, sizeof(params));
|
||||
g_notify.run_ex = notifyRun;
|
||||
g_notify.owner = "app:android";
|
||||
n += core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"max_bytes", "integer", 0, "cap on the returned text (default 65536)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_read, 0, sizeof(g_clip_read));
|
||||
setMeta(&g_clip_read, "clipboard_read",
|
||||
"Read the text on the phone's clipboard — what the user copied "
|
||||
"outside the workspace. Returns UTF-8 text.",
|
||||
CST_TP_READ, "read", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_read.params, params, sizeof(params));
|
||||
g_clip_read.run_ex = clipboardReadRun;
|
||||
g_clip_read.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_read) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 1, "text to put on the clipboard"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_write, 0, sizeof(g_clip_write));
|
||||
setMeta(&g_clip_write, "clipboard_write",
|
||||
"Replace the phone's clipboard with text, so the user can paste it "
|
||||
"into another app. Overwrites what they had copied.",
|
||||
CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_write.params, params, sizeof(params));
|
||||
g_clip_write.run_ex = clipboardWriteRun;
|
||||
g_clip_write.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_write) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 0, "text to share"},
|
||||
{"file", "string", 0, "workspace-relative file to share"},
|
||||
{"subject", "string", 0, "subject line of the shared item"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_share, 0, sizeof(g_share));
|
||||
setMeta(&g_share, "share",
|
||||
"Hand text or a workspace file to the system share sheet, so the "
|
||||
"user can send it to another app.",
|
||||
CST_TP_ASK, "share", kAndroidOnly, shareProbe);
|
||||
memcpy(g_share.params, params, sizeof(params));
|
||||
g_share.run_ex = shareRun;
|
||||
g_share.owner = "app:android";
|
||||
n += core->registerTool(&g_share) ? 1 : 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,184 @@
|
||||
/* Platform tools on Linux, 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. */
|
||||
|
||||
#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: the same rule the engine uses for the
|
||||
* clipboard helpers. */
|
||||
bool haveProgram(const char *name) {
|
||||
QByteArray path = qgetenv("PATH");
|
||||
const QList<QByteArray> dirs = path.split(':');
|
||||
for (const QByteArray &d : dirs) {
|
||||
QByteArray dir = d.isEmpty() ? QByteArray(".") : d;
|
||||
QByteArray cand = dir + "/" + name;
|
||||
if (QFileInfo::exists(QString::fromLocal8Bit(cand)) &&
|
||||
QFileInfo(QString::fromLocal8Bit(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("notify-send"))
|
||||
return 0;
|
||||
if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty())
|
||||
return 0;
|
||||
snprintf(why, why_sz,
|
||||
"no notification service (no notify-send, no D-Bus session bus)");
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
QStringList args;
|
||||
if (title && title[0])
|
||||
args << QString::fromUtf8(title);
|
||||
else
|
||||
args << QStringLiteral("CST-agent");
|
||||
args << QString::fromUtf8(message);
|
||||
const int rc = QProcess::execute(QStringLiteral("notify-send"), args);
|
||||
const QByteArray text =
|
||||
(rc == 0 ? QByteArrayLiteral("notification sent")
|
||||
: QByteArrayLiteral("notify-send failed (rc=") +
|
||||
QByteArray::number(rc) + QByteArrayLiteral(")"));
|
||||
r->out = strdup(text.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc == 0 ? 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": xdg-open through QDesktopServices, so the desktop's
|
||||
* own application for the type 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 desktop opens %1").arg(name);
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* No share sheet here: the path is what another program (or another window)
|
||||
* 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();
|
||||
}
|
||||
|
||||
/* A desktop has nothing to hold: the window is the app, and a turn runs as
|
||||
* long as it is open -- the machine does not freeze a process it can see.
|
||||
* Kept here so the front end calls one API on every platform. */
|
||||
QString holdForeground(const QString &text) {
|
||||
(void)text;
|
||||
return QString();
|
||||
}
|
||||
void releaseForeground() {}
|
||||
|
||||
/* A desktop picker hands over a path, so there is nothing to turn into one:
|
||||
* a file:// URL loses its scheme and a content:// URI (a portal that never
|
||||
* gave us a path) is refused with what the user should do instead. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||
QString s = pathOrUri;
|
||||
if (s.startsWith(QLatin1String("file://")))
|
||||
s = s.mid(7);
|
||||
if (s.startsWith(QLatin1String("content://"))) {
|
||||
if (err)
|
||||
*err = QStringLiteral("that picker handed over a content:// URI this "
|
||||
"desktop cannot open; pick the file by path "
|
||||
"instead");
|
||||
return QString();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Every desktop process reads the files its user can. */
|
||||
bool hasAllFilesAccess() { return true; }
|
||||
QString requestAllFilesAccess() { return QString(); }
|
||||
|
||||
/* A desktop window is not drawn under anything of the desktop's own: the bars
|
||||
* a panel draws are the panel's, and nothing here can be asked for them. */
|
||||
QString requestFullscreen() { return QString(); }
|
||||
|
||||
int platformRegisterLinux(AgentCore *core) {
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
{"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 desktop notification (what the user sees outside the app).",
|
||||
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:linux";
|
||||
return core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "ui/SessionImages.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QQmlApplicationEngine>
|
||||
|
||||
#include "cst_attach.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* "img-1a2b3c4d.png?w=160": the stored name, then what the view asked for.
|
||||
* The size comes from the QML side as sourceSize, which Qt passes in as
|
||||
* requestedSize; the query string is accepted too, so a plain URL can ask. */
|
||||
QString relOf(const QString &id) {
|
||||
const int q = id.indexOf(QLatin1Char('?'));
|
||||
return q < 0 ? id : id.left(q);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionImages::SessionImages()
|
||||
: QQuickImageProvider(QQuickImageProvider::Image) {}
|
||||
|
||||
void SessionImages::setDir(const QString &dir) {
|
||||
QMutexLocker lock(&m_mu);
|
||||
m_dir = dir;
|
||||
}
|
||||
|
||||
QImage SessionImages::requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) {
|
||||
const QString rel = relOf(id);
|
||||
if (!cst_attach_rel_ok(rel.toUtf8().constData()))
|
||||
return QImage(); /* QML sees Image.Error and falls back to the name */
|
||||
QString dir;
|
||||
{
|
||||
QMutexLocker lock(&m_mu);
|
||||
dir = m_dir;
|
||||
}
|
||||
if (dir.isEmpty())
|
||||
return QImage();
|
||||
const QString path = dir + QLatin1Char('/') + rel;
|
||||
if (!QFileInfo::exists(path))
|
||||
return QImage();
|
||||
|
||||
QImageReader reader(path);
|
||||
reader.setAutoTransform(true);
|
||||
const QSize full = reader.size();
|
||||
if (!full.isValid()) {
|
||||
/* No size in the header (or not a picture at all): hand back what Qt
|
||||
* makes of it and let the view scale it. */
|
||||
const QImage img = reader.read();
|
||||
if (size)
|
||||
*size = img.size();
|
||||
return img;
|
||||
}
|
||||
/* Shrink to what the view shows, never up: a screenshot is decoded at the
|
||||
* size of its thumbnail instead of at 4K. */
|
||||
const bool smaller = requestedSize.isValid() && requestedSize.width() > 0 &&
|
||||
requestedSize.height() > 0 &&
|
||||
(full.width() > requestedSize.width() ||
|
||||
full.height() > requestedSize.height());
|
||||
if (smaller) {
|
||||
const QSize scaled = full.scaled(requestedSize, Qt::KeepAspectRatio);
|
||||
reader.setScaledSize(scaled);
|
||||
if (size)
|
||||
*size = scaled;
|
||||
} else if (size) {
|
||||
*size = full;
|
||||
}
|
||||
return reader.read();
|
||||
}
|
||||
|
||||
SessionImages *installSessionImages(QQmlApplicationEngine *engine) {
|
||||
SessionImages *images = new SessionImages;
|
||||
if (engine)
|
||||
engine->addImageProvider(QStringLiteral("cstattach"), images);
|
||||
return images;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,45 @@
|
||||
/* The session's stored images as a QML image provider:
|
||||
*
|
||||
* image://cstattach/<stored name> (see cst_attach.h: "img-1a2b3c4d.png")
|
||||
*
|
||||
* The files live in the current session's attachment directory, which is what
|
||||
* AgentCore::attachmentDir() names. Only that directory is served, and only
|
||||
* names that pass the engine's own cst_attach_rel_ok() -- a transcript can
|
||||
* carry hand-edited JSON, and nothing in it may reach outside the store.
|
||||
*
|
||||
* requestImage() runs on Quick's render thread while setDir() comes from the
|
||||
* window's thread, hence the mutex. */
|
||||
#ifndef CST_APP_SESSION_IMAGES_H
|
||||
#define CST_APP_SESSION_IMAGES_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QMutex>
|
||||
#include <QQuickImageProvider>
|
||||
#include <QString>
|
||||
|
||||
class QQmlApplicationEngine;
|
||||
|
||||
namespace app {
|
||||
|
||||
class SessionImages : public QQuickImageProvider {
|
||||
public:
|
||||
SessionImages();
|
||||
|
||||
/* The directory holding the current session's images; "" disables serving. */
|
||||
void setDir(const QString &dir);
|
||||
|
||||
QImage requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) override;
|
||||
|
||||
private:
|
||||
mutable QMutex m_mu;
|
||||
QString m_dir;
|
||||
};
|
||||
|
||||
/* Registers image://cstattach/ on the engine and hands the provider back, so
|
||||
* the caller can keep its directory in step with the agent's session. */
|
||||
SessionImages *installSessionImages(QQmlApplicationEngine *engine);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif // CST_APP_SESSION_IMAGES_H
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "ui/ThemeIcons.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QPainter>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QScreen>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* What a usable theme has to have; every window in the app needs it. */
|
||||
const char kProbe[] = "mail-attachment";
|
||||
|
||||
/* The desktop's own theme first (whatever the platform theme set), then
|
||||
* Breeze: this window is dark, so the dark variant, and Breeze proper as
|
||||
* Qt's fallback for the odd name that only lives there. Before any of that
|
||||
* the search paths have to be filled in -- Qt only builds them from XDG once
|
||||
* a theme name was set, and a shell that was started outside the desktop
|
||||
* session (nix-shell) has no XDG_DATA_DIRS pointing at the icon packages,
|
||||
* hence the system profile. */
|
||||
void adoptIconTheme() {
|
||||
const QStringList known = QStandardPaths::standardLocations(
|
||||
QStandardPaths::GenericDataLocation);
|
||||
QStringList paths;
|
||||
for (const QString &d : known)
|
||||
paths << d + QStringLiteral("/icons");
|
||||
paths << QDir::homePath() + QStringLiteral("/.icons");
|
||||
const QString system = QStringLiteral("/run/current-system/sw/share/icons");
|
||||
if (!paths.contains(system) && QDir(system).exists())
|
||||
paths << system;
|
||||
paths << QStringLiteral(":/icons");
|
||||
QIcon::setThemeSearchPaths(paths);
|
||||
|
||||
QStringList candidates;
|
||||
if (!QIcon::themeName().isEmpty())
|
||||
candidates << QIcon::themeName();
|
||||
candidates << QStringLiteral("breeze-dark") << QStringLiteral("breeze")
|
||||
<< QStringLiteral("hicolor");
|
||||
for (const QString &name : candidates) {
|
||||
QIcon::setThemeName(name);
|
||||
if (QIcon::hasThemeIcon(QLatin1String(kProbe)))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* The bundled set: app/icons/*.svg, compiled in as :/cst-icons/<name>.svg by
|
||||
* app/CMakeLists.txt. A machine without an icon theme -- Android, where the
|
||||
* Qt theme lookup finds nothing and QML was left showing its glyph fallback,
|
||||
* which the phone fonts cannot draw -- gets the same icons from here. */
|
||||
QIcon bundledIcon(const QString &name) {
|
||||
return QIcon(QStringLiteral(":/cst-icons/") + name + QStringLiteral(".svg"));
|
||||
}
|
||||
|
||||
QIcon iconForName(const QString &name) {
|
||||
const QIcon themed = QIcon::fromTheme(name);
|
||||
if (!themed.isNull())
|
||||
return themed;
|
||||
return bundledIcon(name);
|
||||
}
|
||||
|
||||
/* "?color=%23eff0f1": the tint, and nothing else so far. */
|
||||
QColor colorOption(const QString &id, QString *name) {
|
||||
QColor color;
|
||||
*name = id;
|
||||
const int q = id.indexOf(QLatin1Char('?'));
|
||||
if (q < 0)
|
||||
return color;
|
||||
*name = id.left(q);
|
||||
const QStringList options = id.mid(q + 1).split(QLatin1Char('&'));
|
||||
for (const QString &opt : options) {
|
||||
if (!opt.startsWith(QLatin1String("color=")))
|
||||
continue;
|
||||
const QColor c(QUrl::fromPercentEncoding(opt.mid(6).toUtf8()));
|
||||
if (c.isValid())
|
||||
color = c;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
qreal targetRatio() {
|
||||
if (const QScreen *s = QGuiApplication::primaryScreen())
|
||||
return qMax<qreal>(1.0, s->devicePixelRatio());
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ThemeIconProvider::ThemeIconProvider()
|
||||
: QQuickImageProvider(QQuickImageProvider::Image) {}
|
||||
|
||||
QImage ThemeIconProvider::requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) {
|
||||
QString name;
|
||||
const QColor color = colorOption(id, &name);
|
||||
if (name.isEmpty())
|
||||
return QImage();
|
||||
|
||||
const QIcon icon = iconForName(name);
|
||||
if (icon.isNull())
|
||||
return QImage(); /* QML shows its fallback glyph */
|
||||
|
||||
const int px = requestedSize.isValid() && requestedSize.width() > 0
|
||||
? requestedSize.width()
|
||||
: 22;
|
||||
const qreal ratio = targetRatio();
|
||||
QPixmap pm = icon.pixmap(QSize(px, px), ratio, QIcon::Normal, QIcon::Off);
|
||||
if (pm.isNull())
|
||||
return QImage();
|
||||
if (color.isValid()) {
|
||||
QPainter p(&pm);
|
||||
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||
p.fillRect(pm.rect(), color);
|
||||
}
|
||||
QImage img = pm.toImage();
|
||||
img.setDevicePixelRatio(ratio);
|
||||
if (size)
|
||||
*size = QSize(px, px);
|
||||
return img;
|
||||
}
|
||||
|
||||
void installThemeIcons(QQmlApplicationEngine *engine) {
|
||||
adoptIconTheme();
|
||||
if (engine)
|
||||
engine->addImageProvider(QStringLiteral("csttheme"), new ThemeIconProvider);
|
||||
}
|
||||
|
||||
QString iconThemeName() { return QIcon::themeName(); }
|
||||
|
||||
bool themeIconAvailable(const QString &name) { return !iconForName(name).isNull(); }
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Icons from the desktop's icon theme (Breeze on KDE).
|
||||
*
|
||||
* The window did its icons with unicode glyphs (a clip emoji for attach, a
|
||||
* gear, a hamburger): they render in whatever font happens to cover them --
|
||||
* colour emoji in a grey toolbar -- and on a machine without that glyph they
|
||||
* are a box. Every other desktop app asks the icon theme instead, and Qt
|
||||
* Quick has no image provider for it without Kirigami, so this is one:
|
||||
*
|
||||
* image://csttheme/<icon-name>?color=%23rrggbb
|
||||
*
|
||||
* The icon is looked up with QIcon::fromTheme() in the desktop's theme
|
||||
* (breeze-dark on a KDE desktop, Breeze as fallback), tinted to the asked-for
|
||||
* color and rendered at the requested size. An unknown name yields a null
|
||||
* image, which QML sees as Image.Error -- that is the hook for a glyph
|
||||
* fallback, used on a machine with no icon theme at all. */
|
||||
|
||||
#ifndef CST_APP_THEMEICONS_H
|
||||
#define CST_APP_THEMEICONS_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QQuickImageProvider>
|
||||
#include <QString>
|
||||
|
||||
class QQmlApplicationEngine;
|
||||
|
||||
namespace app {
|
||||
|
||||
class ThemeIconProvider : public QQuickImageProvider {
|
||||
public:
|
||||
ThemeIconProvider();
|
||||
QImage requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) override;
|
||||
};
|
||||
|
||||
/* Points Qt at the desktop's icon theme and registers image://csttheme/.
|
||||
* Call it on every engine that loads the window (main.cpp and --uitest). */
|
||||
void installThemeIcons(QQmlApplicationEngine *engine);
|
||||
|
||||
/* The theme Qt was left on, for --selftest and the README. */
|
||||
QString iconThemeName();
|
||||
|
||||
/* Whether the theme has this icon (what the fallback glyphs are for). */
|
||||
bool themeIconAvailable(const QString &name);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_THEMEICONS_H */
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The --uitest entry point: see UiTest.cpp for what it drives. */
|
||||
|
||||
#ifndef CST_APP_UITEST_H
|
||||
#define CST_APP_UITEST_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
|
||||
namespace app {
|
||||
|
||||
/* Loads the window, drives its controls with synthesized mouse/touch input
|
||||
* (and a resize to the compact layout), prints one line per check and returns
|
||||
* how many failed as the process exit code. `size` is the usual WxH string,
|
||||
* empty for the default. */
|
||||
int runUiTest(AgentCore &core, const QString &size);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_UITEST_H */
|
||||
@@ -0,0 +1,158 @@
|
||||
/* The app's C smoke test: does the engine embed the way app/ needs it to?
|
||||
*
|
||||
* It links libcstcore.a (or the cstcore target) and runs without Qt, so the
|
||||
* C side of the GUI stays verifiable on any machine -- including CI and the
|
||||
* Android cross-build. `cst-agent-app --selftest` runs the same checks inside
|
||||
* the real process. */
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_tools.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h>
|
||||
#include <direct.h>
|
||||
#define getpid _getpid
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
static int failures;
|
||||
|
||||
#define CHECK(cond, what) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* A tool the app would implement (the Linux build uses notify-send; here it
|
||||
* only has to prove the plumbing). */
|
||||
static int ctx_runs;
|
||||
static int app_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)call;
|
||||
(void)ud;
|
||||
ctx_runs++;
|
||||
r->out = strdup("app tool ran");
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int probe_missing(char *why, size_t why_sz) {
|
||||
snprintf(why, why_sz, "the desktop service is not running");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* The scratch workspace goes where this host can actually write: /tmp on a
|
||||
* desktop, the app's own directory on Android (where /tmp is off limits,
|
||||
* so TMPDIR is pointed at it -- see app/src/main.cpp). */
|
||||
char tmp[1024] = "";
|
||||
if (os_temp_dir(tmp, sizeof(tmp)) != 0 || !tmp[0])
|
||||
snprintf(tmp, sizeof(tmp), "%s", "/tmp");
|
||||
char ws[1200];
|
||||
snprintf(ws, sizeof(ws), "%s/cst-app-selftest-%d", tmp, (int)getpid());
|
||||
#ifdef _WIN32
|
||||
_mkdir(ws); /* the engine wants its workspace to exist */
|
||||
#else
|
||||
mkdir(ws, 0700);
|
||||
#endif
|
||||
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", ws);
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||
snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1");
|
||||
snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key");
|
||||
snprintf(cfg.mode, sizeof(cfg.mode), "general");
|
||||
cfg.max_steps = 1;
|
||||
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
CHECK(a != NULL, "the app can start an engine in its own process");
|
||||
if (!a) {
|
||||
fprintf(stderr, "cst-app-selftest: cannot start the engine\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 1. a tool that exists here (no probe) is offered to the model */
|
||||
cst_tool_def_t plain;
|
||||
memset(&plain, 0, sizeof(plain));
|
||||
plain.name = "app_ping";
|
||||
plain.desc = "A tool the app provides.";
|
||||
plain.perm = CST_TP_READ;
|
||||
plain.run_ex = app_tool_run;
|
||||
plain.owner = "app:selftest";
|
||||
CHECK(cst_tool_register(&plain) == 0, "register an app tool");
|
||||
const cst_tool_def_t *t = cst_tool_find("app_ping");
|
||||
CHECK(t != NULL, "it is findable");
|
||||
CHECK(t && cst_tool_active(cst_agent_config(a), t), "and it is offered");
|
||||
ctl_tool_call_t call;
|
||||
memset(&call, 0, sizeof(call));
|
||||
snprintf(call.name, sizeof(call.name), "app_ping");
|
||||
cst_tool_result_t res;
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = ws;
|
||||
ctx.agent = a;
|
||||
cst_tool_run(&ctx, &call, &res);
|
||||
CHECK(ctx_runs == 1, "the app callback ran");
|
||||
CHECK(res.out && strcmp(res.out, "app tool ran") == 0, "its result came back");
|
||||
cst_tool_result_free(&res);
|
||||
|
||||
/* 2. a tool whose platform service is missing is never offered ... */
|
||||
cst_tool_def_t missing;
|
||||
memset(&missing, 0, sizeof(missing));
|
||||
missing.name = "app_no_service";
|
||||
missing.desc = "Needs a service that is not here.";
|
||||
missing.perm = CST_TP_ASK;
|
||||
missing.perm_name = "notify";
|
||||
missing.available = probe_missing;
|
||||
missing.run_ex = app_tool_run;
|
||||
missing.owner = "app:selftest";
|
||||
CHECK(cst_tool_register(&missing) == 0, "register a tool with a probe");
|
||||
const cst_tool_def_t *mt = cst_tool_find("app_no_service");
|
||||
char why[160] = "";
|
||||
CHECK(mt && cst_tool_available(mt, why, sizeof(why)) != 0,
|
||||
"the probe says it cannot run");
|
||||
CHECK(strstr(why, "desktop service") != NULL, "and passes its reason on");
|
||||
CHECK(mt && !cst_tool_active(cst_agent_config(a), mt),
|
||||
"so it stays out of tools[] and the prompt");
|
||||
snprintf(call.name, sizeof(call.name), "app_no_service");
|
||||
cst_tool_run(&ctx, &call, &res);
|
||||
CHECK(res.out && strstr(res.out, "cannot run here") != NULL,
|
||||
"asking for it anyway is answered with the reason");
|
||||
CHECK(ctx_runs == 1, "and its callback never ran");
|
||||
cst_tool_result_free(&res);
|
||||
|
||||
/* 3. the app can drop its tools again */
|
||||
CHECK(cst_tool_unregister_owner("app:selftest") == 2, "unregister both");
|
||||
CHECK(cst_tool_find("app_ping") == NULL, "app_ping is gone");
|
||||
CHECK(cst_tool_find("read_file") != NULL, "the built-ins are untouched");
|
||||
|
||||
cst_agent_destroy(a);
|
||||
{
|
||||
char cmd[1200];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", ws);
|
||||
if (system(cmd) != 0)
|
||||
fprintf(stderr, "note: could not clean %s\n", ws);
|
||||
}
|
||||
if (failures) {
|
||||
fprintf(stderr, "cst-app-selftest: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("cst-app-selftest: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# CST-agent on Android
|
||||
|
||||
The engine runs on the phone as a plain bionic binary -- no Termux, no proot,
|
||||
no second process for the UI to talk to. It is the same C core the CLI, the
|
||||
TUI and the WebUI use, cross-compiled with the NDK; on the phone the Qt front
|
||||
end (app/) will link `libcstcore.a` in-process and register its own platform
|
||||
tools under the owner `app:android`.
|
||||
|
||||
Two scripts cover the engine side:
|
||||
|
||||
| script | what it does |
|
||||
| --- | --- |
|
||||
| `scripts/build_third_party_android.sh ABI [all\|mbedtls\|curl]` | static mbedTLS + libcurl into `third_party/prefix-android-ABI` |
|
||||
| `scripts/build_android.sh [ABI] [--push] [--run] [--run-tests]` | `libcstcore.a`, `cst-agent`, the test binaries; optional push and on-device run |
|
||||
|
||||
## Toolchain
|
||||
|
||||
NixOS: nixpkgs carries the NDK (the Android SDK license makes it unfree, hence
|
||||
the flag; the packaged NDK has a patched interpreter, so its clang runs here):
|
||||
|
||||
```sh
|
||||
export ANDROID_NDK_HOME=$(NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link \
|
||||
--print-out-paths 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
|
||||
```
|
||||
|
||||
`adb` comes from `nix-shell -p android-tools`; the builds need cmake, ninja and
|
||||
curl. End to end:
|
||||
|
||||
```sh
|
||||
nix-shell -p cmake ninja curl android-tools --run '
|
||||
JOBS=16 ./scripts/build_third_party_android.sh arm64-v8a all'
|
||||
nix-shell -p cmake ninja curl android-tools --run '
|
||||
JOBS=16 ./scripts/build_android.sh arm64-v8a --push --run'
|
||||
```
|
||||
|
||||
`--run` finishes with the two smoke checks on the device (`cst-agent
|
||||
--version` and `tests/test_embed`, the in-process embedding contract);
|
||||
`--run-tests` builds the whole `tests/` suite, pushes it and runs every binary
|
||||
on the phone, printing a pass/fail line per test.
|
||||
|
||||
## The APK (Qt front end)
|
||||
|
||||
`scripts/build_apk_android.sh [--install] [--logs] [--clean]` configures with
|
||||
the Qt for Android kit, builds the `apk` target (Gradle, no port opened) and
|
||||
optionally installs it. Bits it needs, all overridable through the
|
||||
environment:
|
||||
|
||||
| variable | what it points at |
|
||||
| --- | --- |
|
||||
| `QT_ANDROID` | Qt for Android 6.11.2 (`aqt install-qt all_os android 6.11.2 android_arm64_v8a -O ~/Qt --archives qtbase qtdeclarative qtquickcontrols2`) |
|
||||
| `QT_HOST` | the host Qt of the same version (`aqt install-qt linux desktop 6.11.2 linux_gcc_64 -O ~/Qt --archives qtbase qtdeclarative qtsvg`) |
|
||||
| `ANDROID_SDK_ROOT` | SDK with `platforms/android-<n>` and `build-tools/<v>` |
|
||||
| `ANDROID_NDK_HOME` | the NDK the engine was built with |
|
||||
| `JAVA_HOME` | JDK 17+ for Gradle |
|
||||
|
||||
### The SDK without sdkmanager
|
||||
|
||||
`nixpkgs`'s full `androidsdk` pulls the emulator and system images (gigabytes);
|
||||
the two components actually needed are cheap. The platform comes from the nix
|
||||
store (unfree, so the license flag is required) and build-tools from Google's
|
||||
own zip, laid out as the SDK `sdkmanager` would:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix build \
|
||||
--impure --no-link --print-out-paths --expr \
|
||||
'builtins.elemAt (import <nixpkgs> {}).androidenv.androidPkgs.platforms 2') # android-35
|
||||
mkdir -p ~/android-sdk/{build-tools,platforms}
|
||||
ln -s $PLATFORM/libexec/android-sdk/platforms/android-35 ~/android-sdk/platforms/
|
||||
```
|
||||
|
||||
`androiddeployqt` then picks `build-tools` and `platform-tools` from there. For
|
||||
build-tools, `dl.google.com` is the only source: from here it hands out
|
||||
~100 KB/s per connection, so the script downloads the zip in 16 ranged
|
||||
requests (≈25x faster) and fixes the exec bits Python's `zipfile` drops.
|
||||
|
||||
### Host Qt on NixOS
|
||||
|
||||
The host Qt is built for a regular distribution: its tools (`qmlimportscanner`
|
||||
first of all) look for `libgssapi_krb5.so.2`, `libglib-2.0.so.0`, `libz.so.1`,
|
||||
`libfontconfig`, `libX11`, `libEGL`/`libGL`, `libzstd`, `libbrotlidec`,
|
||||
`libdbus-1`, `libfreetype`, `libstdc++`, none of which exist in NixOS's search
|
||||
paths. The tools carry `RUNPATH=$ORIGIN/../lib`, so symlinking those libraries
|
||||
from the nix store into `<QT_HOST>/lib/` is enough:
|
||||
|
||||
```sh
|
||||
nix build --no-link --print-out-paths nixpkgs#krb5.lib nixpkgs#dbus.lib \
|
||||
nixpkgs#libglvnd nixpkgs#fontconfig.lib nixpkgs#freetype nixpkgs#glib.lib \
|
||||
nixpkgs#libx11 nixpkgs#libxkbcommon nixpkgs#zlib nixpkgs#zstd \
|
||||
nixpkgs#brotli.lib nixpkgs#stdenv.cc.cc.lib # then ln -s <lib> ~/Qt/6.11.2/gcc_64/lib/
|
||||
```
|
||||
|
||||
Two more things the script does on the way:
|
||||
|
||||
* **GLESv2/EGL**: Qt's `FindGLESv2.cmake` only searches `CMAKE_FIND_ROOT_PATH`
|
||||
prefixes (`<sysroot>/include`), but the NDK keeps the headers in
|
||||
`<sysroot>/usr/include` -- without `-DGLESv2_INCLUDE_DIR`/`-DEGL_INCLUDE_DIR`
|
||||
`find_package(Qt6 Gui)` fails with "dependency GLESv2 could not be found".
|
||||
* **`-L$PREFIX/lib` on every link type**: the APK's payload is a *shared
|
||||
module*, so `CMAKE_SHARED_LINKER_FLAGS`/`CMAKE_MODULE_LINKER_FLAGS` need the
|
||||
prefix too, not just the EXE flags.
|
||||
|
||||
### Gradle
|
||||
|
||||
The generated project resolves plugins and dependencies from `google()` and
|
||||
`mavenCentral()`, plus a Gradle distribution from `services.gradle.org`. From
|
||||
here all three are slow (dl.google.com ~100 KB/s), so two local tweaks:
|
||||
|
||||
* `~/.gradle/init.d/*.gradle` (machine-local, not in the repo) prepends the
|
||||
Aliyun/Tencent mirrors for `pluginManagement`, `dependencyResolutionManagement`
|
||||
and every `buildscript`.
|
||||
* the wrapper in `<QT_ANDROID>/src/3rdparty/gradle/gradle/wrapper/` points at
|
||||
the Tencent Gradle mirror (51 MB/s vs 100 KB/s).
|
||||
|
||||
`scripts/gradle-android-init.gradle` (installed into `~/.gradle/init.d/` by the
|
||||
build script) switches off release lint: `lintVitalAnalyzeRelease` resolves its
|
||||
own tool jars straight from `dl.google.com`, bypassing every repository the
|
||||
build configures, and fails the build after all the real work is done.
|
||||
|
||||
### Signing
|
||||
|
||||
`androiddeployqt` copies the **unsigned** Gradle output and signs it itself
|
||||
(`QT_ANDROID_SIGN_APK` + the `QT_ANDROID_KEYSTORE_*` variables; a signed Gradle
|
||||
output makes it fail with "Could not copy unsigned APK"). The script exports
|
||||
those variables and creates the standard Android debug keystore
|
||||
(`~/.android/debug.keystore`, alias `androiddebugkey`) on first use;
|
||||
`CST_ANDROID_KEYSTORE` and `CST_ANDROID_KEY_ALIAS`/`_PASS` override it.
|
||||
`apksigner` is a bash script with a `#!/bin/bash` shebang, which NixOS does not
|
||||
have, so the script rewrites the shebang of the build-tools wrappers.
|
||||
|
||||
## What is Android-specific
|
||||
|
||||
* **API level 24** (`API_LEVEL=24`): bionic provides threads, `exec` and
|
||||
`/system/etc/security/cacerts`, which is where libcurl is pointed
|
||||
(`CURL_CA_PATH`). Bump the level if a newer syscall or CA layout is needed.
|
||||
* **No TUI, no WebUI**: `-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF` -- there is
|
||||
no ncurses on Android, and the phone front end talks to the engine in-process
|
||||
instead of over HTTP. `src/webui_stub.c` compiles in place of the server.
|
||||
* **Sandbox**: `src/sandbox_exec.c` only enables the seccomp/Landlock paths
|
||||
under `defined(__linux__) && !defined(__ANDROID__)`, so the phone build gets
|
||||
the plain exec path -- the same rule the Termux build follows.
|
||||
The seccomp supervisor used to compile in and the probe reported "seccomp",
|
||||
but on the device a write outside the workspace was not denied
|
||||
(`tests/test_sandbox.c:308`), so the phone build now reports no sandbox
|
||||
instead of one that does not hold.
|
||||
* **sashidown** is built per ABI (`vendor/sashidown/libsashidown-<abi>.a`) with
|
||||
the NDK's `aarch64-linux-android24-clang` wrapper: CMake's compiler is plain
|
||||
clang and keeps `--target`/`--sysroot` in its own variables, which the
|
||||
vendored makefile cannot see. The host archive stays host-built.
|
||||
* **Static libcurl** needs `-L$PREFIX/lib` on the link line, because curl's own
|
||||
CMake config lists its mbedTLS dependencies as `-l` flags rather than paths.
|
||||
|
||||
## Status
|
||||
|
||||
* arm64-v8a, Android 16 (NX789J): `cst-agent` runs, `tests/test_embed` passes.
|
||||
* The whole suite runs on the device (`scripts/build_android.sh arm64-v8a
|
||||
--run-tests`): 36/36 after the diff test learned to tell toybox's `diff` from
|
||||
GNU diffutils.
|
||||
* The APK builds, installs and runs: the Qt front end links the engine
|
||||
in-process (no port), `main.cpp` puts the workspace, config and tmp under
|
||||
`/data/user/0/xyz.whcself.cstagent/files/`, and a turn does a real HTTPS
|
||||
request from inside the ART process. The saved provider key is resolved into
|
||||
the engine's config on every start (`AgentCore::open()`: saved key, the
|
||||
provider's environment variable, `CST_API_KEY`), so the phone answers without
|
||||
being asked for the key again. Rotation keeps the session; the sidebar
|
||||
appears in landscape.
|
||||
* Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`):
|
||||
`notification_send`, `clipboard_get` / `clipboard_set` under the engine's
|
||||
own tool names and `share` (`ACTION_SEND` + chooser) all work on the device.
|
||||
The file
|
||||
card's **Open** builds a `content://` URI through the app's FileProvider
|
||||
(`<package>.qtprovider`, Qt's `@xml/qtprovider_paths`), checks
|
||||
`resolveActivity()` before starting `ACTION_VIEW` and grants the reader
|
||||
permission; **Share** reuses the share sheet.
|
||||
* **Java helpers**: a notification built entirely through `QJniObject` posted
|
||||
nothing on the NX789J -- `Notification.Builder.build()` came back null and
|
||||
the tool could only say "failed: no exception detail", because Qt clears a
|
||||
Java exception before C++ gets to look at it. The one step that has to fail
|
||||
visibly (a builder, a `PendingIntent`, `build()`, `notify()`) now lives in
|
||||
`app/android/src/xyz/whcself/cstagent/Notifier.java`, which catches the
|
||||
throwable and hands the text back for the tool result; the device then
|
||||
reported `notification sent` and the shade had it. androiddeployqt copies
|
||||
`app/android/` with the rest of the package source and Qt's Gradle template
|
||||
puts its `src/` on `java.srcDirs`, so **a class dropped in that directory
|
||||
needs no build-script change** -- the front service of the next step goes
|
||||
there too.
|
||||
* A session's transcript replays a delivered file (`deliver_file`) as a card
|
||||
with Open and Share -- the copy lives next to the session, marked `for_user`
|
||||
so no request ever contains it (see `app/README.md`, "Files the agent
|
||||
delivers").
|
||||
* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
|
||||
`app::holdForeground()`, which starts `CstService`
|
||||
(`app/android/src/xyz/whcself/cstagent/CstService.java`) as a foreground
|
||||
service and takes a partial wake lock; `finishTurn()` stops it again. On the
|
||||
NX789J: with the app sent home and the screen off, the service showed
|
||||
`isForeground=true` with its `CST-agent is working` notification (id 2, the
|
||||
same `cst-agent` channel), the lock `cst-agent:turn` was acquired at the
|
||||
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.
|
||||
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
||||
* **A turn that asked and got no answer**: a consent question cannot outlive
|
||||
the app being off screen. `AgentCore::hookPermission()` / `hookAskUser()`
|
||||
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` |
|
||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||
|
||||
@@ -26,6 +28,12 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
||||
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
|
||||
|
||||
Kimi 的 K3 请求使用 `reasoning_effort`;K2.6/K2.7 使用 `thinking`,并在后续请求中回传 `reasoning_content`。默认发送 Kimi 1 小时隐式 prompt cache 提示,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭。
|
||||
|
||||
当 provider 为 Kimi 或 DeepSeek 时,`web_search` 和 `web_fetch` 使用对应的官方联网 Search/Fetch API;其它 provider 使用通用 URL 抓取。
|
||||
|
||||
`kimi-code` 使用 Kimi Code 的 OpenAI-compatible API。它和按量计费的 `kimi` 开放平台使用不同的 Base URL 与 API Key,不能混用。
|
||||
|
||||
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
|
||||
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
||||
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
|
||||
@@ -42,6 +50,20 @@ cst-agent --provider deepseek # 首次会询问 key
|
||||
cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought
|
||||
```
|
||||
|
||||
### Kimi
|
||||
|
||||
```bash
|
||||
cst-agent --provider kimi # 默认 kimi-k3
|
||||
cst-agent --provider kimi --model kimi-k2.6 # K2 thinking 模式
|
||||
```
|
||||
|
||||
### Kimi Code
|
||||
|
||||
```bash
|
||||
cst-agent --provider kimi-code # 默认 k3
|
||||
cst-agent --provider kimi-code --model k3-256k
|
||||
```
|
||||
|
||||
### OpenAI 官方
|
||||
|
||||
```bash
|
||||
|
||||
@@ -116,7 +116,7 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/state` | 上面那份快照 |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致,另加的字段见下) |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与转录 `.agent/sessions/<id>/session.jsonl` 的行一致,另加的字段见下) |
|
||||
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
|
||||
| POST | `/api/sessions/new` | `{}` → `{id}` |
|
||||
| POST | `/api/sessions/switch` | `{"id":"…"}` |
|
||||
|
||||
@@ -61,6 +61,10 @@ typedef struct {
|
||||
int pages; /* PDF page count, 0 for anything else */
|
||||
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
|
||||
char origin[32]; /* user | view_image | mcp (for display only) */
|
||||
/* A delivered file (deliver_file): the front end shows it as a card the
|
||||
* person can open or share, and no request ever carries it. The stored copy
|
||||
* is the file as it was, not a text reduction -- see cst_attach.h. */
|
||||
int for_user;
|
||||
} cst_attachment_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -89,6 +93,13 @@ typedef struct {
|
||||
cst_protocol_t protocol;
|
||||
int max_steps; /* model calls per user turn */
|
||||
size_t tool_out_cap; /* bytes of one tool output sent to the model */
|
||||
/* What the model is asked to do (Chat Completions fields). 0 = the field
|
||||
* is not sent at all and the provider's own default stands; that is what a
|
||||
* config that was not told otherwise has, so a CLI run is unchanged.
|
||||
* temperature and top_p are doubles, max_tokens the completion cap. */
|
||||
double temperature;
|
||||
double top_p;
|
||||
int max_tokens;
|
||||
int headless;
|
||||
int confirm_shell; /* confirm run_shell */
|
||||
int confirm_write; /* confirm file-changing tools */
|
||||
@@ -156,7 +167,14 @@ void cst_session_unlock(cst_session_t *s);
|
||||
/* --- workspace .agent/sessions store (OpenCode-like) --- */
|
||||
int cst_agent_store_ensure(const char *workspace);
|
||||
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
|
||||
/* A fresh session id: a uuid v4, so two machines sharing a workspace cannot
|
||||
* hand out the same one (a timestamp can). id_sz >= 37. */
|
||||
int cst_agent_store_new_id(char *id_out, size_t id_sz);
|
||||
/* The transcript of an id. A session owns a directory under the store
|
||||
* (<id>/session.jsonl, with its attachments and sub-agent transcripts inside
|
||||
* it); an id that still has the older flat file (<id>.jsonl) keeps it until
|
||||
* cst_agent_store_migrate() moves it. A new id gets its directory made here,
|
||||
* so the caller can open the file next. */
|
||||
int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
char *out, size_t out_sz);
|
||||
int cst_agent_store_set_current(const char *workspace, const char *id);
|
||||
@@ -166,6 +184,10 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out,
|
||||
size_t path_sz, char *id_out,
|
||||
size_t id_sz);
|
||||
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
|
||||
/* The id a session path belongs to: <dir>/<id>.jsonl (the flat layout) or
|
||||
* <dir>/<id>/session.jsonl (a session's own directory). 0 / -1. */
|
||||
int cst_agent_store_id_of_path(const char *session_path, char *out,
|
||||
size_t out_sz);
|
||||
typedef struct {
|
||||
char id[128];
|
||||
long long mtime;
|
||||
@@ -178,6 +200,13 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
int cst_agent_store_title_set(const char *workspace, const char *id,
|
||||
const char *title);
|
||||
int cst_agent_store_delete(const char *workspace, const char *id);
|
||||
/* Move every flat session of the store into the per-session layout: the
|
||||
* transcript to <id>/session.jsonl, the title and the shared attachment
|
||||
* directory (<id>.title, attachments/<id>/) along with it. Sessions that are
|
||||
* already directories are left alone, so running it twice is harmless.
|
||||
* *moved_out / *failed_out (optional) count what happened; 0 / -1. */
|
||||
int cst_agent_store_migrate(const char *workspace, int *moved_out,
|
||||
int *failed_out, char *err, size_t err_sz);
|
||||
|
||||
/* --- tools --- */
|
||||
typedef struct {
|
||||
@@ -271,6 +300,9 @@ typedef struct {
|
||||
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
|
||||
int images_off; /* the model does not take images: send a note instead */
|
||||
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
|
||||
double temperature; /* 0 = not sent */
|
||||
double top_p; /* 0 = not sent */
|
||||
int max_tokens; /* 0 = not sent */
|
||||
const char *session_path; /* where stored images live; NULL = none */
|
||||
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
|
||||
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
||||
@@ -324,6 +356,12 @@ typedef struct {
|
||||
/* Full tool output, fired right after execution (before it is persisted). */
|
||||
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
|
||||
size_t len, int exit_code, void *ud);
|
||||
/* Images a tool produced (view_image, MCP image content), fired right after
|
||||
* the call ran and before the result is persisted. Only a call that made at
|
||||
* least one image is reported, so a front end can show it at once instead of
|
||||
* reading the store. */
|
||||
void (*on_tool_images)(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud);
|
||||
void *userdata;
|
||||
} cst_agent_hooks_t;
|
||||
|
||||
@@ -368,6 +406,12 @@ int cst_agent_pending_count(const cst_agent_t *a);
|
||||
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
|
||||
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
|
||||
size_t out_sz);
|
||||
/* The stored name of pending attachment i ("img-1a2b3c4d.png"), or NULL when
|
||||
* i is out of range. The file is already in the session store, so a front end
|
||||
* can show the image before the message is sent. */
|
||||
const char *cst_agent_pending_rel(const cst_agent_t *a, int i);
|
||||
/* 1 when pending attachment i is an image, 0 otherwise (or out of range). */
|
||||
int cst_agent_pending_is_image(const cst_agent_t *a, int i);
|
||||
void cst_agent_pending_clear(cst_agent_t *a);
|
||||
/* Latest todo_write list of this session, or NULL. */
|
||||
const char *cst_agent_todo(const cst_agent_t *a);
|
||||
@@ -422,6 +466,16 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
|
||||
* <config>/settings.json. */
|
||||
#define CST_SETTINGS_MAX_MODELS 16
|
||||
#define CST_SETTINGS_MAX_TRUSTED 32
|
||||
#define CST_SETTINGS_MAX_PARAMS 16
|
||||
/* Generation parameters remembered per provider ("params" in settings.json):
|
||||
* what the user set for that provider's models. 0 = not set, the field is
|
||||
* not sent. */
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
double temperature;
|
||||
double top_p;
|
||||
int max_tokens;
|
||||
} cst_gen_params_t;
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
char effort[16];
|
||||
@@ -436,10 +490,18 @@ typedef struct {
|
||||
char provider[32];
|
||||
char model[128];
|
||||
} models[CST_SETTINGS_MAX_MODELS];
|
||||
int nparams;
|
||||
cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS];
|
||||
} cst_settings_t;
|
||||
int cst_settings_load(cst_settings_t *s);
|
||||
int cst_settings_save(const cst_settings_t *s);
|
||||
const char *cst_settings_model(const cst_settings_t *s, const char *provider);
|
||||
/* The remembered parameters of a provider, or NULL when it has none. */
|
||||
const cst_gen_params_t *cst_settings_params(const cst_settings_t *s,
|
||||
const char *provider);
|
||||
/* Remember parameters for a provider; all-zero (or NULL) forgets them. */
|
||||
int cst_settings_remember_params(const char *provider,
|
||||
const cst_gen_params_t *p);
|
||||
/* Record cfg's provider, model, effort and mode as the last used ones. */
|
||||
int cst_settings_remember(const cst_agent_config_t *cfg);
|
||||
/* Record an approval mode the user picked (CST_APPROVAL_*). */
|
||||
|
||||
@@ -23,6 +23,10 @@ extern "C" {
|
||||
#define CST_ATTACH_MAX_COUNT 8 /* per message */
|
||||
/* Text stored and inlined per document (a PDF also caps its text here). */
|
||||
#define CST_ATTACH_TEXT_MAX (4 * 1024 * 1024)
|
||||
/* A delivered file (deliver_file) is the opposite of an attachment: it is
|
||||
* handed to the person, not to the model, so any bytes are allowed up to this
|
||||
* size, the extension names the type, and no request ever shows it. */
|
||||
#define CST_ARTIFACT_MAX_BYTES (64 * 1024 * 1024)
|
||||
|
||||
/* Images go on the wire as image parts; everything else is inlined as text. */
|
||||
int cst_attach_is_image(const cst_attachment_t *a);
|
||||
@@ -55,6 +59,14 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||
const char *name, const char *data,
|
||||
size_t n, char *err, size_t err_sz);
|
||||
/* Copy src_path into the session's store as a finished file for the user:
|
||||
* any bytes up to CST_ARTIFACT_MAX_BYTES, mime from the name's extension, and
|
||||
* `for_user` set so it stays out of every request. title (optional) is the
|
||||
* display name, the source's file name the default. */
|
||||
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
|
||||
const char *src_path,
|
||||
const char *title, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Validate and read back a stored payload, or NULL when it is gone. */
|
||||
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||
|
||||
@@ -31,6 +31,10 @@ int cst_clipboard_set_text(const char *text, size_t n, char *err,
|
||||
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
|
||||
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
|
||||
|
||||
/* 0 when this host has a clipboard to talk to (a helper on PATH, or the
|
||||
* Win32/AppKit API), -1 with a reason otherwise. Cheap: no forks. */
|
||||
int cst_clipboard_available(char *why, size_t why_sz);
|
||||
|
||||
#define CST_PASTE_MAX_PATHS 8
|
||||
#define CST_PASTE_PATH_MAX 1024
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p);
|
||||
/* The user file's path (it may not exist). */
|
||||
int cst_models_user_path(char *out, size_t out_sz);
|
||||
|
||||
/* Add or replace a provider in the user file (layer 1 above) and reload the
|
||||
* registry: this is how a provider the presets do not know becomes one.
|
||||
* name is 1-31 characters of letters, digits, '.', '_' or '-'; api_base is
|
||||
* required; key_env names the variable its key may come from; default_model
|
||||
* and models[] are what the model picker offers (a fetched /models list still
|
||||
* wins). An entry with a preset's name overrides that preset, everything else
|
||||
* in the file (model rules) is kept. 0, or -1 with err filled. */
|
||||
int cst_models_user_set_provider(const char *name, const char *api_base,
|
||||
const char *key_env,
|
||||
const char *default_model,
|
||||
const char *const *models, int nmodels,
|
||||
char *err, size_t err_sz);
|
||||
/* Drop a provider from the user file (a preset of the same name stays).
|
||||
* 1 = removed, 0 = it was not there, -1 with err. */
|
||||
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz);
|
||||
|
||||
/* Drop everything and read the built-in data, the user file and the caches
|
||||
* again. Provider pointers stay valid but may name another provider. */
|
||||
void cst_models_reload(void);
|
||||
|
||||
@@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx,
|
||||
const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud);
|
||||
|
||||
/* Platforms a tool exists on, for def.platforms (NULL-terminated).
|
||||
* CST_PLAT_DESKTOP covers linux / win / macos; an app-provided tool can
|
||||
* declare its own, e.g. a static {"android", NULL} in the Qt frontend. */
|
||||
extern const char *const CST_PLAT_DESKTOP[];
|
||||
/* Host platform: "linux" | "win" | "macos" | "android" | "termux" | "other".
|
||||
* CST_PLATFORM overrides it (tests, cross checks). */
|
||||
const char *cst_platform_name(void);
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *desc;
|
||||
@@ -81,10 +89,34 @@ typedef struct {
|
||||
const char *owner; /* "plugin:<name>", "mcp:<server>", ... */
|
||||
/* Non-READ tools that may still join a parallel batch for this call. */
|
||||
int (*parallel)(const ctl_tool_call_t *call, void *ud);
|
||||
/* --- capability metadata (discovery + availability) ------------------- */
|
||||
/* Fine-grained policy name for permissions.json and consent UIs
|
||||
* ("read","write","delete","shell","net","notify","device","ui","meta").
|
||||
* NULL falls back to the class name of perm. */
|
||||
const char *perm_name;
|
||||
/* Platforms the tool exists on (NULL = everywhere). A tool that is not
|
||||
* for this host never reaches the prompt or tools[]. */
|
||||
const char *const *platforms;
|
||||
/* Probe: 0 when the tool can run here, -1 with a reason in why (NULL =
|
||||
* always). Keep it cheap (no forks); the answer is cached until
|
||||
* cst_tool_meta_reload(). */
|
||||
int (*available)(char *why, size_t why_sz);
|
||||
} cst_tool_def_t;
|
||||
|
||||
/* 0 when t exists on this platform. */
|
||||
int cst_tool_platform_ok(const cst_tool_def_t *t);
|
||||
/* 0 when the tool can run here, -1 with a reason in why. Cached. */
|
||||
int cst_tool_available(const cst_tool_def_t *t, char *why, size_t why_sz);
|
||||
/* Drop cached probes (a platform service appeared or went away). */
|
||||
void cst_tool_meta_reload(void);
|
||||
/* Policy name of t: perm_name, or the class default of perm. */
|
||||
const char *cst_tool_perm_name(const cst_tool_def_t *t);
|
||||
|
||||
/* Add a tool at runtime (strings are copied, ud is not). Safe while turns
|
||||
* run; returns 0, or -1 when the name is taken or the table is full. */
|
||||
* run; returns 0, or -1 when the name is taken or the table is full. An
|
||||
* app-provided tool may take a name whose built-in cannot run on this host
|
||||
* (the Android clipboard replacing the xclip one) and then shadows it for
|
||||
* lookups: one capability, one name. */
|
||||
int cst_tool_register(const cst_tool_def_t *def);
|
||||
/* Drop every dynamic tool of owner (call only while no turn runs). */
|
||||
int cst_tool_unregister_owner(const char *owner);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 8
|
||||
#define CST_VERSION_PATCH 6
|
||||
#define CST_VERSION_MINOR 9
|
||||
#define CST_VERSION_PATCH 8
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
|
||||
@@ -38,6 +38,10 @@ void cst_web_md_free(cst_web_md_t *s);
|
||||
|
||||
/* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */
|
||||
char *cst_web_md_html(const char *md, size_t len);
|
||||
/* One-shot: markdown -> a whole HTML document (doctype, charset, title, a
|
||||
* small stylesheet; no script and no external request), for a file handed to
|
||||
* the user. title is optional; NULL or empty means "Document". */
|
||||
char *cst_web_md_document(const char *md, size_t len, const char *title);
|
||||
/* The sanitizer alone (tests, and any HTML that did not come from here). */
|
||||
char *cst_web_md_sanitize(const char *html, size_t len);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define CST_OS_COMPAT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
@@ -12,6 +13,13 @@ extern "C" {
|
||||
#define OS_PATH_MAX 4096
|
||||
#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 --- */
|
||||
int os_path_is_abs(const char *p);
|
||||
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-build the engine for Android: libcstcore.a (what the APK embeds) plus
|
||||
# cst-agent and tests/test_embed, which run straight from /data/local/tmp.
|
||||
# No TUI (no ncurses on Android) and no WebUI (the phone app talks to the
|
||||
# engine in-process; the stub compiles instead of the HTTP server).
|
||||
#
|
||||
# scripts/build_android.sh [arm64-v8a|x86_64] [--push] [--run] [--run-tests]
|
||||
#
|
||||
# --push copy the binaries to /data/local/tmp
|
||||
# --run also smoke-test there: cst-agent --version, test_embed
|
||||
# --run-tests build and run the whole tests/ suite on the device
|
||||
#
|
||||
# Needs ANDROID_NDK_HOME (see build_third_party_android.sh) and the deps from
|
||||
# scripts/build_third_party_android.sh ABI all.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ABI="arm64-v8a"
|
||||
DO_PUSH=0
|
||||
DO_RUN=0
|
||||
DO_TESTS=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
arm64-v8a | x86_64) ABI="$a" ;;
|
||||
--push) DO_PUSH=1 ;;
|
||||
--run) DO_PUSH=1; DO_RUN=1 ;;
|
||||
--run-tests) DO_PUSH=1; DO_TESTS=1 ;;
|
||||
*) echo "usage: $0 [arm64-v8a|x86_64] [--push] [--run|--run-tests]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
API_LEVEL="${API_LEVEL:-24}"
|
||||
PREFIX="$ROOT/third_party/prefix-android-$ABI"
|
||||
BUILD="$ROOT/build-android-$ABI"
|
||||
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||
|
||||
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
|
||||
echo "set ANDROID_NDK_HOME to an NDK (see build_third_party_android.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then
|
||||
echo "missing $PREFIX/lib/libcurl.a -- run:" >&2
|
||||
echo " scripts/build_third_party_android.sh $ABI all" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cmake -S "$ROOT" -B "$BUILD" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
|
||||
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
|
||||
-DANDROID_NDK="$NDK" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$PREFIX" \
|
||||
-DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \
|
||||
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=OFF \
|
||||
-DCST_BUNDLE_BUSYBOX=OFF
|
||||
if [[ "$DO_TESTS" == 1 ]]; then
|
||||
cmake --build "$BUILD" -j"$JOBS" # every test target
|
||||
else
|
||||
cmake --build "$BUILD" -j"$JOBS" --target cst-agent test_embed
|
||||
fi
|
||||
echo "built in $BUILD"
|
||||
[[ "$DO_PUSH" == 1 ]] || exit 0
|
||||
|
||||
ADB="${ADB:-adb}"
|
||||
command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; }
|
||||
DEV=/data/local/tmp
|
||||
NAMES=(cst-agent test_embed)
|
||||
FILES=("$BUILD/cst-agent" "$BUILD/test_embed")
|
||||
TESTS=()
|
||||
if [[ "$DO_TESTS" == 1 ]]; then
|
||||
mapfile -t TESTS < <(find "$BUILD" -maxdepth 1 -type f -name 'test_*' -perm -u+x | sort)
|
||||
for t in "${TESTS[@]}"; do
|
||||
NAMES+=("${t##*/}")
|
||||
FILES+=("$t")
|
||||
done
|
||||
fi
|
||||
"$ADB" push "${FILES[@]}" "$DEV/" >/dev/null
|
||||
"$ADB" shell chmod 755 "${NAMES[@]/#/$DEV/}"
|
||||
echo "pushed ${#NAMES[@]} binaries to $DEV"
|
||||
[[ "$DO_PUSH" == 1 && ( "$DO_RUN" == 1 || "$DO_TESTS" == 1 ) ]] || exit 0
|
||||
|
||||
echo "--- $DEV/cst-agent --version"
|
||||
"$ADB" shell "$DEV/cst-agent --version"
|
||||
if [[ "$DO_TESTS" == 0 ]]; then
|
||||
echo "--- $DEV/test_embed"
|
||||
"$ADB" shell "cd $DEV && ./test_embed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The suite runs on the device as the shell user; tests that want a real
|
||||
# /bin/sh, a host fixture tree or a Linux-specific sandbox report their own
|
||||
# failure, which is the point of running them here.
|
||||
# The sandbox test builds its scratch trees under $PWD/tests, the same way
|
||||
# ctest runs it from the repo root.
|
||||
"$ADB" shell mkdir -p "$DEV/tests"
|
||||
pass=0; fail=0; skipped=()
|
||||
for t in "${TESTS[@]}"; do
|
||||
name="${t##*/}"
|
||||
if "$ADB" shell "cd $DEV && ./$name" >/dev/null 2>&1; then
|
||||
pass=$((pass + 1)); echo "ok $name"
|
||||
else
|
||||
fail=$((fail + 1)); skipped+=("$name"); echo "FAIL $name"
|
||||
fi
|
||||
done
|
||||
echo "--- $pass passed, $fail failed"
|
||||
[[ "$fail" == 0 ]] || { printf 'failed: %s\n' "${skipped[*]}"; exit 1; }
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Qt Quick Android APK of app/ (the engine is linked in-process, so
|
||||
# the APK opens no port). This is the front end counterpart of
|
||||
# scripts/build_android.sh, which cross-builds the engine alone.
|
||||
#
|
||||
# scripts/build_apk_android.sh [--install] [--logs] [--clean]
|
||||
#
|
||||
# --install adb install -r the debug APK on the connected device
|
||||
# --logs stream logcat after installing (Qt + engine tags)
|
||||
# --clean wipe the build directory first
|
||||
#
|
||||
# Needs, all overridable through the environment:
|
||||
# QT_ANDROID Qt for Android (default ~/Qt/<ver>/android_arm64_v8a)
|
||||
# QT_HOST matching host Qt (default ~/Qt/<ver>/gcc_64)
|
||||
# ANDROID_SDK_ROOT SDK with platforms/ + build-tools/ (see docs/android.md)
|
||||
# ANDROID_NDK_HOME NDK, same one as scripts/build_third_party_android.sh
|
||||
# JAVA_HOME JDK 17+ for Gradle (falls back to the javac on PATH)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ABI="${ABI:-arm64-v8a}"
|
||||
API_LEVEL="${API_LEVEL:-24}"
|
||||
BUILD="$ROOT/build-apk-android"
|
||||
PREFIX="$ROOT/third_party/prefix-android-$ABI"
|
||||
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||
|
||||
DO_INSTALL=0
|
||||
DO_LOGS=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--install) DO_INSTALL=1 ;;
|
||||
--logs) DO_INSTALL=1; DO_LOGS=1 ;;
|
||||
--clean) rm -rf "$BUILD" ;;
|
||||
*) echo "usage: $0 [--install] [--logs] [--clean]" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Qt: Android target first, then the host tools of the same version ----
|
||||
if [[ -z "${QT_ANDROID:-}" ]]; then
|
||||
# aqt names the directory after the ABI with underscores: android_arm64_v8a
|
||||
for q in "$HOME"/Qt/*/android_"${ABI//-/_}"/lib/cmake/Qt6/qt.toolchain.cmake; do
|
||||
[[ -f "$q" ]] && QT_ANDROID="${q%/lib/cmake/Qt6/qt.toolchain.cmake}"
|
||||
done
|
||||
fi
|
||||
if [[ -z "${QT_ANDROID:-}" || ! -x "$QT_ANDROID/bin/qt-cmake" ]]; then
|
||||
echo "set QT_ANDROID to a Qt for Android install ('aqt install-qt all_os android ...')" >&2
|
||||
exit 1
|
||||
fi
|
||||
QT_VER="$(basename "$(dirname "$QT_ANDROID")")"
|
||||
QT_HOST="${QT_HOST:-$(dirname "$QT_ANDROID")/gcc_64}"
|
||||
if [[ ! -x "$QT_HOST/bin/qmake" && ! -x "$QT_HOST/bin/androiddeployqt" ]]; then
|
||||
echo "set QT_HOST to the host Qt $QT_VER (same version, needed for androiddeployqt)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
|
||||
echo "set ANDROID_NDK_HOME to an NDK (see scripts/build_third_party_android.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANDROID_SDK_ROOT:-}" || ! -d "$ANDROID_SDK_ROOT/platforms" ]]; then
|
||||
echo "set ANDROID_SDK_ROOT to an SDK with platforms/android-<n> and build-tools/" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${JAVA_HOME:-}" ]]; then
|
||||
JAVAC="$(command -v javac || true)"
|
||||
[[ -n "$JAVAC" ]] && JAVA_HOME="$(dirname "$(dirname "$(readlink -f "$JAVAC")")")"
|
||||
fi
|
||||
if [[ -z "${JAVA_HOME:-}" || ! -x "$JAVA_HOME/bin/java" ]]; then
|
||||
echo "set JAVA_HOME to a JDK 17+ (Gradle needs it)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then
|
||||
echo "missing $PREFIX/lib/libcurl.a -- run:" >&2
|
||||
echo " scripts/build_third_party_android.sh $ABI all" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANDROID_SDK_ROOT ANDROID_NDK_HOME="$NDK" ANDROID_NDK_ROOT="$NDK" JAVA_HOME
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# androiddeployqt shells out to the generated project's gradlew, which picks up
|
||||
# init scripts from $GRADLE_USER_HOME/init.d. Install ours (release lint off)
|
||||
# so every build behaves the same. See the file for why lint has to go.
|
||||
GRADLE_INIT_DIR="${GRADLE_USER_HOME:-$HOME/.gradle}/init.d"
|
||||
mkdir -p "$GRADLE_INIT_DIR"
|
||||
if ! cmp -s "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"; then
|
||||
install -m644 "$ROOT/scripts/gradle-android-init.gradle" "$GRADLE_INIT_DIR/cst-android.gradle"
|
||||
echo "installed $GRADLE_INIT_DIR/cst-android.gradle"
|
||||
fi
|
||||
|
||||
# The release APK has to be signed to install. With CST_ANDROID_KEYSTORE the
|
||||
# init script uses that keystore; otherwise it falls back to the standard
|
||||
# Android debug key, created here on first use (same one Android Studio makes).
|
||||
KEYSTORE="${CST_ANDROID_KEYSTORE:-$HOME/.android/debug.keystore}"
|
||||
if [[ ! -f "$KEYSTORE" && -z "${CST_ANDROID_KEYSTORE:-}" ]]; then
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
keytool -genkeypair -keystore "$KEYSTORE" -storepass android -keypass android \
|
||||
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US" >/dev/null
|
||||
chmod 600 "$KEYSTORE"
|
||||
echo "created debug keystore: $KEYSTORE"
|
||||
fi
|
||||
# androiddeployqt reads these (the path/alias/passwords never reach the CMake
|
||||
# command line, only the QT_ANDROID_SIGN_APK flag does).
|
||||
export QT_ANDROID_KEYSTORE_PATH="$KEYSTORE"
|
||||
export QT_ANDROID_KEYSTORE_ALIAS="${CST_ANDROID_KEY_ALIAS:-androiddebugkey}"
|
||||
export QT_ANDROID_KEYSTORE_STORE_PASS="${CST_ANDROID_KEYSTORE_PASS:-android}"
|
||||
export QT_ANDROID_KEYSTORE_KEY_PASS="${CST_ANDROID_KEY_PASS:-android}"
|
||||
|
||||
# NixOS has no /bin/bash, and build-tools' shell wrappers (apksigner is the one
|
||||
# androiddeployqt calls, d8 and friends are the rest) start with #!/bin/bash.
|
||||
# Point them at the bash that exists here; the SDK is a local copy, so this
|
||||
# sticks.
|
||||
BT_DIR="$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* 2>/dev/null | sort -V | tail -1)"
|
||||
BASH_BIN="$(command -v bash || echo /bin/sh)"
|
||||
if [[ -n "$BT_DIR" ]]; then
|
||||
while IFS= read -r f; do
|
||||
sed -i "1s|^#!/bin/bash|#!$BASH_BIN|" "$f" && echo "fixed shebang: ${f#"$ANDROID_SDK_ROOT"/}"
|
||||
done < <(grep -rls '^#!/bin/bash' "$BT_DIR" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Qt6Gui wants GLESv2/EGL: the NDK sysroot has both, but Qt's Find modules
|
||||
# only search CMAKE_FIND_ROOT_PATH prefixes (<sysroot>/include), while the
|
||||
# headers live in <sysroot>/usr/include -- point them there directly.
|
||||
NDK_SYSROOT_INC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include"
|
||||
[[ -d "$NDK_SYSROOT_INC/GLES2" ]] || echo "warning: no GLES2 headers under $NDK_SYSROOT_INC" >&2
|
||||
|
||||
echo "Qt for Android : $QT_ANDROID ($QT_VER)"
|
||||
echo "host Qt : $QT_HOST"
|
||||
echo "SDK : $ANDROID_SDK_ROOT ($(ls "$ANDROID_SDK_ROOT/build-tools" | tr '\n' ' '))"
|
||||
echo "NDK : $NDK"
|
||||
echo "JDK : $JAVA_HOME"
|
||||
echo "ABI : $ABI, minSdk $API_LEVEL"
|
||||
|
||||
# The engine comes from libcstcore.a, exactly as on the desktop: no TUI, no
|
||||
# HTTP server (src/webui_stub.c stands in), no bundled busybox inside an APK.
|
||||
"$QT_ANDROID/bin/qt-cmake" -S "$ROOT" -B "$BUILD" -G Ninja \
|
||||
-DQT_HOST_PATH="$QT_HOST" \
|
||||
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
|
||||
-DANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" -DANDROID_NDK="$NDK" \
|
||||
-DGLESv2_INCLUDE_DIR="$NDK_SYSROOT_INC" -DEGL_INCLUDE_DIR="$NDK_SYSROOT_INC" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$PREFIX" \
|
||||
-DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="-L$PREFIX/lib" \
|
||||
-DCMAKE_MODULE_LINKER_FLAGS="-L$PREFIX/lib" \
|
||||
-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=ON \
|
||||
-DCST_BUNDLE_BUSYBOX=OFF
|
||||
cmake --build "$BUILD" --target apk -j"$JOBS"
|
||||
|
||||
# AGP names the file after the variant and signing state
|
||||
# (android-build-release.apk vs ...-unsigned.apk), so take the newest one.
|
||||
APK="$(find "$BUILD/app/android-build/build/outputs/apk" "$BUILD" -name '*.apk' \
|
||||
-printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
|
||||
[[ -n "$APK" && -f "$APK" ]] || { echo "no APK produced" >&2; exit 1; }
|
||||
echo
|
||||
echo "APK: $APK ($(du -h "$APK" | cut -f1))"
|
||||
[[ "$DO_INSTALL" == 1 ]] || exit 0
|
||||
|
||||
ADB="${ADB:-adb}"
|
||||
command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; }
|
||||
"$ADB" install -r "$APK"
|
||||
echo "installed: $("$ADB" shell pm list packages | grep cstagent)"
|
||||
[[ "$DO_LOGS" == 1 ]] || exit 0
|
||||
echo "--- am start (Ctrl-C to stop; logcat follows)"
|
||||
"$ADB" shell monkey -p xyz.whcself.cstagent -c android.intent.category.LAUNCHER 1 >/dev/null
|
||||
sleep 2
|
||||
exec "$ADB" logcat -s cst-agent:V Qt:V AndroidRuntime:E libc:V DEBUG:V
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static deps for Android into third_party/prefix-android-ABI: mbedTLS and
|
||||
# libcurl (HTTP(S) only, mbedTLS), the same pair the Linux release uses --
|
||||
# a phone has no system libcurl and bionic's CA store is /system/etc/security,
|
||||
# so libcurl is built here rather than taken from the platform.
|
||||
#
|
||||
# scripts/build_third_party_android.sh arm64-v8a|x86_64 [all|mbedtls|curl]
|
||||
#
|
||||
# Needs the NDK in ANDROID_NDK_HOME (a directory with
|
||||
# build/cmake/android.toolchain.cmake). On NixOS the packaged one is:
|
||||
# export ANDROID_NDK_HOME=$(
|
||||
# NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link --print-out-paths \
|
||||
# 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
|
||||
# Host tools: cmake ninja make.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ABI="${1:?usage: $0 arm64-v8a|x86_64 [all|mbedtls|curl]}"
|
||||
TP="$ROOT/third_party"
|
||||
PREFIX="$TP/prefix-android-$ABI"
|
||||
SRC="$TP/src"
|
||||
WORK="$TP/build-android-$ABI"
|
||||
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||
|
||||
MBEDTLS_VER=3.6.7
|
||||
MBEDTLS_SHA=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6
|
||||
CURL_VER=8.22.0
|
||||
CURL_SHA=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
|
||||
|
||||
# Android 7.0: what bionic gives the engine (threads, exec, /system CA store).
|
||||
API_LEVEL="${API_LEVEL:-24}"
|
||||
# Per-app CAs land in the user store; the system bundle is the readable one.
|
||||
CA_PATH=/system/etc/security/cacerts
|
||||
|
||||
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
||||
if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then
|
||||
echo "set ANDROID_NDK_HOME to an NDK (see the header of this script)" >&2
|
||||
exit 1
|
||||
fi
|
||||
TOOLCHAIN="$NDK/build/cmake/android.toolchain.cmake"
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
|
||||
need cmake
|
||||
need ninja
|
||||
mkdir -p "$PREFIX" "$SRC" "$WORK"
|
||||
|
||||
fetch() { # file sha url...
|
||||
local file="$1" sha="$2" u
|
||||
shift 2
|
||||
if [[ -f "$SRC/$file" ]] && echo "$sha $SRC/$file" | sha256sum -c --quiet - 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
for u in "$@"; do
|
||||
echo " try $u"
|
||||
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 600 -o "$SRC/$file.part" "$u" &&
|
||||
echo "$sha $SRC/$file.part" | sha256sum -c --quiet -; then
|
||||
mv "$SRC/$file.part" "$SRC/$file"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
rm -f "$SRC/$file.part"
|
||||
echo "download failed: $file" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
unpack() { # file dir
|
||||
rm -rf "${WORK:?}/$2"
|
||||
tar -C "$WORK" -xf "$SRC/$1"
|
||||
}
|
||||
|
||||
gh() { # path -> GitHub URL candidates (CN proxies first)
|
||||
printf '%s\n' "https://ghfast.top/https://github.com/$1" \
|
||||
"https://ghproxy.net/https://github.com/$1" "https://github.com/$1"
|
||||
}
|
||||
|
||||
cmake_android() { # src build args...
|
||||
local s="$1" b="$2"
|
||||
shift 2
|
||||
cmake -S "$s" -B "$b" -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
|
||||
-DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \
|
||||
-DANDROID_NDK="$NDK" \
|
||||
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \
|
||||
-DCMAKE_PREFIX_PATH="$PREFIX" -DCMAKE_INSTALL_LIBDIR=lib \
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=OFF \
|
||||
"$@"
|
||||
cmake --build "$b" -j"$JOBS"
|
||||
cmake --install "$b"
|
||||
}
|
||||
|
||||
build_mbedtls() {
|
||||
local f="mbedtls-$MBEDTLS_VER.tar.bz2"
|
||||
mapfile -t urls < <(gh "Mbed-TLS/mbedtls/releases/download/mbedtls-$MBEDTLS_VER/$f")
|
||||
fetch "$f" "$MBEDTLS_SHA" "${urls[@]}"
|
||||
unpack "$f" "mbedtls-$MBEDTLS_VER"
|
||||
# 3.6.x keeps its own entropy source on Android, no /dev/urandom probing.
|
||||
cmake_android "$WORK/mbedtls-$MBEDTLS_VER" "$WORK/mbedtls-$MBEDTLS_VER/build" \
|
||||
-DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF -DGEN_FILES=OFF \
|
||||
-DUSE_STATIC_MBEDTLS_LIBRARY=ON -DUSE_SHARED_MBEDTLS_LIBRARY=OFF \
|
||||
-DMBEDTLS_FATAL_WARNINGS=OFF
|
||||
echo "mbedTLS $MBEDTLS_VER -> $PREFIX"
|
||||
}
|
||||
|
||||
build_curl() {
|
||||
local f="curl-$CURL_VER.tar.xz"
|
||||
mapfile -t urls < <(gh "curl/curl/releases/download/curl-${CURL_VER//./_}/$f")
|
||||
fetch "$f" "$CURL_SHA" "https://curl.se/download/$f" "${urls[@]}"
|
||||
unpack "$f" "curl-$CURL_VER"
|
||||
cmake_android "$WORK/curl-$CURL_VER" "$WORK/curl-$CURL_VER/build" \
|
||||
-DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON \
|
||||
-DCURL_USE_MBEDTLS=ON -DCURL_USE_OPENSSL=OFF \
|
||||
-DMBEDTLS_INCLUDE_DIR="$PREFIX/include" \
|
||||
-DMBEDTLS_LIBRARY="$PREFIX/lib/libmbedtls.a" \
|
||||
-DMBEDX509_LIBRARY="$PREFIX/lib/libmbedx509.a" \
|
||||
-DMBEDCRYPTO_LIBRARY="$PREFIX/lib/libmbedcrypto.a" \
|
||||
-DCURL_CA_BUNDLE=none -DCURL_CA_PATH="$CA_PATH" \
|
||||
-DHTTP_ONLY=ON -DCURL_DISABLE_LDAP=ON -DCURL_USE_LIBPSL=OFF \
|
||||
-DCURL_USE_LIBSSH2=OFF -DCURL_USE_LIBSSH=OFF -DUSE_LIBIDN2=OFF \
|
||||
-DUSE_NGHTTP2=OFF -DCURL_ZLIB=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
|
||||
-DCURL_USE_GSSAPI=OFF -DENABLE_THREADED_RESOLVER=ON \
|
||||
-DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF \
|
||||
-DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DENABLE_CURL_MANUAL=OFF
|
||||
echo "curl $CURL_VER -> $PREFIX"
|
||||
}
|
||||
|
||||
case "${2:-all}" in
|
||||
mbedtls) build_mbedtls ;;
|
||||
curl) build_curl ;;
|
||||
all)
|
||||
build_mbedtls
|
||||
build_curl
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 ABI [all|mbedtls|curl]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "DONE android-$ABI"
|
||||
@@ -0,0 +1,25 @@
|
||||
// Gradle init script for the Android APK build, installed as
|
||||
// ~/.gradle/init.d/cst-android.gradle by scripts/build_apk_android.sh.
|
||||
//
|
||||
// Qt's generated gradle project only sets "lintOptions { abortOnError false }",
|
||||
// so the release build still runs lintVitalAnalyzeRelease -- and that task
|
||||
// resolves its own tool jars (manifest-merger, lint-typedef-remover, ...)
|
||||
// straight from dl.google.com, bypassing every repository the project (or an
|
||||
// init script) configures. On a slow or filtered route that fails the build
|
||||
// after all the real work is done, and nothing here wants lint output anyway.
|
||||
gradle.afterProject { project ->
|
||||
def android = project.extensions.findByName('android')
|
||||
if (android != null) {
|
||||
// AGP keeps both spellings around: lintOptions (deprecated) and lint.
|
||||
try { android.lintOptions.checkReleaseBuilds = false } catch (Exception ignored) { }
|
||||
try { android.lint.checkReleaseBuilds = false } catch (Exception ignored) { }
|
||||
|
||||
// NOTE: no signing config here on purpose. androiddeployqt copies the
|
||||
// *unsigned* gradle output and signs it itself (--sign); a signed
|
||||
// gradle APK makes it fail with "Could not copy unsigned APK". The
|
||||
// keystore is wired up from app/CMakeLists.txt through
|
||||
// QT_ANDROID_SIGN_APK + the QT_ANDROID_KEYSTORE_* environment.
|
||||
}
|
||||
// Belt and braces: switch the tasks themselves off.
|
||||
project.tasks.matching { it.name.startsWith('lintVital') }.configureEach { it.enabled = false }
|
||||
}
|
||||
@@ -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
|
||||
curl
|
||||
nsis
|
||||
# The Windows GUI package unpacks MSYS2's prebuilt Qt6 (.pkg.tar.zst) --
|
||||
# zstd is what tar needs for that (scripts/package_windows_app.sh).
|
||||
zstd
|
||||
];
|
||||
buildInputs = [ win.windows.pthreads ];
|
||||
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
|
||||
|
||||
@@ -1181,6 +1181,8 @@ static void exec_end(turn_t *t, pcall_t *pc, exec_t *x) {
|
||||
if (t->hooks && t->hooks->on_tool_result)
|
||||
t->hooks->on_tool_result(c, pc->output, pc->output_len, pc->exit_code,
|
||||
t->hooks->userdata);
|
||||
if (t->hooks && t->hooks->on_tool_images && pc->nattach > 0)
|
||||
t->hooks->on_tool_images(c, pc->attach, pc->nattach, t->hooks->userdata);
|
||||
if (pc->status != PC_DENIED) {
|
||||
char d[96];
|
||||
snprintf(d, sizeof(d), "%sexit=%d bytes=%zu",
|
||||
@@ -1850,6 +1852,9 @@ static void fill_request(cst_agent_t *a, cst_llm_request_t *req,
|
||||
req->session_path = cst_session_path(a->session);
|
||||
req->cancel_flag = &a->cancel;
|
||||
req->tool_out_cap = a->cfg.tool_out_cap;
|
||||
req->temperature = a->cfg.temperature;
|
||||
req->top_p = a->cfg.top_p;
|
||||
req->max_tokens = a->cfg.max_tokens;
|
||||
if (cst_protocol_is_native(proto)) {
|
||||
req->enable_tools = 1;
|
||||
req->tool_cfg = &a->cfg;
|
||||
@@ -2042,16 +2047,8 @@ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) {
|
||||
out[0] = 0;
|
||||
if (!a)
|
||||
return;
|
||||
const char *p = a->cfg.session_path;
|
||||
const char *sl = strrchr(p, '/');
|
||||
const char *base = sl ? sl + 1 : p;
|
||||
size_t n = strlen(base);
|
||||
if (n >= out_sz)
|
||||
n = out_sz - 1;
|
||||
memcpy(out, base, n);
|
||||
out[n] = 0;
|
||||
if (n > 6 && strcmp(out + n - 6, ".jsonl") == 0)
|
||||
out[n - 6] = 0;
|
||||
if (cst_agent_store_id_of_path(a->cfg.session_path, out, out_sz) != 0)
|
||||
out[0] = 0;
|
||||
}
|
||||
|
||||
const char *cst_agent_session_path(const cst_agent_t *a) {
|
||||
@@ -2170,6 +2167,20 @@ int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
|
||||
err, err_sz);
|
||||
}
|
||||
|
||||
/* The stored name of a pending attachment: the window serves the file from the
|
||||
* session's attachment directory while the message is still being written. */
|
||||
const char *cst_agent_pending_rel(const cst_agent_t *a, int i) {
|
||||
if (!a || i < 0 || i >= a->npending)
|
||||
return NULL;
|
||||
return a->pending[i].rel;
|
||||
}
|
||||
|
||||
int cst_agent_pending_is_image(const cst_agent_t *a, int i) {
|
||||
if (!a || i < 0 || i >= a->npending)
|
||||
return 0;
|
||||
return cst_attach_is_image(&a->pending[i]);
|
||||
}
|
||||
|
||||
const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; }
|
||||
|
||||
const char *cst_agent_notes(const cst_agent_t *a) {
|
||||
@@ -2538,6 +2549,24 @@ cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
|
||||
a->cfg.max_steps = CST_DEFAULT_MAX_STEPS;
|
||||
if (a->cfg.tool_out_cap == 0)
|
||||
a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
|
||||
/* The generation parameters the user saved for this provider (Settings ->
|
||||
* Advanced in the app, "params" in settings.json). They are read here so
|
||||
* that the CLI and the TUI send what the GUI was set to; a caller that
|
||||
* named one explicitly wins. */
|
||||
{
|
||||
cst_settings_t st;
|
||||
if (cst_settings_load(&st) == 0) {
|
||||
const cst_gen_params_t *g = cst_settings_params(&st, a->cfg.provider);
|
||||
if (g) {
|
||||
if (a->cfg.temperature <= 0)
|
||||
a->cfg.temperature = g->temperature;
|
||||
if (a->cfg.top_p <= 0)
|
||||
a->cfg.top_p = g->top_p;
|
||||
if (a->cfg.max_tokens <= 0)
|
||||
a->cfg.max_tokens = g->max_tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
|
||||
: "session.jsonl");
|
||||
if (!a->session) {
|
||||
|
||||
@@ -23,6 +23,29 @@
|
||||
#define OS_F_OK F_OK
|
||||
#endif
|
||||
|
||||
/* --- the store's shape ----------------------------------------------------
|
||||
* A session owns a directory: everything the session keeps is inside it, so
|
||||
* "delete this conversation" is one rmdir and nothing has to be found by
|
||||
* guesswork (a phone's app data directory has no user to tidy it by hand).
|
||||
*
|
||||
* <workspace>/.agent/sessions/
|
||||
* <uuid>/session.jsonl the transcript
|
||||
* <uuid>/title the window title, one line
|
||||
* <uuid>/attachments/ what the messages carry: images, delivered
|
||||
* files (<id>.png, art-<id>.md, ...)
|
||||
* <uuid>/sub-1.jsonl a sub-agent's own transcript
|
||||
* CURRENT the id the next run opens
|
||||
*
|
||||
* Sessions written before this layout are flat, with one shared attachment
|
||||
* directory, and keep working: every reader here takes either shape and
|
||||
* cst_agent_store_migrate() (--migrate-store) moves them in.
|
||||
*
|
||||
* <uuid>.jsonl
|
||||
* <uuid>.title
|
||||
* attachments/<uuid>/... (shared by every session)
|
||||
* <uuid>/sub-1.jsonl (already per session)
|
||||
*/
|
||||
|
||||
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) {
|
||||
if (!workspace || !out || out_sz < 16)
|
||||
return -1;
|
||||
@@ -53,24 +76,140 @@ int cst_agent_store_ensure(const char *workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void make_id(char *out, size_t n) {
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
os_localtime(&now, &tm);
|
||||
static unsigned seq;
|
||||
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++;
|
||||
snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900,
|
||||
tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec,
|
||||
r & 0xffff);
|
||||
/* An id is a directory name: anything that could leave the store, and the two
|
||||
* names the store itself uses, is not one. */
|
||||
static int id_ok(const char *id) {
|
||||
if (!id || !id[0] || id[0] == '.')
|
||||
return 0;
|
||||
for (const char *p = id; *p; p++) {
|
||||
char c = *p;
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
|
||||
return 0;
|
||||
}
|
||||
if (strstr(id, ".."))
|
||||
return 0;
|
||||
return strcmp(id, "CURRENT") != 0 && strcmp(id, "attachments") != 0;
|
||||
}
|
||||
|
||||
/* 16 random bytes, whatever the platform offers; the clock and the pid are
|
||||
* the fallback (a collision only costs a reopen, not data). */
|
||||
static void random_bytes(unsigned char *out, size_t n) {
|
||||
FILE *f = fopen("/dev/urandom", "rb");
|
||||
if (f) {
|
||||
size_t got = fread(out, 1, n, f);
|
||||
fclose(f);
|
||||
if (got == n)
|
||||
return;
|
||||
}
|
||||
static unsigned seq;
|
||||
unsigned long long x = (unsigned long long)time(NULL) ^
|
||||
((unsigned long long)os_getpid() << 20) ^
|
||||
((unsigned long long)++seq << 8);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
x = x * 6364136223846793005ULL + 1442695040888963407ULL;
|
||||
out[i] = (unsigned char)(x >> 33);
|
||||
}
|
||||
}
|
||||
|
||||
/* A uuid v4: unique across machines and time, which a timestamp is not (two
|
||||
* phones, one synced workspace, and the ids have to stay apart). */
|
||||
int cst_agent_store_new_id(char *id_out, size_t id_sz) {
|
||||
if (!id_out || id_sz < 24)
|
||||
if (!id_out || id_sz < 37)
|
||||
return -1;
|
||||
make_id(id_out, id_sz);
|
||||
unsigned char b[16];
|
||||
random_bytes(b, sizeof(b));
|
||||
b[6] = (unsigned char)((b[6] & 0x0f) | 0x40); /* version 4 */
|
||||
b[8] = (unsigned char)((b[8] & 0x3f) | 0x80); /* variant 1 */
|
||||
snprintf(id_out, id_sz,
|
||||
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-"
|
||||
"%02x%02x%02x%02x%02x%02x",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
|
||||
b[11], b[12], b[13], b[14], b[15]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* <sessions>/<id>, the session's own directory (not made here). */
|
||||
static int dir_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (!workspace || !id_ok(id))
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, out_sz, dir, id);
|
||||
}
|
||||
|
||||
/* <sessions>/<id>.jsonl -- the shape before per-session directories. */
|
||||
static int flat_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (!workspace || !id_ok(id))
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX], leaf[192];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
}
|
||||
|
||||
/* The transcript of an id in the per-session layout. */
|
||||
static int nested_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, out_sz, d, "session.jsonl");
|
||||
}
|
||||
|
||||
int cst_agent_store_id_of_path(const char *session_path, char *out,
|
||||
size_t out_sz) {
|
||||
if (!session_path || !session_path[0] || !out || out_sz < 2)
|
||||
return -1;
|
||||
const char *base = session_path;
|
||||
for (const char *p = session_path; *p; p++)
|
||||
if (*p == '/' || *p == '\\')
|
||||
base = p + 1;
|
||||
size_t n = strlen(base);
|
||||
if (n > 6 && strcmp(base + n - 6, ".jsonl") == 0) {
|
||||
size_t stem = n - 6;
|
||||
if (stem == 7 && strncmp(base, "session", 7) == 0) {
|
||||
/* <id>/session.jsonl: the id is the directory the file sits in. */
|
||||
if (base == session_path)
|
||||
return -1; /* a bare "session.jsonl" has no session to name */
|
||||
const char *end = base - 1; /* the separator before the leaf */
|
||||
const char *start = end;
|
||||
while (start > session_path && start[-1] != '/' && start[-1] != '\\')
|
||||
start--;
|
||||
size_t dn = (size_t)(end - start);
|
||||
if (!dn || dn >= out_sz)
|
||||
return -1;
|
||||
memcpy(out, start, dn);
|
||||
out[dn] = 0;
|
||||
return 0;
|
||||
}
|
||||
n = stem;
|
||||
}
|
||||
if (!n || n >= out_sz)
|
||||
return -1;
|
||||
memcpy(out, base, n);
|
||||
out[n] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Which shape this id has. 1 = its own directory, 0 = flat, -1 = neither
|
||||
* (and not a usable id). */
|
||||
static int shape_of_id(const char *workspace, const char *id) {
|
||||
char p[OS_PATH_MAX];
|
||||
os_stat_t st;
|
||||
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 &&
|
||||
os_lstat(p, &st) == 0 && !st.is_dir)
|
||||
return 1;
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 &&
|
||||
!st.is_dir)
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
char *out, size_t out_sz) {
|
||||
if (!workspace || !id || !out)
|
||||
@@ -80,12 +219,19 @@ int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
snprintf(out, out_sz, "%s", id);
|
||||
return 0;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
char leaf[160];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
int shape = shape_of_id(workspace, id);
|
||||
if (shape == 0)
|
||||
return flat_for_id(workspace, id, out, out_sz);
|
||||
if (nested_for_id(workspace, id, out, out_sz) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
if (shape < 0) {
|
||||
/* A new session: its directory is made here, so whoever opens the
|
||||
* transcript next has a place to put it (and its attachments). */
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) == 0)
|
||||
os_mkdir_p(d, 0700);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_store_set_current(const char *workspace, const char *id) {
|
||||
@@ -164,24 +310,42 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
|
||||
return 0;
|
||||
typedef struct {
|
||||
char id[128];
|
||||
time_t mtime;
|
||||
double mtime;
|
||||
} ent_t;
|
||||
ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
|
||||
int n = 0, cap = max_ids * 4;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
if (n >= cap)
|
||||
break;
|
||||
if (de->is_dir) {
|
||||
/* a per-session directory: only one that holds a transcript counts */
|
||||
if (!id_ok(de->name))
|
||||
continue;
|
||||
char p[OS_PATH_MAX];
|
||||
if (nested_for_id(workspace, de->name, p, sizeof(p)) != 0)
|
||||
continue;
|
||||
os_stat_t st;
|
||||
if (os_lstat(p, &st) != 0 || st.is_dir)
|
||||
continue;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%s", de->name);
|
||||
ents[n].mtime = st.mtime;
|
||||
n++;
|
||||
continue;
|
||||
}
|
||||
size_t len = strlen(de->name);
|
||||
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
|
||||
continue;
|
||||
if (n >= cap)
|
||||
break;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6),
|
||||
de->name);
|
||||
char id[128];
|
||||
snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
|
||||
if (!id_ok(id))
|
||||
continue;
|
||||
char full[OS_PATH_MAX];
|
||||
if (os_path_join(full, sizeof(full), dir, de->name) != 0)
|
||||
continue;
|
||||
struct stat st;
|
||||
ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0;
|
||||
os_stat_t st;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%s", id);
|
||||
ents[n].mtime = os_lstat(full, &st) == 0 ? st.mtime : 0;
|
||||
n++;
|
||||
}
|
||||
os_dir_close(d);
|
||||
@@ -201,20 +365,28 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
|
||||
return out_n;
|
||||
}
|
||||
|
||||
static int side_path(const char *workspace, const char *id, const char *ext,
|
||||
char *out, size_t out_sz) {
|
||||
char dir[OS_PATH_MAX], leaf[192];
|
||||
if (!id || !id[0] || strchr(id, '/') || strchr(id, '\\') ||
|
||||
cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
/* The title of a session: inside its directory in the new layout, a <id>.title
|
||||
* beside the transcript in the old one. */
|
||||
static int title_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (shape_of_id(workspace, id) == 0) {
|
||||
char dir[OS_PATH_MAX];
|
||||
char leaf[192];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s.title", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
}
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s%s", id, ext);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
return os_path_join(out, out_sz, d, "title");
|
||||
}
|
||||
|
||||
int cst_agent_store_title_set(const char *workspace, const char *id,
|
||||
const char *title) {
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".title", p, sizeof(p)) != 0)
|
||||
if (title_for_id(workspace, id, p, sizeof(p)) != 0)
|
||||
return -1;
|
||||
if (!title || !title[0])
|
||||
return remove(p) == 0 || errno == ENOENT ? 0 : -1;
|
||||
@@ -247,14 +419,18 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
memset(info, 0, sizeof(*info));
|
||||
snprintf(info->id, sizeof(info->id), "%s", id ? id : "");
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0)
|
||||
if (shape_of_id(workspace, id) == 0) {
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) != 0)
|
||||
return -1;
|
||||
} else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) {
|
||||
return -1;
|
||||
struct stat st;
|
||||
if (stat(p, &st) != 0)
|
||||
}
|
||||
os_stat_t st;
|
||||
if (os_lstat(p, &st) != 0)
|
||||
return -1;
|
||||
info->mtime = (long long)st.st_mtime;
|
||||
info->mtime = (long long)st.mtime;
|
||||
char tp[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) {
|
||||
if (title_for_id(workspace, id, tp, sizeof(tp)) == 0) {
|
||||
FILE *tf = fopen(tp, "rb");
|
||||
if (tf) {
|
||||
if (fgets(info->title, sizeof(info->title), tf)) {
|
||||
@@ -268,72 +444,273 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
FILE *fp = fopen(p, "rb");
|
||||
if (!fp)
|
||||
return 0;
|
||||
size_t cap = 1 << 16;
|
||||
char *line = malloc(cap);
|
||||
int have_first = 0;
|
||||
while (line && fgets(line, (int)cap, fp)) {
|
||||
size_t n = strlen(line);
|
||||
if (n == cap - 1 && line[n - 1] != '\n') {
|
||||
/* very long line: skip the rest, it is not the first user line */
|
||||
int ch;
|
||||
while ((ch = fgetc(fp)) != EOF && ch != '\n')
|
||||
;
|
||||
info->messages++;
|
||||
continue;
|
||||
}
|
||||
if (n <= 1)
|
||||
continue;
|
||||
info->messages++;
|
||||
if (have_first)
|
||||
continue;
|
||||
cJSON *o = cJSON_Parse(line);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||
cJSON_IsString(c) && c->valuestring[0]) {
|
||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||
have_first = 1;
|
||||
}
|
||||
cJSON_Delete(o);
|
||||
/* Reading a transcript must stay cheap: the session list asks for this for
|
||||
* every stored session, on the GUI thread, every time the store changes.
|
||||
* So a line is only ever *parsed* while the first user message is still
|
||||
* missing and only within the first kFirstUserBytes; everything else is
|
||||
* counted by looking for newlines, which costs a memchr per block instead
|
||||
* of a cJSON tree per line. */
|
||||
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
|
||||
char *buf = malloc(kBlock);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
free(line);
|
||||
size_t have = 0; /* bytes in buf that belong to the current line */
|
||||
size_t seen = 0; /* bytes already consumed by finished lines */
|
||||
int slice = 0; /* the current line did not fit: its tail is dropped */
|
||||
int want_first = 1; /* still looking for the first user message */
|
||||
for (;;) {
|
||||
size_t got = fread(buf + have, 1, kBlock - have, fp);
|
||||
size_t total = have + got;
|
||||
size_t at = 0;
|
||||
for (;;) {
|
||||
char *nl = memchr(buf + at, '\n', total - at);
|
||||
if (!nl)
|
||||
break;
|
||||
const size_t len = (size_t)(nl - (buf + at));
|
||||
/* 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++;
|
||||
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
|
||||
*nl = 0; /* the parse reads a C string; the newline is ours */
|
||||
cJSON *o = cJSON_Parse(buf + at);
|
||||
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
||||
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
||||
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
||||
cJSON_IsString(c) && c->valuestring[0]) {
|
||||
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
||||
want_first = 0;
|
||||
}
|
||||
cJSON_Delete(o);
|
||||
}
|
||||
seen += len + 1;
|
||||
at += len + 1;
|
||||
slice = 0;
|
||||
}
|
||||
have = total - at;
|
||||
if (got == 0)
|
||||
break; /* EOF */
|
||||
if (have == kBlock) {
|
||||
/* a line longer than one block: count it, drop its tail, keep going */
|
||||
info->messages++;
|
||||
seen += have;
|
||||
have = 0;
|
||||
slice = 1;
|
||||
} else if (have > 0) {
|
||||
memmove(buf, buf + at, have);
|
||||
}
|
||||
}
|
||||
if (have > 0 && !slice)
|
||||
info->messages++; /* the last line, without a newline */
|
||||
free(buf);
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_store_delete(const char *workspace, const char *id) {
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0)
|
||||
/* Remove a tree (a session's directory: its transcript, its attachments and
|
||||
* its sub-agent files). Links are removed, never followed. */
|
||||
static int rm_tree(const char *path) {
|
||||
os_stat_t st;
|
||||
if (os_lstat(path, &st) != 0)
|
||||
return -1;
|
||||
if (remove(p) != 0)
|
||||
return -1;
|
||||
if (side_path(workspace, id, ".title", p, sizeof(p)) == 0)
|
||||
remove(p);
|
||||
/* sub-agent transcripts live in <id>/ */
|
||||
if (side_path(workspace, id, "", p, sizeof(p)) == 0) {
|
||||
os_dir_t *d = os_dir_open(p);
|
||||
if (d) {
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
char f[OS_PATH_MAX];
|
||||
if (strncmp(de->name, "sub-", 4) == 0 &&
|
||||
os_path_join(f, sizeof(f), p, de->name) == 0)
|
||||
remove(f);
|
||||
}
|
||||
os_dir_close(d);
|
||||
rmdir(p);
|
||||
if (!st.is_dir)
|
||||
return remove(path) == 0 ? 0 : -1;
|
||||
os_dir_t *d = os_dir_open(path);
|
||||
if (d) {
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
char f[OS_PATH_MAX];
|
||||
if (os_path_join(f, sizeof(f), path, de->name) == 0)
|
||||
rm_tree(f);
|
||||
}
|
||||
os_dir_close(d);
|
||||
}
|
||||
return rmdir(path) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int cst_agent_store_delete(const char *workspace, const char *id) {
|
||||
if (!id_ok(id))
|
||||
return -1;
|
||||
char p[OS_PATH_MAX];
|
||||
int found = 0;
|
||||
/* both shapes, whichever this session has (or had) */
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
|
||||
found = 1;
|
||||
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
|
||||
found = 1;
|
||||
if (!found)
|
||||
return -1;
|
||||
if (title_for_id(workspace, id, p, sizeof(p)) == 0)
|
||||
remove(p);
|
||||
/* the session's own directory: attachments, sub-agent transcripts */
|
||||
if (dir_for_id(workspace, id, p, sizeof(p)) == 0)
|
||||
rm_tree(p);
|
||||
/* and the shared attachment directory of the old layout */
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
|
||||
os_path_join(p, sizeof(p), dir, "attachments") == 0) {
|
||||
char one[OS_PATH_MAX];
|
||||
if (os_path_join(one, sizeof(one), p, id) == 0)
|
||||
rm_tree(one);
|
||||
}
|
||||
char cur[128];
|
||||
if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 &&
|
||||
strcmp(cur, id) == 0) {
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
|
||||
os_path_join(p, sizeof(p), dir, "CURRENT") == 0)
|
||||
remove(p);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Move a directory: rename if the OS likes it, otherwise copy file by file
|
||||
* (a synced workspace can be odd about renames). */
|
||||
static int move_file(const char *from, const char *to) {
|
||||
if (os_rename_replace(from, to) == 0)
|
||||
return 0;
|
||||
FILE *in = fopen(from, "rb");
|
||||
if (!in)
|
||||
return -1;
|
||||
FILE *out = fopen(to, "wb");
|
||||
if (!out) {
|
||||
fclose(in);
|
||||
return -1;
|
||||
}
|
||||
char buf[16384];
|
||||
size_t n;
|
||||
int rc = 0;
|
||||
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
||||
if (fwrite(buf, 1, n, out) != n) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
if (fclose(out) != 0)
|
||||
rc = -1;
|
||||
if (rc == 0)
|
||||
remove(from);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int move_tree(const char *from, const char *to) {
|
||||
os_stat_t st;
|
||||
if (os_lstat(from, &st) != 0)
|
||||
return -1;
|
||||
if (!st.is_dir)
|
||||
return move_file(from, to);
|
||||
if (os_mkdir_p(to, 0700) != 0)
|
||||
return -1;
|
||||
os_dir_t *d = os_dir_open(from);
|
||||
if (!d)
|
||||
return -1;
|
||||
int rc = 0;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
char f[OS_PATH_MAX], t[OS_PATH_MAX];
|
||||
if (os_path_join(f, sizeof(f), from, de->name) != 0 ||
|
||||
os_path_join(t, sizeof(t), to, de->name) != 0 || move_tree(f, t) != 0)
|
||||
rc = -1;
|
||||
}
|
||||
os_dir_close(d);
|
||||
if (rc == 0)
|
||||
rmdir(from);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_agent_store_migrate(const char *workspace, int *moved_out,
|
||||
int *failed_out, char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (moved_out)
|
||||
*moved_out = 0;
|
||||
if (failed_out)
|
||||
*failed_out = 0;
|
||||
if (!workspace || !workspace[0]) {
|
||||
snprintf(err, err_sz, "no workspace");
|
||||
return -1;
|
||||
}
|
||||
if (cst_agent_store_ensure(workspace) != 0) {
|
||||
snprintf(err, err_sz, "cannot use the store under %s", workspace);
|
||||
return -1;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) {
|
||||
snprintf(err, err_sz, "no sessions directory");
|
||||
return -1;
|
||||
}
|
||||
os_dir_t *d = os_dir_open(dir);
|
||||
if (!d) {
|
||||
snprintf(err, err_sz, "cannot read %s", dir);
|
||||
return -1;
|
||||
}
|
||||
int moved = 0, failed = 0;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
if (de->is_dir)
|
||||
continue;
|
||||
size_t len = strlen(de->name);
|
||||
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
|
||||
continue;
|
||||
char id[128];
|
||||
snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
|
||||
if (!id_ok(id))
|
||||
continue;
|
||||
char nested_dir[OS_PATH_MAX], nested[OS_PATH_MAX], flat[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, nested_dir, sizeof(nested_dir)) != 0 ||
|
||||
nested_for_id(workspace, id, nested, sizeof(nested)) != 0 ||
|
||||
flat_for_id(workspace, id, flat, sizeof(flat)) != 0) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
/* already moved (a second run, or a session that never was flat) */
|
||||
os_stat_t st;
|
||||
if (os_lstat(nested, &st) == 0)
|
||||
continue;
|
||||
if (os_mkdir_p(nested_dir, 0700) != 0 || move_file(flat, nested) != 0) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
/* the title and the attachments that belong to this session */
|
||||
char sdir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, sdir, sizeof(sdir)) == 0) {
|
||||
char t_from[OS_PATH_MAX], t_to[OS_PATH_MAX];
|
||||
char leaf[192];
|
||||
snprintf(leaf, sizeof(leaf), "%s.title", id);
|
||||
if (os_path_join(t_from, sizeof(t_from), sdir, leaf) == 0 &&
|
||||
os_path_join(t_to, sizeof(t_to), nested_dir, "title") == 0)
|
||||
move_file(t_from, t_to); /* no title is not a failure */
|
||||
}
|
||||
char att_dir[OS_PATH_MAX], att_one[OS_PATH_MAX];
|
||||
char a_from[OS_PATH_MAX], a_to[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, att_dir, sizeof(att_dir)) == 0 &&
|
||||
os_path_join(att_one, sizeof(att_one), att_dir, "attachments") == 0 &&
|
||||
os_path_join(a_from, sizeof(a_from), att_one, id) == 0 &&
|
||||
os_path_join(a_to, sizeof(a_to), nested_dir, "attachments") == 0) {
|
||||
os_stat_t ast;
|
||||
if (os_lstat(a_from, &ast) == 0 && ast.is_dir)
|
||||
move_tree(a_from, a_to);
|
||||
}
|
||||
moved++;
|
||||
}
|
||||
os_dir_close(d);
|
||||
/* the shared directory itself is empty now (and has no other purpose) */
|
||||
char att[OS_PATH_MAX];
|
||||
if (os_path_join(att, sizeof(att), dir, "attachments") == 0)
|
||||
rmdir(att); /* fails while another session still uses it: fine */
|
||||
if (moved_out)
|
||||
*moved_out = moved;
|
||||
if (failed_out)
|
||||
*failed_out = failed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -260,8 +260,13 @@ int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
||||
|
||||
/* --- storage ------------------------------------------------------------- */
|
||||
|
||||
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
|
||||
* workspace keeps the two together. */
|
||||
/* Where a session's payloads live, beside its JSONL so moving the workspace
|
||||
* keeps the two together:
|
||||
* <uuid>/session.jsonl -> <uuid>/attachments/ (the store's shape
|
||||
* since v0.9.5)
|
||||
* <id>.jsonl -> <dir>/attachments/<id>/ (older sessions; the
|
||||
* migration moves them)
|
||||
* The directory is made here: every store() writes into it next. */
|
||||
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
||||
if (!session_path || !session_path[0])
|
||||
return -1;
|
||||
@@ -289,8 +294,13 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
||||
char *dot = strstr(leaf, ".jsonl");
|
||||
if (dot)
|
||||
*dot = 0;
|
||||
/* A session that owns its directory keeps its payloads directly inside it;
|
||||
* only the flat layout needs the shared directory and its stem. */
|
||||
char rel[320];
|
||||
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
||||
if (strcmp(leaf, "session") == 0)
|
||||
snprintf(rel, sizeof(rel), "attachments");
|
||||
else
|
||||
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
||||
char full[OS_PATH_MAX];
|
||||
if (os_path_join(full, sizeof(full), dir, rel) != 0)
|
||||
return -1;
|
||||
@@ -344,16 +354,18 @@ static char *read_whole(const char *path, size_t *out_len, size_t max) {
|
||||
}
|
||||
|
||||
/* FNV over the bytes, the name and the clock: short, stable, unique enough
|
||||
* inside one session. */
|
||||
static void make_id(const char *name, const char *data, size_t n, char *out,
|
||||
size_t out_sz) {
|
||||
* inside one session. The prefix says what the name is for: "img" for what a
|
||||
* model is shown, "art" for what a person is handed. */
|
||||
static void make_id(const char *prefix, const char *name, const char *data,
|
||||
size_t n, char *out, size_t out_sz) {
|
||||
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
|
||||
h = cst_fnv1a64(h, name, strlen(name));
|
||||
h = cst_fnv1a64(h, &n, sizeof(n));
|
||||
double now = os_now_ms();
|
||||
h = cst_fnv1a64(h, &now, sizeof(now));
|
||||
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
|
||||
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
|
||||
snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
|
||||
(unsigned)(h & 0xFFFFFFFFu));
|
||||
}
|
||||
|
||||
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
|
||||
@@ -416,11 +428,15 @@ static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* Write one payload whose kind is already known. */
|
||||
/* Write one payload whose kind is already known. id_prefix names it ("img" /
|
||||
* "art"), for_user marks it as the person's rather than the model's, and
|
||||
* ext_override is the stored file's extension when the caller knows better
|
||||
* than the mime does. */
|
||||
static cst_attachment_t *store(const char *session_path, const char *name,
|
||||
const char *data, size_t n, const char *mime,
|
||||
int pages, const char *origin, char *err,
|
||||
size_t err_sz) {
|
||||
int pages, const char *origin, int for_user,
|
||||
const char *id_prefix, const char *ext_override,
|
||||
char *err, size_t err_sz) {
|
||||
if (!name || !name[0])
|
||||
name = "image";
|
||||
char dir[OS_PATH_MAX];
|
||||
@@ -433,12 +449,14 @@ static cst_attachment_t *store(const char *session_path, const char *name,
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return NULL;
|
||||
}
|
||||
make_id(name, data, n, a->id, sizeof(a->id));
|
||||
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
|
||||
make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
|
||||
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
|
||||
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
|
||||
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
|
||||
snprintf(a->mime, sizeof(a->mime), "%s", mime);
|
||||
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
|
||||
snprintf(a->detail, sizeof(a->detail), "auto");
|
||||
a->for_user = for_user ? 1 : 0;
|
||||
a->bytes = (long long)n;
|
||||
a->pages = pages;
|
||||
if (cst_attach_is_image(a))
|
||||
@@ -494,7 +512,8 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
||||
return NULL;
|
||||
}
|
||||
return store(session_path, name, data, n, mime, 0, "user", err, err_sz);
|
||||
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
|
||||
err, err_sz);
|
||||
}
|
||||
|
||||
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||
@@ -526,11 +545,135 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||
if (!payload)
|
||||
return NULL;
|
||||
cst_attachment_t *a =
|
||||
store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz);
|
||||
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
|
||||
NULL, err, err_sz);
|
||||
free(payload);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* The mime of a delivered file: its extension decides (the content is not
|
||||
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
|
||||
* the OS still opens by name. */
|
||||
static const char *mime_from_name(const char *name) {
|
||||
static const struct {
|
||||
const char *ext, *mime;
|
||||
} T[] = {
|
||||
{"md", "text/markdown"}, {"markdown", "text/markdown"},
|
||||
{"txt", "text/plain"}, {"log", "text/plain"},
|
||||
{"json", "application/json"}, {"csv", "text/csv"},
|
||||
{"html", "text/html"}, {"htm", "text/html"},
|
||||
{"xml", "application/xml"}, {"yaml", "application/yaml"},
|
||||
{"yml", "application/yaml"}, {"toml", "application/toml"},
|
||||
{"pdf", "application/pdf"}, {"zip", "application/zip"},
|
||||
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
|
||||
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
|
||||
{"py", "text/x-python"}, {"c", "text/x-c"},
|
||||
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
|
||||
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
|
||||
{"js", "text/javascript"}, {"ts", "text/typescript"},
|
||||
{"go", "text/x-go"}, {"rs", "text/x-rust"},
|
||||
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
|
||||
{"css", "text/css"}, {"sql", "text/x-sql"},
|
||||
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
|
||||
{"png", "image/png"}, {"jpg", "image/jpeg"},
|
||||
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
|
||||
{"webp", "image/webp"}, {"bmp", "image/bmp"},
|
||||
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
|
||||
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
|
||||
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
|
||||
{"mp4", "video/mp4"}, {"webm", "video/webm"},
|
||||
{"apk", "application/vnd.android.package-archive"},
|
||||
{"docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
{"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
||||
{"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml."
|
||||
"presentation"},
|
||||
};
|
||||
const char *dot = name ? strrchr(name, '.') : NULL;
|
||||
if (!dot || !dot[1])
|
||||
return "application/octet-stream";
|
||||
const char *e = dot + 1;
|
||||
size_t el = strlen(e);
|
||||
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
|
||||
if (strlen(T[i].ext) != el)
|
||||
continue;
|
||||
size_t k = 0;
|
||||
for (; k < el; k++) {
|
||||
char c = e[k];
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = (char)(c - 'A' + 'a');
|
||||
if (c != T[i].ext[k])
|
||||
break;
|
||||
}
|
||||
if (k == el)
|
||||
return T[i].mime;
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
|
||||
* most 8 characters -- a stored name has to stay portable), else whatever the
|
||||
* mime maps to. */
|
||||
static void ext_for_name(const char *name, const char *mime, char *out,
|
||||
size_t out_sz) {
|
||||
size_t k = 0;
|
||||
const char *dot = name ? strrchr(name, '.') : NULL;
|
||||
if (dot && dot[1] && out_sz > 1) {
|
||||
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
|
||||
char c = *p;
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = (char)(c - 'A' + 'a');
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
|
||||
break;
|
||||
out[k++] = c;
|
||||
}
|
||||
}
|
||||
out[k] = 0;
|
||||
if (!k)
|
||||
snprintf(out, out_sz, "%s", mime_of_ext(mime));
|
||||
}
|
||||
|
||||
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
|
||||
const char *src_path,
|
||||
const char *title, char *err,
|
||||
size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!session_path || !session_path[0]) {
|
||||
snprintf(err, err_sz, "no session to store the file in");
|
||||
return NULL;
|
||||
}
|
||||
if (!src_path || !src_path[0]) {
|
||||
snprintf(err, err_sz, "no file to deliver");
|
||||
return NULL;
|
||||
}
|
||||
size_t n = 0;
|
||||
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
|
||||
if (!data) {
|
||||
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
||||
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
|
||||
return NULL;
|
||||
}
|
||||
const char *leaf = src_path;
|
||||
for (const char *p = src_path; *p; p++)
|
||||
if (*p == '/' || *p == '\\')
|
||||
leaf = p + 1;
|
||||
const char *name = (title && title[0]) ? title : leaf;
|
||||
char mime[64], ext[16];
|
||||
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
|
||||
ext_for_name(name, mime, ext, sizeof(ext));
|
||||
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
|
||||
"art", ext, err, err_sz);
|
||||
free(data);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* Images are opaque and travel as image parts; everything else is text the
|
||||
* store holds and the request inlines. */
|
||||
int cst_attach_is_image(const cst_attachment_t *a) {
|
||||
@@ -538,7 +681,8 @@ int cst_attach_is_image(const cst_attachment_t *a) {
|
||||
}
|
||||
|
||||
int cst_attach_is_text(const cst_attachment_t *a) {
|
||||
return a && !cst_attach_is_image(a);
|
||||
/* A delivered file is the person's: it is never inlined into a request. */
|
||||
return a && !a->for_user && !cst_attach_is_image(a);
|
||||
}
|
||||
|
||||
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||
@@ -618,9 +762,10 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
||||
if (a->bytes >= 1024 * 1024)
|
||||
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
||||
(double)a->bytes / (1024 * 1024));
|
||||
else if (a->bytes >= 1024)
|
||||
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
|
||||
else
|
||||
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
|
||||
(double)a->bytes / 1024);
|
||||
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
|
||||
}
|
||||
|
||||
/* --- lifetime ------------------------------------------------------------ */
|
||||
@@ -687,6 +832,8 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
|
||||
cJSON_AddStringToObject(o, "detail", a->detail);
|
||||
if (a->origin[0])
|
||||
cJSON_AddStringToObject(o, "origin", a->origin);
|
||||
if (a->for_user)
|
||||
cJSON_AddNumberToObject(o, "for_user", 1);
|
||||
cJSON_AddItemToArray(arr, o);
|
||||
}
|
||||
char *s = cJSON_PrintUnformatted(arr);
|
||||
@@ -739,6 +886,8 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out)
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
|
||||
snprintf(a->origin, sizeof(a->origin), "%s",
|
||||
cJSON_IsString(v) ? v->valuestring : "");
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
|
||||
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||
k++;
|
||||
}
|
||||
if (k == 0) {
|
||||
|
||||
@@ -18,6 +18,51 @@ static void set_err(char *err, size_t err_sz, const char *msg) {
|
||||
snprintf(err, err_sz, "%s", msg);
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
/* PATH lookup without shelling out: cheap enough for a capability probe. */
|
||||
static int on_path(const char *name) {
|
||||
const char *path = getenv("PATH");
|
||||
if (!path)
|
||||
return 0;
|
||||
for (const char *p = path;;) {
|
||||
const char *colon = strchr(p, ':');
|
||||
size_t len = colon ? (size_t)(colon - p) : strlen(p);
|
||||
char cand[4096];
|
||||
if (len + strlen(name) + 2 < sizeof(cand)) {
|
||||
if (len == 0)
|
||||
snprintf(cand, sizeof(cand), "./%s", name);
|
||||
else
|
||||
snprintf(cand, sizeof(cand), "%.*s/%s", (int)len, p, name);
|
||||
if (access(cand, X_OK) == 0)
|
||||
return 1;
|
||||
}
|
||||
if (!colon)
|
||||
break;
|
||||
p = colon + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int cst_clipboard_available(char *why, size_t why_sz) {
|
||||
if (why && why_sz)
|
||||
why[0] = 0;
|
||||
#ifdef _WIN32
|
||||
return 0; /* the Win32 clipboard API is always there */
|
||||
#elif defined(__APPLE__)
|
||||
return 0; /* pbpaste / pbcopy ship with the OS */
|
||||
#else
|
||||
static const char *const helpers[] = {"wl-paste", "wl-copy", "xclip", NULL};
|
||||
for (int i = 0; helpers[i]; i++)
|
||||
if (on_path(helpers[i]))
|
||||
return 0;
|
||||
if (why && why_sz)
|
||||
snprintf(why, why_sz,
|
||||
"no clipboard helper on PATH (install wl-clipboard or xclip)");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void put_le32(unsigned char *p, unsigned long v) {
|
||||
p[0] = (unsigned char)(v & 0xff);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xff);
|
||||
|
||||
@@ -22,6 +22,25 @@
|
||||
#define ERRBODY_MAX 4096
|
||||
#define LLM_MAX_RETRIES 3
|
||||
|
||||
static int api_host_has(const char *base, const char *needle) {
|
||||
return base && needle && strstr(base, needle) != NULL;
|
||||
}
|
||||
|
||||
static int is_kimi_api(const char *base) {
|
||||
return api_host_has(base, "api.moonshot.ai") ||
|
||||
api_host_has(base, "api.moonshot.cn") ||
|
||||
api_host_has(base, "api.kimi.ai") ||
|
||||
api_host_has(base, "api.kimi.com");
|
||||
}
|
||||
|
||||
static int is_kimi_k2(const char *model) {
|
||||
return model && strncmp(model, "kimi-k2", 7) == 0;
|
||||
}
|
||||
|
||||
static int is_kimi_k27(const char *model) {
|
||||
return model && strncmp(model, "kimi-k2.7-code", 14) == 0;
|
||||
}
|
||||
|
||||
static int ca_file_ok(const char *path) {
|
||||
if (!path || !path[0])
|
||||
return 0;
|
||||
@@ -296,10 +315,12 @@ static cJSON *document_part(const cst_llm_request_t *req,
|
||||
}
|
||||
|
||||
/* Append every attachment of m: images as image parts (or a note saying why
|
||||
* not), documents inline as text. */
|
||||
static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
||||
const cst_message_t *m, size_t *budget,
|
||||
size_t *doc_budget, const char *img_off) {
|
||||
* not), documents inline as text. Returns how many parts were added: a
|
||||
* delivered file (for_user) adds none. */
|
||||
static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
||||
const cst_message_t *m, size_t *budget,
|
||||
size_t *doc_budget, const char *img_off) {
|
||||
int added = 0;
|
||||
int n = m->nattach;
|
||||
if (n > CST_ATTACH_MAX_COUNT) {
|
||||
char note[160];
|
||||
@@ -307,10 +328,15 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
||||
"[only the first %d of %d attachments are sent]",
|
||||
CST_ATTACH_MAX_COUNT, n);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
added++;
|
||||
n = CST_ATTACH_MAX_COUNT;
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cst_attachment_t *a = &m->attach[i];
|
||||
/* A delivered file belongs to the person, not to the model: no part, and
|
||||
* not even a "not sent" note -- it was never meant to be in the request. */
|
||||
if (a->for_user)
|
||||
continue;
|
||||
int is_img = cst_attach_is_image(a);
|
||||
char why[256];
|
||||
cJSON *p = NULL;
|
||||
@@ -322,13 +348,16 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
||||
p = document_part(req, a, doc_budget, why, sizeof(why));
|
||||
if (p) {
|
||||
cJSON_AddItemToArray(parts, p);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
char note[512];
|
||||
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
|
||||
is_img ? "image" : "document", a->name, why);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/* Images a tool result produced are deferred: an assistant tool_call must be
|
||||
@@ -345,8 +374,14 @@ static void defer_attachments(const cst_llm_request_t *req,
|
||||
cJSON_AddArrayToObject(p, "content");
|
||||
*pending = p;
|
||||
}
|
||||
parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
|
||||
budget, doc_budget, img_off);
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
|
||||
parts_add_attachments(req, content, m, budget, doc_budget, img_off);
|
||||
if (cJSON_GetArraySize(content) == 0) {
|
||||
/* Nothing to carry (a tool result that only delivered a file): an empty
|
||||
* user message is noise, and some providers refuse it. */
|
||||
cJSON_Delete(p);
|
||||
*pending = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* m's text plus one line per image it would have carried (malloc'd); a
|
||||
@@ -436,14 +471,46 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
return NULL;
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "model", req->model);
|
||||
if (req->reasoning_effort && req->reasoning_effort[0])
|
||||
const int kimi = is_kimi_api(req->api_base);
|
||||
const int kimi_k2 = kimi && is_kimi_k2(req->model);
|
||||
if (req->reasoning_effort && req->reasoning_effort[0] && !kimi_k2)
|
||||
cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort);
|
||||
if (kimi_k2) {
|
||||
/* K2.6/K2.7 do not understand K3's top-level reasoning_effort. They
|
||||
* expose thinking as an object and require preserved reasoning to be
|
||||
* sent back on the next turn. K2.7-code is always enabled/kept. */
|
||||
cJSON *thinking = cJSON_AddObjectToObject(root, "thinking");
|
||||
cJSON_AddStringToObject(thinking, "type", "enabled");
|
||||
if (req->send_reasoning || is_kimi_k27(req->model))
|
||||
cJSON_AddStringToObject(thinking, "keep", "all");
|
||||
}
|
||||
if (kimi && (!getenv("CST_NO_CACHE_HINTS") ||
|
||||
!getenv("CST_NO_CACHE_HINTS")[0] ||
|
||||
getenv("CST_NO_CACHE_HINTS")[0] == '0')) {
|
||||
/* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause
|
||||
* longer than that, so opt into the 1h tier; users can select 5m/1h. */
|
||||
const char *ttl = getenv("CST_KIMI_CACHE_TTL");
|
||||
if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0))
|
||||
ttl = "1h";
|
||||
cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options");
|
||||
cJSON_AddStringToObject(cache, "mode", "implicit");
|
||||
cJSON_AddStringToObject(cache, "ttl", ttl);
|
||||
}
|
||||
cJSON_AddTrueToObject(root, "stream");
|
||||
const char *nousage = getenv("CST_NO_STREAM_USAGE");
|
||||
if (!nousage || !nousage[0] || nousage[0] == '0') {
|
||||
cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
|
||||
cJSON_AddTrueToObject(so, "include_usage");
|
||||
}
|
||||
/* The generation parameters the user set (Settings -> Advanced). A field
|
||||
* that was not set is left out: an absent field is the provider's own
|
||||
* default, which "temperature": 0 would not be. */
|
||||
if (req->temperature > 0)
|
||||
cJSON_AddNumberToObject(root, "temperature", req->temperature);
|
||||
if (req->top_p > 0)
|
||||
cJSON_AddNumberToObject(root, "top_p", req->top_p);
|
||||
if (req->max_tokens > 0)
|
||||
cJSON_AddNumberToObject(root, "max_tokens", req->max_tokens);
|
||||
cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
|
||||
size_t tcap = wire_tool_cap(req);
|
||||
cJSON *prev_user = NULL; /* merge consecutive user messages */
|
||||
@@ -561,8 +628,10 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
if (req->send_reasoning && m->reasoning && m->reasoning[0])
|
||||
cJSON_AddItemToObject(o, "reasoning_content",
|
||||
clean_string(m->reasoning, 0));
|
||||
if (m->prefix)
|
||||
cJSON_AddTrueToObject(o, "prefix");
|
||||
if (m->prefix) {
|
||||
/* Kimi calls the OpenAI-style assistant prefill flag `partial`. */
|
||||
cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix");
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToArray(msgs, o);
|
||||
if (role == CST_ROLE_TOOL && n_att)
|
||||
|
||||
@@ -33,6 +33,8 @@ static void usage(const char *argv0) {
|
||||
" -N, --new start a new session\n"
|
||||
" -R, --resume ID open a session by id or unique prefix\n"
|
||||
" -L, --list-sessions list sessions and exit\n"
|
||||
" --migrate-store move sessions written by an older version\n"
|
||||
" into one directory per session, and exit\n"
|
||||
" --list-models [P] what is known about each provider's models\n"
|
||||
" (built-in, <config>/models.json, GET /models)\n"
|
||||
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
|
||||
@@ -175,6 +177,25 @@ static int list_sessions(const char *ws) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Move a store written by an older version into one directory per session
|
||||
* (--migrate-store). Idempotent: what is already a directory is left alone. */
|
||||
static int migrate_store(const char *ws) {
|
||||
int moved = 0, failed = 0;
|
||||
char err[256] = "";
|
||||
if (cst_agent_store_migrate(ws, &moved, &failed, err, sizeof(err)) != 0) {
|
||||
fprintf(stderr, "cannot migrate %s/.agent/sessions: %s\n", ws,
|
||||
err[0] ? err : "unknown error");
|
||||
return 1;
|
||||
}
|
||||
printf("migrated %d session(s) into %s/.agent/sessions/<id>/\n", moved, ws);
|
||||
if (failed)
|
||||
printf("%d session(s) could not be moved; their files are untouched\n",
|
||||
failed);
|
||||
if (moved == 0)
|
||||
printf("nothing to move (already one directory per session)\n");
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Exact id or unique prefix → id. */
|
||||
static int resolve_session_id(const char *ws, const char *want, char *out,
|
||||
size_t out_sz) {
|
||||
@@ -338,7 +359,7 @@ int main(int argc, char **argv) {
|
||||
int force_ask_key = 0;
|
||||
int session_explicit = 0;
|
||||
const char *prompt = NULL;
|
||||
int new_session = 0, do_list = 0;
|
||||
int new_session = 0, do_list = 0, do_migrate = 0;
|
||||
const char *resume_id = NULL;
|
||||
/* WebUI (--web): a second front end on the same engine. */
|
||||
int web_mode = 0;
|
||||
@@ -372,6 +393,7 @@ int main(int argc, char **argv) {
|
||||
{"continue", no_argument, 0, 'c'},
|
||||
{"resume", required_argument, 0, 'R'},
|
||||
{"list-sessions", no_argument, 0, 'L'},
|
||||
{"migrate-store", no_argument, 0, 0x107},
|
||||
{"yolo", no_argument, 0, 'y'},
|
||||
{"approval", required_argument, 0, 'A'},
|
||||
{"confirm", required_argument, 0, 'C'},
|
||||
@@ -528,6 +550,9 @@ int main(int argc, char **argv) {
|
||||
web.open_browser = 1;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x107:
|
||||
do_migrate = 1;
|
||||
break;
|
||||
case 'V':
|
||||
printf("cst-agent %s\n", CST_VERSION);
|
||||
curl_global_cleanup();
|
||||
@@ -539,6 +564,12 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
if (do_migrate) {
|
||||
int rc = migrate_store(cfg.workspace_root);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (do_list) {
|
||||
int rc = list_sessions(cfg.workspace_root);
|
||||
curl_global_cleanup();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "cst_metrics.h"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -31,18 +32,11 @@ cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path)
|
||||
os_mkdir(agent, 0755);
|
||||
os_path_join(dir, sizeof(dir), agent, "metrics");
|
||||
os_mkdir(dir, 0755);
|
||||
const char *base = session_path ? session_path : "session.jsonl";
|
||||
const char *s1 = strrchr(base, '/');
|
||||
const char *s2 = strrchr(base, '\\');
|
||||
if (s2 && (!s1 || s2 > s1))
|
||||
s1 = s2;
|
||||
if (s1)
|
||||
base = s1 + 1;
|
||||
/* One file per session: the id of the transcript's path (<id>.jsonl, or
|
||||
* <id>/session.jsonl in the store's own layout). */
|
||||
char leaf[256];
|
||||
snprintf(leaf, sizeof(leaf), "%s", base);
|
||||
size_t n = strlen(leaf);
|
||||
if (n > 6 && strcmp(leaf + n - 6, ".jsonl") == 0)
|
||||
leaf[n - 6] = 0;
|
||||
if (cst_agent_store_id_of_path(session_path, leaf, sizeof(leaf)) != 0)
|
||||
snprintf(leaf, sizeof(leaf), "session");
|
||||
char file[300];
|
||||
snprintf(file, sizeof(file), "%s.jsonl", leaf);
|
||||
os_path_join(m->path, sizeof(m->path), dir, file);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "cst_models.h"
|
||||
#include "cst_util.h"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -326,6 +328,162 @@ int cst_models_user_path(char *out, size_t out_sz) {
|
||||
return w > 0 && (size_t)w < out_sz ? 0 : -1;
|
||||
}
|
||||
|
||||
/* --- the user file: providers the presets do not know -------------------- */
|
||||
|
||||
static int valid_provider_name(const char *name) {
|
||||
if (!name || !name[0] || strlen(name) >= 32)
|
||||
return 0;
|
||||
for (const char *q = name; *q; q++)
|
||||
if (!isalnum((unsigned char)*q) && *q != '.' && *q != '_' && *q != '-')
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The user file as a JSON object: an empty one when there is no file, NULL
|
||||
* when there is one that is not an object (never overwrite somebody else's
|
||||
* file, their edit is not worth losing). */
|
||||
static cJSON *read_user_doc(char *path, size_t path_sz) {
|
||||
if (cst_models_user_path(path, path_sz) != 0)
|
||||
return NULL;
|
||||
char *text = read_file(path);
|
||||
if (!text)
|
||||
return cJSON_CreateObject();
|
||||
cJSON *doc = cJSON_Parse(text + cst_bom_len(text, strlen(text)));
|
||||
free(text);
|
||||
if (!cJSON_IsObject(doc)) {
|
||||
cJSON_Delete(doc);
|
||||
return NULL;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
static int write_user_doc(cJSON *doc, const char *path, char *err,
|
||||
size_t err_sz) {
|
||||
char *text = cJSON_Print(doc);
|
||||
if (!text) {
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return -1;
|
||||
}
|
||||
char tmp[1200];
|
||||
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) {
|
||||
cJSON_free(text);
|
||||
snprintf(err, err_sz, "path too long");
|
||||
return -1;
|
||||
}
|
||||
int rc = -1;
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
if (!f) {
|
||||
snprintf(err, err_sz, "%s: cannot write (%s)", tmp, strerror(errno));
|
||||
} else {
|
||||
const int ok = fputs(text, f) >= 0 && fputc('\n', f) != EOF;
|
||||
const int closed = fclose(f);
|
||||
if (ok && closed == 0 &&
|
||||
#ifdef _WIN32
|
||||
(remove(path), 1) &&
|
||||
#endif
|
||||
rename(tmp, path) == 0)
|
||||
rc = 0;
|
||||
else
|
||||
snprintf(err, err_sz, "%s: cannot replace (%s)", path, strerror(errno));
|
||||
if (rc != 0)
|
||||
remove(tmp);
|
||||
}
|
||||
cJSON_free(text);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_models_user_set_provider(const char *name, const char *api_base,
|
||||
const char *key_env, const char *default_model,
|
||||
const char *const *models, int nmodels,
|
||||
char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!valid_provider_name(name)) {
|
||||
snprintf(err, err_sz,
|
||||
"a provider name is 1-31 of letters, digits, '.', '_' or '-'");
|
||||
return -1;
|
||||
}
|
||||
if (!api_base || !api_base[0]) {
|
||||
snprintf(err, err_sz, "a base URL is needed");
|
||||
return -1;
|
||||
}
|
||||
char path[1100];
|
||||
pthread_mutex_lock(&g_mu);
|
||||
cJSON *doc = read_user_doc(path, sizeof(path));
|
||||
if (!doc) {
|
||||
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return -1;
|
||||
}
|
||||
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
||||
if (!cJSON_IsObject(providers))
|
||||
providers = cJSON_AddObjectToObject(doc, "providers");
|
||||
cJSON *entry = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(entry, "api_base", api_base);
|
||||
if (key_env && key_env[0])
|
||||
cJSON_AddStringToObject(entry, "key_env", key_env);
|
||||
if (default_model && default_model[0])
|
||||
cJSON_AddStringToObject(entry, "default_model", default_model);
|
||||
if (models && nmodels > 0) {
|
||||
cJSON *arr = cJSON_AddArrayToObject(entry, "models");
|
||||
for (int i = 0; i < nmodels; i++)
|
||||
if (models[i] && models[i][0])
|
||||
cJSON_AddItemToArray(arr, cJSON_CreateString(models[i]));
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
||||
cJSON_AddItemToObject(providers, name, entry);
|
||||
const int rc = write_user_doc(doc, path, err, err_sz);
|
||||
cJSON_Delete(doc);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (rc == 0)
|
||||
cst_models_reload(); /* the provider is there from now on */
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!valid_provider_name(name)) {
|
||||
snprintf(err, err_sz, "not a provider name");
|
||||
return -1;
|
||||
}
|
||||
char path[1100];
|
||||
if (cst_models_user_path(path, sizeof(path)) != 0) {
|
||||
snprintf(err, err_sz, "no config directory");
|
||||
return -1;
|
||||
}
|
||||
FILE *probe = fopen(path, "rb");
|
||||
if (!probe)
|
||||
return 0; /* no user file: nothing of ours to drop */
|
||||
fclose(probe);
|
||||
pthread_mutex_lock(&g_mu);
|
||||
cJSON *doc = read_user_doc(path, sizeof(path));
|
||||
if (!doc) {
|
||||
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return -1;
|
||||
}
|
||||
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
||||
const int found = cJSON_IsObject(providers) &&
|
||||
cJSON_GetObjectItemCaseSensitive(providers, name) != NULL;
|
||||
if (found)
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
||||
const int rc = found ? write_user_doc(doc, path, err, err_sz) : 0;
|
||||
cJSON_Delete(doc);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (rc == 0 && found)
|
||||
cst_models_reload();
|
||||
return rc == 0 ? found : -1;
|
||||
}
|
||||
|
||||
static void load_default_locked(void) {
|
||||
reset_locked();
|
||||
char e[128];
|
||||
@@ -718,6 +876,11 @@ const char *cst_effort_wire(const char *provider, const char *model,
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (strcmp(mode, "passthrough") == 0)
|
||||
return effort;
|
||||
if (strcmp(mode, "kimi") == 0) {
|
||||
/* Kimi K3 accepts low/high/max; the older K2 thinking models use the
|
||||
* separate `thinking` object and only need a valid effort selection. */
|
||||
return strcmp(effort, "medium") == 0 ? "low" : effort;
|
||||
}
|
||||
if (strcmp(mode, "openai") == 0) {
|
||||
cst_model_info_t mi;
|
||||
cst_model_info(provider, model, &mi);
|
||||
|
||||
@@ -15,6 +15,23 @@
|
||||
"prefix_completion": true,
|
||||
"effort": "passthrough"
|
||||
},
|
||||
"kimi": {
|
||||
"api_base": "https://api.moonshot.ai/v1",
|
||||
"key_env": "MOONSHOT_API_KEY",
|
||||
"default_model": "kimi-k3",
|
||||
"models": ["kimi-k3", "kimi-k2.6", "kimi-k2.7-code"],
|
||||
"reasoning_passback": true,
|
||||
"effort": "kimi"
|
||||
},
|
||||
"kimi-code": {
|
||||
"api_base": "https://api.kimi.ai/coding/v1",
|
||||
"key_env": "KIMI_API_KEY",
|
||||
"default_model": "k3",
|
||||
"models": ["k3", "k3-256k", "kimi-for-coding",
|
||||
"kimi-for-coding-highspeed"],
|
||||
"reasoning_passback": true,
|
||||
"effort": "kimi"
|
||||
},
|
||||
"openai": {
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"key_env": "OPENAI_API_KEY",
|
||||
@@ -65,6 +82,16 @@
|
||||
{"match": "glm", "context": 128000},
|
||||
{"match": "glm-*v*", "vision": true},
|
||||
{"match": "kimi", "context": 131072},
|
||||
{"match": "kimi-k3", "context": 1048576, "vision": true,
|
||||
"reasoning": true},
|
||||
{"match": "kimi-k2.6", "context": 262144, "vision": true,
|
||||
"reasoning": true},
|
||||
{"match": "kimi-k2.7-code", "context": 262144, "reasoning": true},
|
||||
{"match": "k3", "context": 1048576, "vision": true, "reasoning": true},
|
||||
{"match": "k3-256k", "context": 262144, "reasoning": true},
|
||||
{"match": "kimi-for-coding", "context": 1048576, "reasoning": true},
|
||||
{"match": "kimi-for-coding-highspeed", "context": 262144,
|
||||
"reasoning": true},
|
||||
{"match": "pixtral", "vision": true},
|
||||
{"match": "llava", "vision": true},
|
||||
{"match": "*-vl*", "vision": true},
|
||||
|
||||
@@ -1217,3 +1217,40 @@ int unsetenv(const char *name) {
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@
|
||||
#include <sddl.h>
|
||||
#endif
|
||||
|
||||
#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__))
|
||||
/* Android keeps the plain exec path, like its Landlock branch above: the NDK
|
||||
* build compiles the seccomp supervisor, the probe then reports "seccomp",
|
||||
* but on the device the writes outside the workspace are not denied
|
||||
* (tests/test_sandbox.c:308). Claiming a sandbox that does not hold is worse
|
||||
* than reporting none -- see docs/android.md. */
|
||||
#if defined(__linux__) && !defined(__ANDROID__) && \
|
||||
(defined(__x86_64__) || defined(__aarch64__))
|
||||
#include <fcntl.h>
|
||||
#include <linux/audit.h>
|
||||
#include <linux/filter.h>
|
||||
|
||||
@@ -15,39 +15,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
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
|
||||
#include "os_compat.h"
|
||||
|
||||
struct cst_session {
|
||||
char path[4096];
|
||||
@@ -158,7 +126,7 @@ static void load_existing(cst_session_t *s) {
|
||||
return;
|
||||
char *line = NULL;
|
||||
size_t cap = 0;
|
||||
while (getline(&line, &cap, rp) != -1) {
|
||||
while (os_getline(&line, &cap, rp) != -1) {
|
||||
cJSON *o = cJSON_Parse(line);
|
||||
if (!o || !cJSON_IsObject(o)) {
|
||||
cJSON_Delete(o);
|
||||
@@ -280,26 +248,18 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
|
||||
cJSON_Delete(mo);
|
||||
}
|
||||
if (msg->attach && msg->nattach > 0) {
|
||||
cJSON *arr = cJSON_CreateArray();
|
||||
for (int i = 0; i < msg->nattach; i++) {
|
||||
const cst_attachment_t *a = &msg->attach[i];
|
||||
cJSON *it = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(it, "id", a->id);
|
||||
cJSON_AddStringToObject(it, "name", a->name);
|
||||
cJSON_AddStringToObject(it, "mime", a->mime);
|
||||
cJSON_AddStringToObject(it, "rel", a->rel);
|
||||
cJSON_AddNumberToObject(it, "bytes", (double)a->bytes);
|
||||
if (a->width > 0 && a->height > 0) {
|
||||
cJSON_AddNumberToObject(it, "width", a->width);
|
||||
cJSON_AddNumberToObject(it, "height", a->height);
|
||||
}
|
||||
if (a->detail[0])
|
||||
cJSON_AddStringToObject(it, "detail", a->detail);
|
||||
if (a->origin[0])
|
||||
cJSON_AddStringToObject(it, "origin", a->origin);
|
||||
cJSON_AddItemToArray(arr, it);
|
||||
/* One writer for every attachment (cst_attachments_to_json): a field it
|
||||
* carries must not have to be remembered here too -- forgetting
|
||||
* `for_user` would put a delivered file back in front of the model. */
|
||||
char *json = cst_attachments_to_json(msg->attach, msg->nattach);
|
||||
if (json) {
|
||||
cJSON *arr = cJSON_Parse(json);
|
||||
free(json);
|
||||
if (cJSON_IsArray(arr))
|
||||
cJSON_AddItemToObject(o, "attachments", arr);
|
||||
else
|
||||
cJSON_Delete(arr);
|
||||
}
|
||||
cJSON_AddItemToObject(o, "attachments", arr);
|
||||
}
|
||||
char *txt = cJSON_PrintUnformatted(o);
|
||||
cJSON_Delete(o);
|
||||
|
||||
@@ -72,6 +72,25 @@ int cst_settings_load(cst_settings_t *s) {
|
||||
m);
|
||||
s->nmodels++;
|
||||
}
|
||||
const cJSON *params = cJSON_GetObjectItemCaseSensitive(root, "params");
|
||||
const cJSON *p;
|
||||
cJSON_ArrayForEach(p, params) {
|
||||
if (s->nparams >= CST_SETTINGS_MAX_PARAMS || !p->string || !cJSON_IsObject(p))
|
||||
continue;
|
||||
cst_gen_params_t *g = &s->params[s->nparams];
|
||||
memset(g, 0, sizeof(*g));
|
||||
snprintf(g->provider, sizeof(g->provider), "%s", p->string);
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(p, "temperature");
|
||||
if (cJSON_IsNumber(v))
|
||||
g->temperature = v->valuedouble;
|
||||
v = cJSON_GetObjectItemCaseSensitive(p, "top_p");
|
||||
if (cJSON_IsNumber(v))
|
||||
g->top_p = v->valuedouble;
|
||||
v = cJSON_GetObjectItemCaseSensitive(p, "max_tokens");
|
||||
if (cJSON_IsNumber(v))
|
||||
g->max_tokens = (int)v->valuedouble;
|
||||
s->nparams++;
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
@@ -102,6 +121,21 @@ int cst_settings_save(const cst_settings_t *s) {
|
||||
cJSON *models = cJSON_AddObjectToObject(root, "models");
|
||||
for (int i = 0; i < s->nmodels; i++)
|
||||
cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model);
|
||||
if (s->nparams) {
|
||||
cJSON *params = cJSON_AddObjectToObject(root, "params");
|
||||
for (int i = 0; i < s->nparams; i++) {
|
||||
const cst_gen_params_t *g = &s->params[i];
|
||||
if (!g->provider[0])
|
||||
continue;
|
||||
cJSON *o = cJSON_AddObjectToObject(params, g->provider);
|
||||
if (g->temperature > 0)
|
||||
cJSON_AddNumberToObject(o, "temperature", g->temperature);
|
||||
if (g->top_p > 0)
|
||||
cJSON_AddNumberToObject(o, "top_p", g->top_p);
|
||||
if (g->max_tokens > 0)
|
||||
cJSON_AddNumberToObject(o, "max_tokens", g->max_tokens);
|
||||
}
|
||||
}
|
||||
char *text = cJSON_Print(root);
|
||||
cJSON_Delete(root);
|
||||
if (!text)
|
||||
@@ -180,6 +214,65 @@ int cst_settings_remember(const cst_agent_config_t *cfg) {
|
||||
snprintf(s.models[i].provider, sizeof(s.models[i].provider), "%s",
|
||||
cfg->provider);
|
||||
snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model);
|
||||
/* The generation parameters ride along with the provider choice. An
|
||||
* all-zero config (the CLI, the TUI) leaves what the GUI saved alone. */
|
||||
if (cfg->temperature > 0 || cfg->top_p > 0 || cfg->max_tokens > 0) {
|
||||
int pi = 0;
|
||||
while (pi < s.nparams && strcmp(s.params[pi].provider, cfg->provider) != 0)
|
||||
pi++;
|
||||
if (pi == s.nparams) {
|
||||
if (s.nparams >= CST_SETTINGS_MAX_PARAMS)
|
||||
return -1;
|
||||
memset(&s.params[pi], 0, sizeof(s.params[pi]));
|
||||
snprintf(s.params[pi].provider, sizeof(s.params[pi].provider), "%s",
|
||||
cfg->provider);
|
||||
s.nparams++;
|
||||
}
|
||||
s.params[pi].temperature = cfg->temperature;
|
||||
s.params[pi].top_p = cfg->top_p;
|
||||
s.params[pi].max_tokens = cfg->max_tokens;
|
||||
}
|
||||
return cst_settings_save(&s);
|
||||
}
|
||||
|
||||
const cst_gen_params_t *cst_settings_params(const cst_settings_t *s,
|
||||
const char *provider) {
|
||||
for (int i = 0; s && provider && provider[0] && i < s->nparams; i++)
|
||||
if (strcmp(s->params[i].provider, provider) == 0)
|
||||
return &s->params[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cst_settings_remember_params(const char *provider,
|
||||
const cst_gen_params_t *p) {
|
||||
if (!provider || !provider[0])
|
||||
return -1;
|
||||
cst_settings_t s;
|
||||
cst_settings_load(&s);
|
||||
int i = 0;
|
||||
while (i < s.nparams && strcmp(s.params[i].provider, provider) != 0)
|
||||
i++;
|
||||
const int forget = !p || (p->temperature <= 0 && p->top_p <= 0 &&
|
||||
p->max_tokens <= 0);
|
||||
if (i == s.nparams) {
|
||||
if (forget)
|
||||
return 0; /* nothing remembered, nothing to forget */
|
||||
if (s.nparams >= CST_SETTINGS_MAX_PARAMS)
|
||||
return -1;
|
||||
memset(&s.params[i], 0, sizeof(s.params[i]));
|
||||
snprintf(s.params[i].provider, sizeof(s.params[i].provider), "%s",
|
||||
provider);
|
||||
s.nparams++;
|
||||
}
|
||||
if (forget) { /* keep the array compact when an entry goes away */
|
||||
memmove(&s.params[i], &s.params[i + 1],
|
||||
sizeof(s.params[i]) * (s.nparams - i - 1));
|
||||
s.nparams--;
|
||||
} else {
|
||||
s.params[i].temperature = p->temperature;
|
||||
s.params[i].top_p = p->top_p;
|
||||
s.params[i].max_tokens = p->max_tokens;
|
||||
}
|
||||
return cst_settings_save(&s);
|
||||
}
|
||||
|
||||
|
||||