Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580d533656 | ||
|
|
08008a5a4c | ||
|
|
7299f39245 | ||
|
|
d4f65b17cf | ||
|
|
60cbce3c5c | ||
|
|
84f9585588 | ||
|
|
c2320f946b | ||
|
|
82dee65942 | ||
|
|
3fd039cb51 | ||
|
|
8694afca94 | ||
|
|
cbd71b83c7 | ||
|
|
be4025e276 | ||
|
|
69842edf16 | ||
|
|
f5d340bd2a | ||
|
|
da812cc7e8 | ||
|
|
5ba215b36c | ||
|
|
e0b0eddf3a | ||
|
|
50abcf1a91 | ||
|
|
9bf7707912 | ||
|
|
7e1dbd9afa | ||
|
|
2d1052fd5f | ||
|
|
e238f5f37d | ||
|
|
adf3e9f024 | ||
|
|
6e81513e36 | ||
|
|
c7b32c3fd6 |
@@ -11,13 +11,11 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux:
|
# 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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
cc: [gcc, clang]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -34,60 +32,38 @@ jobs:
|
|||||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||||
$SUDO apt-get update
|
$SUDO apt-get update
|
||||||
$SUDO apt-get install -y --no-install-recommends \
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
build-essential clang cmake pkg-config python3 \
|
build-essential pkg-config python3 curl ca-certificates \
|
||||||
libcurl4-openssl-dev libncurses-dev
|
libcurl4-openssl-dev libncurses-dev
|
||||||
- name: Build (warnings are errors)
|
- name: Build TinyCC
|
||||||
run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)"
|
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
|
- name: Unit tests
|
||||||
run: |
|
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
||||||
# Same -Werror as the build step so undeclared libc helpers cannot
|
|
||||||
# silently return int and truncate pointers (see test_subagent).
|
|
||||||
make CC=${{ matrix.cc }} \
|
|
||||||
CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" check
|
|
||||||
- name: CMake build + ctest
|
|
||||||
run: |
|
|
||||||
cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON
|
|
||||||
cmake --build build -j"$(nproc)"
|
|
||||||
ctest --test-dir build --output-on-failure
|
|
||||||
- name: Smoke test
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
./cst-agent --help
|
./cst-agent --help
|
||||||
./cst-agent --workspace "$PWD" --list-sessions
|
./cst-agent --workspace "$PWD" --list-sessions
|
||||||
./cst-agent --demo-ctl --workspace .
|
./cst-agent --demo-ctl --workspace .
|
||||||
|
- name: WebUI end-to-end (recorded stream, no API key)
|
||||||
|
run: sh scripts/e2e_webui.sh
|
||||||
|
|
||||||
musl:
|
# Release: static musl binaries, gcc with -Werror.
|
||||||
|
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
|
||||||
|
linux:
|
||||||
|
needs: tcc-check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
container: alpine:3.20
|
strategy:
|
||||||
steps:
|
fail-fast: false
|
||||||
- name: Install tools + mirror
|
matrix:
|
||||||
run: |
|
include:
|
||||||
sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' \
|
- arch: x86_64
|
||||||
/etc/apk/repositories
|
check: 1
|
||||||
apk add --no-cache curl git bash
|
- arch: x86
|
||||||
- name: Checkout
|
check: 1
|
||||||
env:
|
- arch: aarch64
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
check: 0
|
||||||
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 build dependencies
|
|
||||||
run: |
|
|
||||||
# linux-headers provides linux/audit.h for the seccomp probe path
|
|
||||||
apk add --no-cache build-base pkgconf curl-dev ncurses-dev \
|
|
||||||
linux-headers git nodejs bash python3
|
|
||||||
- name: Build + unit tests
|
|
||||||
run: |
|
|
||||||
make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)"
|
|
||||||
make check
|
|
||||||
|
|
||||||
# MinGW-w64 UCRT cross build → dist/cst-agent-*-windows-x86_64.zip
|
|
||||||
windows-cross:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 120
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
env:
|
env:
|
||||||
@@ -103,20 +79,29 @@ jobs:
|
|||||||
SUDO=$(command -v sudo || true)
|
SUDO=$(command -v sudo || true)
|
||||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||||
$SUDO apt-get update
|
$SUDO apt-get update
|
||||||
|
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
|
||||||
$SUDO apt-get install -y --no-install-recommends \
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl git cmake ninja-build make pkg-config \
|
build-essential cmake ninja-build pkg-config python3 perl \
|
||||||
python3 zip unzip perl xz-utils
|
curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
|
||||||
- name: Cross-build Windows zip
|
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
||||||
run: bash scripts/ci_windows_cross.sh
|
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
||||||
- name: Verify zip
|
fi
|
||||||
|
- name: Build (static, -Werror)
|
||||||
|
env:
|
||||||
|
CST_LINUX_CHECK: ${{ matrix.check }}
|
||||||
|
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
||||||
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
ls -lah dist/windows-x86_64/
|
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
|
||||||
ls -lah dist/cst-agent-*-windows-x86_64.zip
|
file "$d/cst-agent"
|
||||||
cat dist/cst-agent-*-windows-x86_64.zip.sha256
|
run=""
|
||||||
unzip -l dist/cst-agent-*-windows-x86_64.zip | head -20
|
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
||||||
# Auto-publish to Gitea Releases (v$CST_VERSION from include/cst_version.h).
|
$run "$d/cst-agent" --version
|
||||||
# Prefer repo secret RELEASE_TOKEN (write:repository); fall back to the
|
$run "$d/cst-agent" --help >/dev/null
|
||||||
# job token. Skipped on pull_request and when no token is available.
|
$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
|
- name: Publish Gitea Release
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
@@ -132,4 +117,59 @@ jobs:
|
|||||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
bash scripts/release_windows.sh
|
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
|
||||||
|
|||||||
@@ -26,8 +26,26 @@ third_party/ncurses-*.tar.gz
|
|||||||
third_party/busybox-w32/
|
third_party/busybox-w32/
|
||||||
third_party/busybox-src/
|
third_party/busybox-src/
|
||||||
third_party/prefix-mingw/
|
third_party/prefix-mingw/
|
||||||
|
third_party/prefix-linux-*/
|
||||||
|
third_party/build-linux-*/
|
||||||
|
third_party/prefix-android-*/
|
||||||
|
third_party/build-android-*/
|
||||||
|
third_party/src/
|
||||||
vendor/sashidown/*.o
|
vendor/sashidown/*.o
|
||||||
vendor/sashidown/src/*.o
|
vendor/sashidown/src/*.o
|
||||||
vendor/sashidown/libsashidown.a
|
vendor/sashidown/libsashidown.a
|
||||||
|
vendor/sashidown/libsashidown-*.a
|
||||||
vendor/sashidown/libsashidown-mingw.a
|
vendor/sashidown/libsashidown-mingw.a
|
||||||
.gen/models_builtin.inc
|
.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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ set(CMAKE_C_STANDARD 11)
|
|||||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" 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_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
|
||||||
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
|
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
|
||||||
|
|
||||||
@@ -63,6 +65,29 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
|||||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||||
COMMENT "Building libsashidown for MinGW"
|
COMMENT "Building libsashidown for MinGW"
|
||||||
VERBATIM)
|
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()
|
else()
|
||||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
|
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
@@ -94,6 +119,7 @@ set(CST_CORE_SOURCES
|
|||||||
src/subagent.c
|
src/subagent.c
|
||||||
src/attach.c
|
src/attach.c
|
||||||
src/clipboard.c
|
src/clipboard.c
|
||||||
|
src/pdf_text.c
|
||||||
src/session.c
|
src/session.c
|
||||||
src/tools/tools.c
|
src/tools/tools.c
|
||||||
src/tools/patch.c
|
src/tools/patch.c
|
||||||
@@ -101,6 +127,7 @@ set(CST_CORE_SOURCES
|
|||||||
src/tools/fs_tools.c
|
src/tools/fs_tools.c
|
||||||
src/tools/agent_tools.c
|
src/tools/agent_tools.c
|
||||||
src/tools/web.c
|
src/tools/web.c
|
||||||
|
src/tools/session_tools.c
|
||||||
src/tools/diff.c
|
src/tools/diff.c
|
||||||
src/undo.c
|
src/undo.c
|
||||||
src/permissions.c
|
src/permissions.c
|
||||||
@@ -110,29 +137,103 @@ set(CST_CORE_SOURCES
|
|||||||
src/agent_store.c
|
src/agent_store.c
|
||||||
src/json_baseline.c
|
src/json_baseline.c
|
||||||
src/creds.c
|
src/creds.c
|
||||||
|
src/provider_switch.c
|
||||||
src/mm_tool.c
|
src/mm_tool.c
|
||||||
src/models.c
|
src/models.c
|
||||||
src/model_registry.c
|
src/model_registry.c
|
||||||
src/metrics.c
|
src/metrics.c
|
||||||
|
src/net.c
|
||||||
|
src/http_srv.c
|
||||||
|
src/web_md.c
|
||||||
)
|
)
|
||||||
|
|
||||||
# Built-in model data as a C byte list (included by model_registry.c).
|
# Built-in model data as a C byte list (included by model_registry.c).
|
||||||
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
|
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
|
||||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
|
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
|
||||||
|
|
||||||
|
# The WebUI shell (web/*) as C byte arrays, the same trick as the model data.
|
||||||
|
file(GLOB CST_WEB_ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/web/*")
|
||||||
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CST_WEB_ASSETS})
|
||||||
|
set(_cst_web_inc "/* Generated by CMake from web/ - do not edit. */\n")
|
||||||
|
set(_cst_web_table "")
|
||||||
|
foreach(_f ${CST_WEB_ASSETS})
|
||||||
|
if(IS_DIRECTORY "${_f}")
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
get_filename_component(_name "${_f}" NAME)
|
||||||
|
string(REGEX REPLACE "[^A-Za-z0-9]" "_" _var "wui_asset_${_name}")
|
||||||
|
file(READ "${_f}" _hex HEX)
|
||||||
|
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _hex "${_hex}")
|
||||||
|
set(_ctype "application/octet-stream")
|
||||||
|
if(_name MATCHES "\\.html$")
|
||||||
|
set(_ctype "text/html; charset=utf-8")
|
||||||
|
elseif(_name MATCHES "\\.js$")
|
||||||
|
set(_ctype "text/javascript; charset=utf-8")
|
||||||
|
elseif(_name MATCHES "\\.css$")
|
||||||
|
set(_ctype "text/css; charset=utf-8")
|
||||||
|
elseif(_name MATCHES "\\.svg$")
|
||||||
|
set(_ctype "image/svg+xml")
|
||||||
|
endif()
|
||||||
|
string(APPEND _cst_web_inc "static const unsigned char ${_var}[] = {${_hex}0x00};\n")
|
||||||
|
string(APPEND _cst_web_table " {\"${_name}\", \"${_ctype}\", ${_var}, sizeof(${_var}) - 1},\n")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _cst_web_inc "static const wui_asset_t wui_assets[] = {\n${_cst_web_table}};\n")
|
||||||
|
string(APPEND _cst_web_inc "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));\n")
|
||||||
|
file(WRITE "${CST_GEN_DIR}/web_assets.inc" "${_cst_web_inc}")
|
||||||
|
if(NOT CST_BUILD_WEBUI)
|
||||||
|
list(APPEND CST_CORE_SOURCES src/webui_stub.c)
|
||||||
|
else()
|
||||||
|
list(APPEND CST_CORE_SOURCES src/webui.c)
|
||||||
|
endif()
|
||||||
|
|
||||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
|
||||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
|
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
|
||||||
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
|
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
|
||||||
include_directories("${CST_GEN_DIR}")
|
include_directories("${CST_GEN_DIR}")
|
||||||
|
|
||||||
|
if(NOT CST_BUILD_WEBUI)
|
||||||
|
add_compile_definitions(CST_NO_WEBUI=1)
|
||||||
|
endif()
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
||||||
include_directories(vendor/tre-regex)
|
include_directories(vendor/tre-regex)
|
||||||
list(APPEND CST_CORE_SOURCES
|
set(_CST_TRE_SOURCES
|
||||||
vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c
|
vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c
|
||||||
vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c)
|
vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c)
|
||||||
|
list(APPEND CST_CORE_SOURCES ${_CST_TRE_SOURCES})
|
||||||
|
# Upstream code, kept as is: CST_WIN_CFLAGS=-Werror must not apply to it.
|
||||||
|
set_source_files_properties(${_CST_TRE_SOURCES} PROPERTIES COMPILE_OPTIONS "-w")
|
||||||
endif()
|
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)
|
if(CST_BUILD_TUI)
|
||||||
list(APPEND CST_SOURCES src/textsel.c src/tui.c)
|
list(APPEND CST_SOURCES src/textsel.c src/tui.c)
|
||||||
@@ -155,14 +256,8 @@ if(NOT WIN32)
|
|||||||
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
|
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_link_libraries(cst-agent PRIVATE
|
# cstcore carries sashidown, curl, threads and libm as usage requirements.
|
||||||
"${SASHIDOWN_A}"
|
target_link_libraries(cst-agent PRIVATE cstcore)
|
||||||
CURL::libcurl
|
|
||||||
Threads::Threads
|
|
||||||
)
|
|
||||||
if(NOT WIN32)
|
|
||||||
target_link_libraries(cst-agent PRIVATE m)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
|
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
|
||||||
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
|
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
|
||||||
@@ -231,7 +326,7 @@ endif()
|
|||||||
enable_testing()
|
enable_testing()
|
||||||
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline
|
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline
|
||||||
test_replay_starvation test_replay_real test_mm_tool test_textsel
|
test_replay_starvation test_replay_real test_mm_tool test_textsel
|
||||||
test_markdown)
|
test_pdf_text test_markdown)
|
||||||
foreach(t ${_CST_SMALL_TESTS})
|
foreach(t ${_CST_SMALL_TESTS})
|
||||||
if(t STREQUAL test_textsel)
|
if(t STREQUAL test_textsel)
|
||||||
add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
|
add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
|
||||||
@@ -239,6 +334,12 @@ foreach(t ${_CST_SMALL_TESTS})
|
|||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(${t} PRIVATE advapi32 user32)
|
target_link_libraries(${t} PRIVATE advapi32 user32)
|
||||||
endif()
|
endif()
|
||||||
|
elseif(t STREQUAL test_pdf_text)
|
||||||
|
add_executable(${t} tests/${t}.c src/pdf_text.c src/os_compat.c src/os_win.c
|
||||||
|
src/util.c)
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(${t} PRIVATE advapi32 user32)
|
||||||
|
endif()
|
||||||
elseif(t STREQUAL test_markdown)
|
elseif(t STREQUAL test_markdown)
|
||||||
add_executable(${t} tests/${t}.c)
|
add_executable(${t} tests/${t}.c)
|
||||||
target_include_directories(${t} PRIVATE "${SD_DIR}/include")
|
target_include_directories(${t} PRIVATE "${SD_DIR}/include")
|
||||||
@@ -262,24 +363,35 @@ foreach(t ${_CST_SMALL_TESTS})
|
|||||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
||||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
target_link_libraries(${t} PRIVATE m)
|
target_link_libraries(${t} PRIVATE m Threads::Threads)
|
||||||
endif()
|
endif()
|
||||||
cst_win_exe(${t})
|
cst_win_exe(${t})
|
||||||
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
# Tests that link the whole core (no TUI).
|
# Tests that link the whole core (no TUI).
|
||||||
foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch
|
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||||
test_session_roundtrip test_attach
|
test_apply_patch test_session_roundtrip test_store test_attach
|
||||||
test_shell test_diff test_tools_fs test_registry
|
test_artifacts
|
||||||
test_undo test_config test_models test_model_registry test_sandbox
|
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
||||||
test_keymap test_shell_compact test_skills
|
test_undo test_config test_models test_model_registry test_sandbox
|
||||||
test_plugins test_mcp test_subagent test_lowil
|
test_keymap test_shell_compact test_skills
|
||||||
test_win_utf8 test_os_dir test_tmpdir)
|
test_plugins test_mcp test_subagent test_lowil
|
||||||
|
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)
|
||||||
|
endif()
|
||||||
|
foreach(t ${_CST_CORE_TESTS})
|
||||||
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
||||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
# The core now contains src/web_md.c, which needs sashidown's header and
|
||||||
|
# archive exactly like the Makefile build does.
|
||||||
|
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
|
||||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||||
target_link_libraries(${t} PRIVATE CURL::libcurl Threads::Threads)
|
target_link_libraries(${t} PRIVATE
|
||||||
|
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
|
||||||
|
add_dependencies(${t} sashidown)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
target_link_libraries(${t} PRIVATE m)
|
target_link_libraries(${t} PRIVATE m)
|
||||||
else()
|
else()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ PREFIX ?= /usr/local
|
|||||||
DESTDIR ?=
|
DESTDIR ?=
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null)
|
UNAME_S := $(shell uname -s 2>/dev/null)
|
||||||
UNAME_O := $(shell uname -o 2>/dev/null)
|
UNAME_O := $(shell uname -o 2>/dev/null)
|
||||||
|
comma := ,
|
||||||
|
|
||||||
# Android (Termux): pthreads live in libc.
|
# Android (Termux): pthreads live in libc.
|
||||||
PTHREAD_LIBS := -lpthread
|
PTHREAD_LIBS := -lpthread
|
||||||
@@ -26,6 +27,15 @@ CPPFLAGS += -D_DARWIN_C_SOURCE
|
|||||||
endif
|
endif
|
||||||
PKGC = $(PKG_ENV) $(PKG_CONFIG)
|
PKGC = $(PKG_ENV) $(PKG_CONFIG)
|
||||||
|
|
||||||
|
# STATIC=1: fully static link (the musl release builds).
|
||||||
|
STATIC ?= 0
|
||||||
|
LINK_STATIC :=
|
||||||
|
ifeq ($(STATIC),1)
|
||||||
|
PKGC += --static
|
||||||
|
LINK_STATIC := -static
|
||||||
|
endif
|
||||||
|
LDFLAGS += $(LINK_STATIC)
|
||||||
|
|
||||||
# Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide).
|
# Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide).
|
||||||
CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done)
|
CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done)
|
||||||
ifneq ($(CURSES_PC),)
|
ifneq ($(CURSES_PC),)
|
||||||
@@ -43,12 +53,37 @@ ifeq ($(strip $(CURL_LIBS)),)
|
|||||||
CURL_LIBS := -lcurl
|
CURL_LIBS := -lcurl
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# WebUI (HTTP + SSE front end): src/net.c src/http_srv.c src/web_md.c
|
||||||
|
# src/webui.c. CST_WEBUI=0 swaps in the stub (--web then prints a hint).
|
||||||
|
CST_WEBUI ?= 1
|
||||||
|
|
||||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||||
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
||||||
|
|
||||||
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
|
# tcc writes no rpath of its own, so shared libs found via pkg-config -L
|
||||||
|
# (Nix store, /opt prefixes) would not load at run time.
|
||||||
|
RPATH_LIBS :=
|
||||||
|
ifneq ($(findstring tcc,$(notdir $(CC))),)
|
||||||
|
ifneq ($(STATIC),1)
|
||||||
|
RPATH_LIBS := $(patsubst -L%,-Wl$(comma)-rpath$(comma)%,$(filter -L%,$(PKG_LIBS)))
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Keep POSIX declarations visible for shared test helpers (notably symlink()).
|
||||||
|
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
|
||||||
LDFLAGS += -L$(SD_DIR)
|
LDFLAGS += -L$(SD_DIR)
|
||||||
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
|
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
|
||||||
|
|
||||||
|
WEB_DIR := web
|
||||||
|
WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \
|
||||||
|
$(wildcard $(WEB_DIR)/*.css) $(wildcard $(WEB_DIR)/*.svg)
|
||||||
|
ifeq ($(CST_WEBUI),0)
|
||||||
|
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui_stub.c
|
||||||
|
# The stub body is guarded by this, so the switch has to define it too.
|
||||||
|
CPPFLAGS += -DCST_NO_WEBUI=1
|
||||||
|
else
|
||||||
|
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui.c
|
||||||
|
endif
|
||||||
|
|
||||||
CORE_SRC = \
|
CORE_SRC = \
|
||||||
$(CJSON_DIR)/cJSON.c \
|
$(CJSON_DIR)/cJSON.c \
|
||||||
@@ -61,6 +96,7 @@ CORE_SRC = \
|
|||||||
src/keymap.c \
|
src/keymap.c \
|
||||||
src/skills.c \
|
src/skills.c \
|
||||||
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
|
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
|
||||||
|
src/pdf_text.c \
|
||||||
src/session.c \
|
src/session.c \
|
||||||
src/tools/tools.c \
|
src/tools/tools.c \
|
||||||
src/tools/patch.c \
|
src/tools/patch.c \
|
||||||
@@ -68,6 +104,7 @@ CORE_SRC = \
|
|||||||
src/tools/fs_tools.c \
|
src/tools/fs_tools.c \
|
||||||
src/tools/agent_tools.c \
|
src/tools/agent_tools.c \
|
||||||
src/tools/web.c \
|
src/tools/web.c \
|
||||||
|
src/tools/session_tools.c \
|
||||||
src/tools/diff.c \
|
src/tools/diff.c \
|
||||||
src/undo.c \
|
src/undo.c \
|
||||||
src/permissions.c \
|
src/permissions.c \
|
||||||
@@ -76,15 +113,20 @@ CORE_SRC = \
|
|||||||
src/agent_loop.c \
|
src/agent_loop.c \
|
||||||
src/agent_store.c \
|
src/agent_store.c \
|
||||||
src/json_baseline.c \
|
src/json_baseline.c \
|
||||||
src/creds.c \
|
src/creds.c src/provider_switch.c \
|
||||||
src/mm_tool.c \
|
src/mm_tool.c \
|
||||||
src/models.c src/model_registry.c \
|
src/models.c src/model_registry.c \
|
||||||
src/metrics.c
|
src/metrics.c \
|
||||||
|
$(WEB_SRC)
|
||||||
|
|
||||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||||
OBJ = $(SRC:.c=.o)
|
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 \
|
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
|
||||||
.gen/models_builtin.inc
|
.gen/models_builtin.inc .gen/web_assets.inc
|
||||||
|
|
||||||
TESTS = \
|
TESTS = \
|
||||||
tests/test_ctl_frontier \
|
tests/test_ctl_frontier \
|
||||||
@@ -98,11 +140,17 @@ TESTS = \
|
|||||||
tests/test_sse_decode \
|
tests/test_sse_decode \
|
||||||
tests/test_apply_patch \
|
tests/test_apply_patch \
|
||||||
tests/test_session_roundtrip \
|
tests/test_session_roundtrip \
|
||||||
|
tests/test_store \
|
||||||
tests/test_attach \
|
tests/test_attach \
|
||||||
|
tests/test_artifacts \
|
||||||
|
tests/test_pdf_text \
|
||||||
tests/test_shell \
|
tests/test_shell \
|
||||||
tests/test_diff \
|
tests/test_diff \
|
||||||
tests/test_tools_fs \
|
tests/test_tools_fs \
|
||||||
|
tests/test_tools_ext \
|
||||||
tests/test_registry \
|
tests/test_registry \
|
||||||
|
tests/test_tool_meta \
|
||||||
|
tests/test_embed \
|
||||||
tests/test_undo \
|
tests/test_undo \
|
||||||
tests/test_config \
|
tests/test_config \
|
||||||
tests/test_models \
|
tests/test_models \
|
||||||
@@ -120,12 +168,24 @@ TESTS = \
|
|||||||
tests/test_win_utf8 \
|
tests/test_win_utf8 \
|
||||||
tests/test_os_dir \
|
tests/test_os_dir \
|
||||||
tests/test_tmpdir
|
tests/test_tmpdir
|
||||||
|
# test_webui drives the real server; there is nothing to test with the stub.
|
||||||
|
ifneq ($(CST_WEBUI),0)
|
||||||
|
TESTS += tests/test_webui
|
||||||
|
endif
|
||||||
|
|
||||||
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
|
TEST_SYS_LIBS = -lm $(PTHREAD_LIBS) $(LINK_STATIC)
|
||||||
|
TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
|
||||||
|
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
|
||||||
|
# keeps them, so the small tests link the OS layer too.
|
||||||
|
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
|
.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-gcc:
|
||||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||||
@@ -153,32 +213,40 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
|||||||
@mkdir -p .gen
|
@mkdir -p .gen
|
||||||
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
|
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
|
||||||
|
|
||||||
|
# The WebUI shell is embedded the same way, one array per file.
|
||||||
|
.gen/web_assets.inc: $(WEB_ASSETS) scripts/embed_web.sh
|
||||||
|
@mkdir -p .gen
|
||||||
|
@sh scripts/embed_web.sh $@ $(WEB_DIR)
|
||||||
|
|
||||||
%.o: %.c $(HDRS)
|
%.o: %.c $(HDRS)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
|
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
|
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h
|
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS)
|
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c
|
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c
|
tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h
|
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(TEST_OS_SRC)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_textsel: tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c include/cst_textsel.h
|
tests/test_textsel: tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) include/cst_textsel.h
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
|
tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TEST_OS_SRC)
|
||||||
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_pdf_text.c src/pdf_text.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
|
|
||||||
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
||||||
@@ -186,10 +254,14 @@ tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
|||||||
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
|
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
||||||
|
|
||||||
|
$(TESTS): sashidown-lib
|
||||||
|
|
||||||
check: $(TESTS)
|
check: $(TESTS)
|
||||||
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
||||||
|
|
||||||
# AddressSanitizer + UBSan build of the agent and all tests.
|
# AddressSanitizer + UBSan build of the agent and all tests.
|
||||||
|
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
|
||||||
|
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
|
||||||
asan:
|
asan:
|
||||||
$(MAKE) clean
|
$(MAKE) clean
|
||||||
$(MAKE) sashidown-lib
|
$(MAKE) sashidown-lib
|
||||||
@@ -199,7 +271,8 @@ demo: cst-agent
|
|||||||
./cst-agent --demo-ctl --workspace .
|
./cst-agent --demo-ctl --workspace .
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc
|
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
|
||||||
|
.gen/web_assets.inc
|
||||||
|
|
||||||
install: cst-agent
|
install: cst-agent
|
||||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ cst-agent --version
|
|||||||
cd ~/my-project && cst-agent
|
cd ~/my-project && cst-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
不想编译:[Release](https://git.whcself.xyz/WHC/CST-agent/releases) 里有静态链接的 Linux 单文件
|
||||||
|
(`cst-agent-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`,musl,任何发行版都能直接跑),解压后把 `cst-agent`
|
||||||
|
放进 `PATH` 即可;每个文件旁边有 `.sha256`。
|
||||||
|
|
||||||
Windows 10 1903+(PowerShell,不需要管理员):
|
Windows 10 1903+(PowerShell,不需要管理员):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -34,7 +38,8 @@ irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_window
|
|||||||
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
|
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
|
||||||
```
|
```
|
||||||
|
|
||||||
详见 [docs/README-windows.md](docs/README-windows.md)。
|
也可以下载 Release 里的安装包 `cst-agent-VERSION-windows-x86_64-setup.exe`(按用户安装、自动加 PATH、
|
||||||
|
开始菜单有 WebUI 入口),或便携版 zip。详见 [docs/README-windows.md](docs/README-windows.md)。
|
||||||
|
|
||||||
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
|
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
|
||||||
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
|
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
|
||||||
@@ -49,7 +54,8 @@ irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_window
|
|||||||
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
|
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
|
||||||
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
||||||
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
|
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
|
||||||
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
|
| Linux 静态发行包(musl 交叉) | [`scripts/package_linux.sh`](scripts/package_linux.sh) `x86_64\|x86\|aarch64` → `dist/*.tar.gz` |
|
||||||
|
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/`、zip 和安装包 |
|
||||||
| Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
|
| Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
|
||||||
|
|
||||||
### 迷你验收工程(论文案例)
|
### 迷你验收工程(论文案例)
|
||||||
@@ -86,6 +92,7 @@ cd CST-agent
|
|||||||
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
|
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
|
||||||
make check # 单元测试 + 截断实验(含真实录制流)
|
make check # 单元测试 + 截断实验(含真实录制流)
|
||||||
make asan # AddressSanitizer + UBSan 构建并跑全部测试
|
make asan # AddressSanitizer + UBSan 构建并跑全部测试
|
||||||
|
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
|
||||||
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
|
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -111,7 +118,7 @@ cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
|
|||||||
./scripts/bootstrap_windows.sh
|
./scripts/bootstrap_windows.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖:`gcc` 或 `clang`、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
||||||
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
||||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
||||||
@@ -120,12 +127,29 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
|
|||||||
|
|
||||||
| OS | Arch | 编译器 | Headless | TUI | Shell |
|
| OS | Arch | 编译器 | Headless | TUI | Shell |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Linux(glibc / musl) | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
|
| Linux(glibc / musl) | x86_64 / x86 / aarch64 | GCC, Clang, TinyCC | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
|
||||||
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
|
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
|
||||||
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
|
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
|
||||||
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
|
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
|
||||||
| Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
|
| Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
|
||||||
|
|
||||||
|
### WebUI(`--web`)
|
||||||
|
|
||||||
|
同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。
|
||||||
|
它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。
|
||||||
|
协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/
|
||||||
|
cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL
|
||||||
|
cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新
|
||||||
|
cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问
|
||||||
|
sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key
|
||||||
|
```
|
||||||
|
|
||||||
|
不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`);
|
||||||
|
`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。
|
||||||
|
|
||||||
### Termux
|
### Termux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -147,6 +171,18 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
|
|||||||
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
|
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
|
||||||
并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。
|
并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。
|
||||||
|
|
||||||
|
### Linux 静态发行包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/package_linux.sh x86_64 # 或 x86 / aarch64;CST_LINUX_CHECK=1 顺便跑 make check
|
||||||
|
```
|
||||||
|
|
||||||
|
用 Bootlin 的 musl 交叉 gcc(固定版本、校验 sha256)静态编译 mbedTLS、curl 8.22(只开 HTTP/HTTPS)和宽字符 ncurses,
|
||||||
|
再以 `STATIC=1` 和 `-Werror` 编出 `cst-agent`,产物是 `dist/cst-agent-VERSION-linux-ARCH.tar.gz`(+ `.sha256`)。
|
||||||
|
宿主需要 `cmake ninja make perl` 以及 `tic`/`infocmp` 和终端条目(Debian:`ncurses-bin ncurses-term`)。
|
||||||
|
ncurses 会去常见目录找 terminfo,找不到时用编进去的 `xterm-256color`、`xterm`、`screen-256color`、`tmux-256color`、`linux`、`vt100`。
|
||||||
|
CA 证书:`CST_CA_BUNDLE` → `SSL_CERT_FILE` → 各发行版的常见位置(Debian/Ubuntu、Fedora/RHEL、openSUSE、Alpine)。
|
||||||
|
|
||||||
### Windows 发行包
|
### Windows 发行包
|
||||||
|
|
||||||
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
|
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
|
||||||
@@ -155,10 +191,17 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
|
|||||||
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)和
|
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
|
||||||
`dist/cst-agent-VERSION-windows-x86_64.zip`。CI 在 `main` / `v*` tag 推送且交叉构建成功后会自动上传到
|
`dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `makensis` 时的安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`。
|
||||||
Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
|
|
||||||
本地:`GITEA_TOKEN=... scripts/release_windows.sh`。
|
### CI 与 Release
|
||||||
|
|
||||||
|
CI 用 TinyCC 校验(构建、`make check`、WebUI 端到端),通过后用 gcc(`-Werror`)出发行包:
|
||||||
|
Linux x86_64 / x86 / aarch64 静态包(x86 两种跑 `make check`,aarch64 用 qemu 冒烟),
|
||||||
|
Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|
||||||
|
推送到 `main` 或 `v*` tag 时每个任务把自己的产物上传到 Gitea Release `vVERSION`(同名资源会被替换);
|
||||||
|
仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
|
||||||
|
本地:`GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*.tar.gz dist/cst-agent-*.zip dist/cst-agent-*-setup.exe`。
|
||||||
|
|
||||||
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
|
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
|
||||||
|
|
||||||
@@ -190,18 +233,20 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
|
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
|
||||||
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
|
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
|
||||||
|
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
|
||||||
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
|
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
|
||||||
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
|
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
|
||||||
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
|
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
|
||||||
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
|
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
|
||||||
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
|
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
|
||||||
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) |
|
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
|
||||||
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
|
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
|
||||||
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
|
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
|
||||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 |
|
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 |
|
||||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
|
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
|
||||||
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
|
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
|
||||||
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
|
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
|
||||||
|
| `CST_CA_BUNDLE` | HTTPS 用的 CA 证书文件(默认:`SSL_CERT_FILE`、libcurl 自带路径、常见发行版位置;Windows 用系统证书库) |
|
||||||
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
|
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -228,16 +273,18 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `-N` / `--new` | 开新会话 |
|
| `-N` / `--new` | 开新会话 |
|
||||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
||||||
|
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
|
||||||
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
|
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
|
||||||
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
|
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
|
||||||
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
||||||
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
||||||
|
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
|
||||||
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
|
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
|
||||||
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
|
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
|
||||||
|
|
||||||
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
|
工作目录默认就是当前目录(`-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`,不会被误提交。
|
||||||
|
|
||||||
### 内置工具
|
### 内置工具
|
||||||
|
|
||||||
@@ -253,10 +300,18 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
||||||
|
| `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 开关) |
|
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
|
||||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
||||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
||||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||||
|
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||||
|
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||||
|
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
|
||||||
|
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||||
|
|
||||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
||||||
@@ -264,6 +319,26 @@ cst-agent --headless --prompt 'List files'
|
|||||||
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
|
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
|
||||||
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
||||||
|
|
||||||
|
### 工作模式(coding / general)
|
||||||
|
|
||||||
|
`--mode general`(或 TUI 里 `/mode general`、WebUI 运行设置里的「工作模式」、`CST_MODE=general`)把默认口径
|
||||||
|
从"代码助手"换成"通用助手":工作流从改代码/跑测试换成"先弄清要什么、再动手、给出处和不确定的地方",
|
||||||
|
工具说明从"仓库"改成"文件与数据",并说明 `web_fetch` 可以拿外部资料;`/status` 第一行显示当前模式,
|
||||||
|
切换后 history 里会留一条系统提示。默认仍是 `coding`,选择记在 `settings.json`;
|
||||||
|
`--tools minimal`(论文口径)的提示词按字节冻结,不受模式影响。
|
||||||
|
|
||||||
|
### 剪贴板与文档附件
|
||||||
|
|
||||||
|
- **剪贴板**:`clipboard_read` / `clipboard_write` 两个工具(写剪贴板是不可逆发射,要确认);
|
||||||
|
TUI 里 `/attach paste` 在剪贴板里没有图片时,把剪贴板的**文本**放进输入框(丢掉 CR 与控制字节,尊重输入长度上限)。
|
||||||
|
- **附件**(`/attach <path>`、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 image part;
|
||||||
|
**PDF 在摄取时就抽出文本层**(零依赖解析器:Flate / ASCIIHex / ASCII85、对象流、CID 字体 ToUnicode、
|
||||||
|
内联图像跳过),存的是文本、`mime` 仍是 `application/pdf`;其余文件必须是 UTF-8 文本。
|
||||||
|
文档以 **text part 内联**发出,所以不受 `CST_NO_IMAGES` 或非视觉模型影响;图片照旧会被换成一行"未发送"说明。
|
||||||
|
上限:单个附件 ≤16 MB(文档文本 ≤4 MiB),每条消息最多 8 个。
|
||||||
|
加密 PDF、扫描件(无文本层)会被明确拒绝并说明原因;
|
||||||
|
`view_image` 仍只收图片,文本/PDF 走 `/attach`。
|
||||||
|
|
||||||
### 权限、撤销与项目说明
|
### 权限、撤销与项目说明
|
||||||
|
|
||||||
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
|
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
|
||||||
@@ -302,7 +377,7 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
|||||||
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
||||||
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
|
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
|
||||||
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
|
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
|
||||||
- **自动压缩**:上一轮 prompt 用到模型上下文 80% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
||||||
|
|
||||||
### 扩展:Skills、插件、MCP、子 agent
|
### 扩展:Skills、插件、MCP、子 agent
|
||||||
|
|
||||||
@@ -382,6 +457,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
|
|||||||
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
|
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
|
||||||
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
|
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
|
||||||
| `/protocol [name]` | 切换工具调用协议 |
|
| `/protocol [name]` | 切换工具调用协议 |
|
||||||
|
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
|
||||||
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
|
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
|
||||||
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
|
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
|
||||||
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
|
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
|
||||||
@@ -391,6 +467,8 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
|
|||||||
| `/diff [path]` * | `git status` + `git diff` |
|
| `/diff [path]` * | `git status` + `git diff` |
|
||||||
| `/compact` | 手动压缩上下文 |
|
| `/compact` | 手动压缩上下文 |
|
||||||
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
|
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
|
||||||
|
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
|
||||||
|
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
|
||||||
| `/init` | 让 agent 写 `AGENTS.md` |
|
| `/init` | 让 agent 写 `AGENTS.md` |
|
||||||
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
|
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
|
||||||
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
|
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
|
"${CST_APP_ROOT}/src/web_md.c")
|
||||||
|
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"); the other platforms still use the Linux file, whose
|
||||||
|
# registration entry point is simply not called there.
|
||||||
|
if(ANDROID)
|
||||||
|
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.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/ui/UiTest.cpp
|
||||||
|
src/ui/ThemeIcons.cpp
|
||||||
|
src/ui/SessionImages.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()
|
||||||
|
|
||||||
|
# --- 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,414 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- [ ] 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,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,660 @@
|
|||||||
|
// 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 -------------------------------------------------------
|
||||||
|
header: ToolBar {
|
||||||
|
id: appBar
|
||||||
|
implicitHeight: Theme.headerHeight
|
||||||
|
background: Rectangle {
|
||||||
|
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
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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,177 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
anchors.centerIn: parent
|
||||||
|
/* "no result" is not an outcome: no exit code belongs on it. */
|
||||||
|
text: card.stateText + (card.hasResult && card.state !== "unfinished"
|
||||||
|
? " · exit " + card.exitCode : "")
|
||||||
|
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,266 @@
|
|||||||
|
/* AgentCore: the Qt wrapper around one cst_agent_t.
|
||||||
|
*
|
||||||
|
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
|
||||||
|
* is turned into a Qt signal, and the two hooks that need an answer
|
||||||
|
* (on_permission, on_ask_user) block the worker until the GUI replies. No
|
||||||
|
* HTTP, no socket: the GUI owns the engine in its own process. */
|
||||||
|
|
||||||
|
#ifndef CST_APP_AGENTCORE_H
|
||||||
|
#define CST_APP_AGENTCORE_H
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
#include <QWaitCondition>
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_tools.h"
|
||||||
|
|
||||||
|
class QThread;
|
||||||
|
|
||||||
|
class AgentCore : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
|
||||||
|
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
|
||||||
|
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
|
||||||
|
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QString model READ model NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
|
||||||
|
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
|
||||||
|
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
|
||||||
|
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit AgentCore(QObject *parent = nullptr);
|
||||||
|
~AgentCore() override;
|
||||||
|
|
||||||
|
/* Start the engine on a workspace. provider/model/apiKey may be empty to
|
||||||
|
* fall back to the saved credentials and the provider's default model. */
|
||||||
|
bool open(const QString &workspace, const QString &provider,
|
||||||
|
const QString &model, const QString &apiKey, QString *err);
|
||||||
|
void close();
|
||||||
|
|
||||||
|
bool running() const { return m_running; }
|
||||||
|
QString workspace() const { return m_workspace; }
|
||||||
|
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
|
||||||
|
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
|
||||||
|
QString model() const { return QString::fromUtf8(m_cfg.model); }
|
||||||
|
int approval() const { return m_cfg.approval; }
|
||||||
|
bool planMode() const { return m_cfg.plan_mode != 0; }
|
||||||
|
QString todo() const { return m_todo; }
|
||||||
|
QString sessionId() const;
|
||||||
|
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
||||||
|
* message looks the same here as it does in the WebUI. */
|
||||||
|
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
||||||
|
|
||||||
|
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
|
||||||
|
|
||||||
|
/* Stored sessions, newest first; one map each: id, title (a rename, else
|
||||||
|
* empty), firstUser, messages, mtime (ms since the epoch), current. */
|
||||||
|
Q_INVOKABLE QVariantList sessions() const;
|
||||||
|
/* The open session as transcript rows, in exactly the shape the live
|
||||||
|
* signals build (kind, role, text, name, state, detail, output, exitCode,
|
||||||
|
* complete, 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);
|
||||||
|
void turnFinished(int rc, const QString &err);
|
||||||
|
void runningChanged();
|
||||||
|
void workspaceChanged();
|
||||||
|
void modeChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class Turn;
|
||||||
|
void finishTurn(int rc, const QString &err);
|
||||||
|
void setRunning(bool on);
|
||||||
|
/* Take the engine's config over m_cfg again (a switch changed it) and say
|
||||||
|
* so. */
|
||||||
|
void syncConfig();
|
||||||
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||||
|
void afterSessionChange();
|
||||||
|
|
||||||
|
/* Hook trampolines (C, run on the worker thread). */
|
||||||
|
static void hookStatus(const char *msg, void *ud);
|
||||||
|
static void hookAssistant(const char *text, size_t len, void *ud);
|
||||||
|
static void hookThink(const char *text, size_t len, void *ud);
|
||||||
|
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
||||||
|
const char *detail, void *ud);
|
||||||
|
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||||
|
size_t len, int exit_code, void *ud);
|
||||||
|
static void 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;
|
||||||
|
/* 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,40 @@
|
|||||||
|
#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
|
||||||
|
/* 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
/* 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();
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
|
|
||||||
|
#endif /* CST_APP_TOOLPROVIDER_H */
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
/* 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 <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/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"));
|
||||||
|
parser.addOption(wsOpt);
|
||||||
|
parser.addOption(providerOpt);
|
||||||
|
parser.addOption(modelOpt);
|
||||||
|
parser.addOption(planOpt);
|
||||||
|
parser.addOption(uitestOpt);
|
||||||
|
parser.addOption(sizeOpt);
|
||||||
|
parser.process(app);
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
/* 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 <QJniObject>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <QtCore/private/qandroidextras_p.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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. */
|
||||||
|
template <typename Fn>
|
||||||
|
void onGuiThread(Fn &&fn) {
|
||||||
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||||
|
fn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||||
|
Qt::BlockingQueuedConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
jint sdkInt() {
|
||||||
|
static const jint sdk = QJniObject::getStaticField<jint>(
|
||||||
|
"android/os/Build$VERSION", "SDK_INT");
|
||||||
|
return sdk;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJniObject appContext() {
|
||||||
|
return QNativeInterface::QAndroidApplication::context();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,180 @@
|
|||||||
|
/* 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(); }
|
||||||
|
|
||||||
|
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,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,860 @@
|
|||||||
|
/* --uitest: drive the window with synthesized input.
|
||||||
|
*
|
||||||
|
* The GUI is checked the way a person uses it -- a mouse click on a segment,
|
||||||
|
* a finger tap on another, a narrowed window that has to collapse the
|
||||||
|
* sidebar -- instead of only loading the QML and hoping. Qt's test event
|
||||||
|
* synthesis does the pointing, so this runs on any platform, with or without
|
||||||
|
* a touchscreen, and can be scripted (see app/README.md). */
|
||||||
|
|
||||||
|
#include "UiTest.h"
|
||||||
|
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPointingDevice>
|
||||||
|
#include <QQuickItem>
|
||||||
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QQuickWindow>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "ui/SessionImages.h"
|
||||||
|
#include "ui/ThemeIcons.h"
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int g_fail;
|
||||||
|
|
||||||
|
void check(bool ok, const QString &what) {
|
||||||
|
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
||||||
|
std::fflush(stdout);
|
||||||
|
if (!ok)
|
||||||
|
g_fail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every item with this objectName in the visual tree. A view that has just
|
||||||
|
* been replayed can still hold the delegates it is about to drop, so a name
|
||||||
|
* can match more than one item for a moment. */
|
||||||
|
void collectNamed(QQuickItem *root, const QString &name,
|
||||||
|
QList<QQuickItem *> *out) {
|
||||||
|
if (!root)
|
||||||
|
return;
|
||||||
|
if (root->objectName() == name)
|
||||||
|
out->append(root);
|
||||||
|
const QList<QQuickItem *> kids = root->childItems();
|
||||||
|
for (QQuickItem *c : kids)
|
||||||
|
collectNamed(c, name, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
int countNamed(QQuickItem *root, const QString &prefix) {
|
||||||
|
if (!root)
|
||||||
|
return 0;
|
||||||
|
int n = root->objectName().startsWith(prefix) ? 1 : 0;
|
||||||
|
const QList<QQuickItem *> kids = root->childItems();
|
||||||
|
for (QQuickItem *c : kids)
|
||||||
|
n += countNamed(c, prefix);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An item by objectName. The QObject tree is tried first (it holds the named
|
||||||
|
* controls); a view's delegates can be owned by the model instead, so the
|
||||||
|
* visual tree -- what a finger can actually reach -- is walked as well. */
|
||||||
|
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
||||||
|
QList<QQuickItem *> hits;
|
||||||
|
collectNamed(win->contentItem(), name, &hits);
|
||||||
|
/* What a finger can reach is the one the test means: a delegate on its way
|
||||||
|
* out is still in the tree but invisible. */
|
||||||
|
for (QQuickItem *it : hits)
|
||||||
|
if (it->isVisible())
|
||||||
|
return it;
|
||||||
|
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
|
||||||
|
return it;
|
||||||
|
return hits.isEmpty() ? nullptr : hits.first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The centre of an item, in window coordinates. */
|
||||||
|
QPoint centre(QQuickItem *it) {
|
||||||
|
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
|
||||||
|
return p.toPoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clickMouse(QQuickWindow *win, const QString &name) {
|
||||||
|
QQuickItem *it = item(win, name);
|
||||||
|
if (!it || !it->isVisible()) {
|
||||||
|
check(false, QStringLiteral("mouse click %1: not there").arg(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
|
||||||
|
}
|
||||||
|
|
||||||
|
void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
|
||||||
|
QQuickItem *it = item(win, name);
|
||||||
|
if (!it || !it->isVisible()) {
|
||||||
|
check(false, QStringLiteral("touch tap %1: not there").arg(name));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QPoint p = centre(it);
|
||||||
|
QTest::touchEvent(win, dev).press(0, p, win);
|
||||||
|
QTest::touchEvent(win, dev).release(0, p, win);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resizeTo(QQuickWindow *win, int w, int h) {
|
||||||
|
win->resize(w, h);
|
||||||
|
QTest::qWait(250); /* let the layouts settle */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A stored session with a tool call in it, written through the store's own
|
||||||
|
* API, so the file is exactly what the engine reads back. */
|
||||||
|
QString writeFixtureSession(const QString &ws, const QString &id) {
|
||||||
|
const QByteArray w = ws.toUtf8();
|
||||||
|
char path[4096] = "";
|
||||||
|
if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path,
|
||||||
|
sizeof(path)) != 0)
|
||||||
|
return QString();
|
||||||
|
cst_session_t *s = cst_session_open(path);
|
||||||
|
if (!s)
|
||||||
|
return QString();
|
||||||
|
cst_message_t m;
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_USER;
|
||||||
|
m.content = strdup("what is here?");
|
||||||
|
/* The prompt carries a picture, stored exactly the way the engine stores
|
||||||
|
* one (the file next to the session, the message naming it). */
|
||||||
|
{
|
||||||
|
QImage pic(320, 200, QImage::Format_RGB32);
|
||||||
|
pic.fill(Qt::darkCyan);
|
||||||
|
QPainter painter(&pic);
|
||||||
|
painter.fillRect(QRect(0, 0, 160, 100), Qt::darkYellow);
|
||||||
|
painter.end();
|
||||||
|
QByteArray png;
|
||||||
|
QBuffer buf(&png);
|
||||||
|
buf.open(QIODevice::WriteOnly);
|
||||||
|
pic.save(&buf, "PNG");
|
||||||
|
char aerr[256] = "";
|
||||||
|
m.attach = cst_attach_import_bytes(path, "shot.png", png.constData(),
|
||||||
|
static_cast<size_t>(png.size()), aerr,
|
||||||
|
sizeof(aerr));
|
||||||
|
m.nattach = m.attach ? 1 : 0;
|
||||||
|
}
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
cst_attachments_free(m.attach, m.nattach);
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.content = strdup("let me look. ");
|
||||||
|
m.tool_calls_json = strdup(
|
||||||
|
"[{\"id\":\"call_1\",\"type\":\"function\",\"function\":"
|
||||||
|
"{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":"
|
||||||
|
"\\\"README.md\\\"}\"}}]");
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_TOOL;
|
||||||
|
m.content = strdup("hello\n");
|
||||||
|
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1");
|
||||||
|
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
/* A delivered file (deliver_file): the copy the agent hands to the person,
|
||||||
|
* marked for_user -- the row a file card is drawn from. */
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.tool_calls_json = strdup(
|
||||||
|
"[{\"id\":\"call_2\",\"type\":\"function\",\"function\":"
|
||||||
|
"{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":"
|
||||||
|
"\\\"report.md\\\"}\"}}]");
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
{
|
||||||
|
const QString src = ws + QStringLiteral("/report.md");
|
||||||
|
QFile rf(src);
|
||||||
|
if (rf.open(QIODevice::WriteOnly))
|
||||||
|
rf.write("# report\n");
|
||||||
|
rf.close();
|
||||||
|
char aerr[256] = "";
|
||||||
|
m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL,
|
||||||
|
aerr, sizeof(aerr));
|
||||||
|
m.nattach = m.attach ? 1 : 0;
|
||||||
|
}
|
||||||
|
m.role = CST_ROLE_TOOL;
|
||||||
|
m.content = strdup("delivered report.md\n");
|
||||||
|
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2");
|
||||||
|
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file");
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
cst_attachments_free(m.attach, m.nattach);
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.role = CST_ROLE_ASSISTANT;
|
||||||
|
m.reasoning = strdup("checked the file");
|
||||||
|
m.content = strdup("a repo.");
|
||||||
|
cst_session_append(s, &m);
|
||||||
|
cst_session_close(s);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A picture of an interesting state on request: the only way to look at the
|
||||||
|
* layout on a machine without a screen (CST_APP_SHOT=shot.png makes the run
|
||||||
|
* write shot.png at the end and shot-settings.png while the settings dialog
|
||||||
|
* is open). */
|
||||||
|
void maybeShot(QQuickWindow *win, const QString &suffix) {
|
||||||
|
if (!qEnvironmentVariableIsSet("CST_APP_SHOT"))
|
||||||
|
return;
|
||||||
|
const QString base = qEnvironmentVariable("CST_APP_SHOT");
|
||||||
|
const QString path =
|
||||||
|
suffix.isEmpty() ? base
|
||||||
|
: base.endsWith(QLatin1String(".png"))
|
||||||
|
? base.left(base.size() - 4) + suffix + QStringLiteral(".png")
|
||||||
|
: base + suffix + QStringLiteral(".png");
|
||||||
|
std::printf("%s %s\n",
|
||||||
|
win->grabWindow().save(path) ? "note wrote" : "note cannot write",
|
||||||
|
qPrintable(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int runUiTest(AgentCore &core, const QString &size) {
|
||||||
|
int width = 1000, height = 720;
|
||||||
|
const int x = size.indexOf(QLatin1Char('x'));
|
||||||
|
if (x > 0) {
|
||||||
|
const int w = size.left(x).toInt(), h = size.mid(x + 1).toInt();
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
width = w;
|
||||||
|
height = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A workspace is never empty on a second run: the engine opens the last
|
||||||
|
* session of the store (cst_agent_open()), so the window is created with a
|
||||||
|
* transcript already in the engine. Put one there before the QML loads,
|
||||||
|
* which is exactly what a restart looks like. */
|
||||||
|
/* A file card hands its file to the system: in a test run nothing may open
|
||||||
|
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
||||||
|
qputenv("CST_APP_NO_OPEN", "1");
|
||||||
|
const QString resumed =
|
||||||
|
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
||||||
|
check(!resumed.isEmpty() && core.switchSession(resumed),
|
||||||
|
QStringLiteral("the engine has a session open before the window exists"));
|
||||||
|
|
||||||
|
QQmlApplicationEngine engine;
|
||||||
|
installThemeIcons(&engine); /* the window's icons, same as main() */
|
||||||
|
/* image://cstattach/: the session's stored images, pointed at the session
|
||||||
|
* the engine has open (and re-pointed on every switch). */
|
||||||
|
SessionImages *images = 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"), width);
|
||||||
|
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), height);
|
||||||
|
engine.load(QUrl(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml")));
|
||||||
|
if (engine.rootObjects().isEmpty()) {
|
||||||
|
std::printf("FAIL the window did not load\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
QQuickWindow *win = qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
|
||||||
|
if (!win) {
|
||||||
|
std::printf("FAIL the root object is not a window\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
QTest::qWait(250);
|
||||||
|
|
||||||
|
/* --- the window opens on the session the engine already had ----------- */
|
||||||
|
{
|
||||||
|
QQuickItem *tr = item(win, QStringLiteral("transcript"));
|
||||||
|
const int rows = tr ? tr->property("count").toInt() : -1;
|
||||||
|
check(rows == 6,
|
||||||
|
QStringLiteral("the open session is on screen at startup (%1 rows)")
|
||||||
|
.arg(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
QQuickItem *docked = item(win, QStringLiteral("dockedSidebar"));
|
||||||
|
QQuickItem *navButton = item(win, QStringLiteral("navButton"));
|
||||||
|
QQuickItem *navPanel = item(win, QStringLiteral("navPanel"));
|
||||||
|
QQuickItem *composer = item(win, QStringLiteral("composerInput"));
|
||||||
|
QPointingDevice *touch = QTest::createTouchDevice();
|
||||||
|
|
||||||
|
/* --- a desktop window: the sidebar is docked, the drawer is gone --- */
|
||||||
|
check(docked && docked->isVisible(), QStringLiteral("wide window: sidebar docked"));
|
||||||
|
check(navButton && !navButton->isVisible(),
|
||||||
|
QStringLiteral("wide window: no drawer button"));
|
||||||
|
check(core.mode() == QLatin1String("coding"),
|
||||||
|
QStringLiteral("engine starts in coding mode"));
|
||||||
|
|
||||||
|
/* --- mouse: pick the other mode --- */
|
||||||
|
clickMouse(win, QStringLiteral("modeGeneral"));
|
||||||
|
check(core.mode() == QLatin1String("general"),
|
||||||
|
QStringLiteral("mouse click on \"general\" reaches the engine"));
|
||||||
|
check(item(win, QStringLiteral("modeGeneral"))->property("current").toBool(),
|
||||||
|
QStringLiteral("mouse click marks \"general\" as current"));
|
||||||
|
|
||||||
|
/* --- touch: tap the first mode back --- */
|
||||||
|
tapTouch(win, touch, QStringLiteral("modeCoding"));
|
||||||
|
check(core.mode() == QLatin1String("coding"),
|
||||||
|
QStringLiteral("touch tap on \"coding\" reaches the engine"));
|
||||||
|
|
||||||
|
/* --- mouse: the plan switch is a two-way control --- */
|
||||||
|
clickMouse(win, QStringLiteral("planSwitch"));
|
||||||
|
check(core.planMode(), QStringLiteral("plan switch turns plan mode on"));
|
||||||
|
clickMouse(win, QStringLiteral("planSwitch"));
|
||||||
|
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
|
||||||
|
|
||||||
|
/* --- keyboard: typing in the composer arms Send --- */
|
||||||
|
QQuickItem *send = item(win, QStringLiteral("sendButton"));
|
||||||
|
if (composer && send) {
|
||||||
|
composer->forceActiveFocus();
|
||||||
|
/* QTest::keyClicks() is QWidget-only; a window takes one key at a time. */
|
||||||
|
for (const QChar &c : QStringLiteral("hello"))
|
||||||
|
QTest::keyClick(win, c.toLatin1());
|
||||||
|
check(send->property("enabled").toBool(),
|
||||||
|
QStringLiteral("typing arms the send button"));
|
||||||
|
QTest::keyClick(win, Qt::Key_A, Qt::ControlModifier);
|
||||||
|
QTest::keyClick(win, Qt::Key_Backspace);
|
||||||
|
check(!send->property("enabled").toBool(),
|
||||||
|
QStringLiteral("an empty composer disarms it"));
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("composer and send button are reachable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- sessions: the sidebar is the store's face ------------------------ */
|
||||||
|
QQuickItem *list = item(win, QStringLiteral("transcript"));
|
||||||
|
const QString fixture =
|
||||||
|
writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix"));
|
||||||
|
check(!fixture.isEmpty() && list != nullptr,
|
||||||
|
QStringLiteral("a session fixture with a tool call is in the store"));
|
||||||
|
QMetaObject::invokeMethod(win, "reloadSessions");
|
||||||
|
QTest::qWait(150);
|
||||||
|
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
|
||||||
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
||||||
|
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
||||||
|
const int listed = win->property("sessionsView").toList().size();
|
||||||
|
check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr,
|
||||||
|
QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)")
|
||||||
|
.arg(rowsSeen)
|
||||||
|
.arg(listed));
|
||||||
|
check(core.sessions().size() >= 2,
|
||||||
|
QStringLiteral("the store lists it next to the open one"));
|
||||||
|
|
||||||
|
/* the fixture is: user, agent + call, result, agent + call, delivered
|
||||||
|
* file, thought, agent */
|
||||||
|
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||||
|
check(core.sessionId() == fixture,
|
||||||
|
QStringLiteral("a click on a row switches the engine"));
|
||||||
|
check(list && list->property("count").toInt() == 6,
|
||||||
|
QStringLiteral("the reopened transcript is replayed into the view"));
|
||||||
|
{
|
||||||
|
bool toolOk = false;
|
||||||
|
const QVariantList rows = core.history();
|
||||||
|
for (const QVariant &v : rows) {
|
||||||
|
const QVariantMap r = v.toMap();
|
||||||
|
if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") &&
|
||||||
|
r.value(QStringLiteral("name")).toString() == QLatin1String("read_file"))
|
||||||
|
toolOk = r.value(QStringLiteral("complete")).toBool() &&
|
||||||
|
r.value(QStringLiteral("output")).toString() ==
|
||||||
|
QLatin1String("hello\n");
|
||||||
|
}
|
||||||
|
check(toolOk, QStringLiteral("a tool result is paired with its call"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- a delivered file: a card with open and share --------------------- */
|
||||||
|
{
|
||||||
|
/* The view is re-laid out off the click; give it the frame it needs. */
|
||||||
|
QTest::qWait(150);
|
||||||
|
const int cards =
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||||
|
check(cards == 1,
|
||||||
|
QStringLiteral("a delivered file is a card in the tool row (%1)")
|
||||||
|
.arg(cards));
|
||||||
|
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
|
||||||
|
item(win, QStringLiteral("fileShare0")) != nullptr,
|
||||||
|
QStringLiteral("the card carries open and share"));
|
||||||
|
win->setProperty("statusText", QString());
|
||||||
|
clickMouse(win, QStringLiteral("fileOpen0"));
|
||||||
|
const QString opened = win->property("statusText").toString();
|
||||||
|
check(opened.startsWith(QLatin1String("opened ")),
|
||||||
|
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
||||||
|
clickMouse(win, QStringLiteral("fileShare0"));
|
||||||
|
const QString shared = win->property("statusText").toString();
|
||||||
|
check(shared.contains(QLatin1String("report.md")),
|
||||||
|
QStringLiteral("Share names the file (%1)").arg(shared));
|
||||||
|
const QString refused =
|
||||||
|
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
||||||
|
check(refused.contains(QLatin1String("no such file")),
|
||||||
|
QStringLiteral("a name this session does not own is refused (%1)")
|
||||||
|
.arg(refused));
|
||||||
|
/* And the card says it: a tap that cannot work must not look dead. */
|
||||||
|
QQuickItem *strip = nullptr;
|
||||||
|
{
|
||||||
|
QList<QQuickItem *> hits;
|
||||||
|
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
|
||||||
|
for (QQuickItem *it : hits)
|
||||||
|
if (it->isVisible())
|
||||||
|
strip = it;
|
||||||
|
}
|
||||||
|
bool called = false;
|
||||||
|
if (strip)
|
||||||
|
called = QMetaObject::invokeMethod(
|
||||||
|
strip, "openFile",
|
||||||
|
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
|
||||||
|
const QVariant err = strip ? strip->property("errorText") : QVariant();
|
||||||
|
check(called && err.toString().contains(QLatin1String("no such file")),
|
||||||
|
QStringLiteral("the card shows why it could not open it (%1)")
|
||||||
|
.arg(err.toString()));
|
||||||
|
QTest::qWait(150);
|
||||||
|
QQuickItem *errText = item(win, QStringLiteral("fileError"));
|
||||||
|
check(errText && errText->isVisible() &&
|
||||||
|
errText->property("text").toString().contains(
|
||||||
|
QLatin1String("no such file")),
|
||||||
|
QStringLiteral("and the line is where the card is"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* new sessions: the engine moves, the view empties, the store grows */
|
||||||
|
const int before = core.sessions().size();
|
||||||
|
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||||
|
const QString first = core.sessionId();
|
||||||
|
check(first != fixture, QStringLiteral("New starts another session"));
|
||||||
|
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||||
|
const QString second = core.sessionId();
|
||||||
|
check(second != first, QStringLiteral("New starts another one each time"));
|
||||||
|
check(core.sessions().size() == before + 2,
|
||||||
|
QStringLiteral("both new sessions are in the store"));
|
||||||
|
check(list && list->property("count").toInt() == 0,
|
||||||
|
QStringLiteral("a fresh session starts with an empty view"));
|
||||||
|
|
||||||
|
/* the x drops a stored session and leaves the open one alone */
|
||||||
|
QTest::qWait(150);
|
||||||
|
clickMouse(win, QStringLiteral("sessionDelete:") + first);
|
||||||
|
check(core.sessions().size() == before + 1,
|
||||||
|
QStringLiteral("the x drops a stored session"));
|
||||||
|
check(core.sessionId() == second,
|
||||||
|
QStringLiteral("deleting another session leaves the open one alone"));
|
||||||
|
|
||||||
|
/* and back to the fixture, which still has its six rows */
|
||||||
|
QTest::qWait(150);
|
||||||
|
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||||
|
check(core.sessionId() == fixture && list &&
|
||||||
|
list->property("count").toInt() == 6,
|
||||||
|
QStringLiteral("switching back replays it again"));
|
||||||
|
{
|
||||||
|
bool promptImage = false;
|
||||||
|
const QVariantList rows = core.history();
|
||||||
|
for (const QVariant &v : rows) {
|
||||||
|
const QVariantMap r = v.toMap();
|
||||||
|
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
||||||
|
r.value(QStringLiteral("images")).toString().contains(
|
||||||
|
QLatin1String("|")))
|
||||||
|
promptImage = true;
|
||||||
|
}
|
||||||
|
check(promptImage,
|
||||||
|
QStringLiteral("a stored prompt image comes back with its row"));
|
||||||
|
/* The rows are in the model by now; the cards -- and the thumbnails in
|
||||||
|
* them -- are built on the view's next layout pass. A frame forces it,
|
||||||
|
* and that frame is also the picture a person looks at. */
|
||||||
|
maybeShot(win, QStringLiteral("-images"));
|
||||||
|
QTest::qWait(150);
|
||||||
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
||||||
|
QVariant v;
|
||||||
|
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
||||||
|
Q_ARG(QVariant, 0));
|
||||||
|
std::printf("note: replayed row 0: %s\n", qPrintable(v.toString()));
|
||||||
|
std::printf("note: %d strips and %d thumbs in the tree\n",
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("imageThumb")));
|
||||||
|
}
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >= 1,
|
||||||
|
QStringLiteral("the replayed transcript shows it as a thumbnail (%1)")
|
||||||
|
.arg(countNamed(win->contentItem(), QStringLiteral("imageThumb"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- provider, model, key: the settings dialog ------------------------ */
|
||||||
|
check(core.providers().size() > 0 &&
|
||||||
|
!core.providers().first().toMap().value(QStringLiteral("name"))
|
||||||
|
.toString()
|
||||||
|
.isEmpty(),
|
||||||
|
QStringLiteral("the provider presets are readable"));
|
||||||
|
check(core.useProvider(QStringLiteral("no-such-provider"), QString(), QString())
|
||||||
|
.contains(QLatin1String("no such provider")),
|
||||||
|
QStringLiteral("switching to an unknown provider is refused"));
|
||||||
|
check(core.usage().contains(QStringLiteral("requests")),
|
||||||
|
QStringLiteral("the token counters are readable"));
|
||||||
|
|
||||||
|
clickMouse(win, QStringLiteral("settingsButton"));
|
||||||
|
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
|
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
|
||||||
|
QStringLiteral("the dialog lists the providers"));
|
||||||
|
maybeShot(win, QStringLiteral("-settings"));
|
||||||
|
QQuickItem *mfield = item(win, QStringLiteral("settingsModel"));
|
||||||
|
if (mfield && panel) {
|
||||||
|
mfield->setProperty("text", QStringLiteral("cst-test-model"));
|
||||||
|
clickMouse(win, QStringLiteral("settingsApply"));
|
||||||
|
check(core.model() == QLatin1String("cst-test-model"),
|
||||||
|
QStringLiteral("the model picked in the dialog reaches the engine"));
|
||||||
|
QQuickItem *note = item(win, QStringLiteral("settingsNote"));
|
||||||
|
check(note && note->property("text").toString().contains(
|
||||||
|
QLatin1String("cst-test-model")),
|
||||||
|
QStringLiteral("the dialog says what it is using now"));
|
||||||
|
clickMouse(win, QStringLiteral("settingsClose"));
|
||||||
|
check(!panel->isVisible(), QStringLiteral("Close puts the dialog away"));
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("the dialog has a model field"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- advanced: a custom provider, added and removed ------------------- */
|
||||||
|
/* The run has a scratch config dir (main.cpp points XDG_CONFIG_HOME at it),
|
||||||
|
* so the engine's user model file is this run's own. */
|
||||||
|
check(core.genParams(QStringLiteral("deepseek")).value(QStringLiteral("temperature"))
|
||||||
|
.toDouble() == 0.0,
|
||||||
|
QStringLiteral("no generation parameters are saved to begin with"));
|
||||||
|
check(core.setGenParams(QStringLiteral("deepseek"), 0.2, 0.9, 4096).isEmpty(),
|
||||||
|
QStringLiteral("generation parameters are accepted"));
|
||||||
|
{
|
||||||
|
const QVariantMap g = core.genParams(QStringLiteral("deepseek"));
|
||||||
|
check(g.value(QStringLiteral("temperature")).toDouble() > 0.19 &&
|
||||||
|
g.value(QStringLiteral("max_tokens")).toInt() == 4096,
|
||||||
|
QStringLiteral("they are remembered per provider (%1 / %2 / %3)")
|
||||||
|
.arg(g.value(QStringLiteral("temperature")).toDouble())
|
||||||
|
.arg(g.value(QStringLiteral("top_p")).toDouble())
|
||||||
|
.arg(g.value(QStringLiteral("max_tokens")).toInt()));
|
||||||
|
}
|
||||||
|
check(core.addProvider(QStringLiteral("cst-scratch"), QStringLiteral("https://api.example.com/v1"),
|
||||||
|
QStringLiteral("CST_SCRATCH_KEY"), QStringLiteral("scratch-1"),
|
||||||
|
QStringLiteral("scratch-1, scratch-2"))
|
||||||
|
.isEmpty(),
|
||||||
|
QStringLiteral("a provider the presets do not know is added"));
|
||||||
|
{
|
||||||
|
bool found = false, custom = false;
|
||||||
|
QString base, models;
|
||||||
|
const QVariantList list = core.providers();
|
||||||
|
for (const QVariant &v : list) {
|
||||||
|
const QVariantMap p = v.toMap();
|
||||||
|
if (p.value(QStringLiteral("name")).toString() != QLatin1String("cst-scratch"))
|
||||||
|
continue;
|
||||||
|
found = true;
|
||||||
|
custom = p.value(QStringLiteral("custom")).toBool();
|
||||||
|
base = p.value(QStringLiteral("base")).toString();
|
||||||
|
models = core.modelChoices(QStringLiteral("cst-scratch")).join(QLatin1Char(','));
|
||||||
|
}
|
||||||
|
check(found && custom && base.contains(QLatin1String("api.example.com")),
|
||||||
|
QStringLiteral("it shows up in the provider list as a custom one"));
|
||||||
|
check(models.contains(QLatin1String("scratch-1")) &&
|
||||||
|
models.contains(QLatin1String("scratch-2")),
|
||||||
|
QStringLiteral("the models it lists are the picker's (%1)").arg(models));
|
||||||
|
}
|
||||||
|
check(!core.addProvider(QStringLiteral("bad name"), QStringLiteral("https://x/v1"),
|
||||||
|
QString(), QString(), QString())
|
||||||
|
.isEmpty(),
|
||||||
|
QStringLiteral("a name that is not a provider name is refused"));
|
||||||
|
check(core.removeProvider(QStringLiteral("cst-scratch")) == 1,
|
||||||
|
QStringLiteral("the custom provider can be removed again"));
|
||||||
|
{
|
||||||
|
bool gone = true;
|
||||||
|
const QVariantList list = core.providers();
|
||||||
|
for (const QVariant &v : list)
|
||||||
|
if (v.toMap().value(QStringLiteral("name")).toString() ==
|
||||||
|
QLatin1String("cst-scratch"))
|
||||||
|
gone = false;
|
||||||
|
check(gone, QStringLiteral("and it is gone from the list"));
|
||||||
|
}
|
||||||
|
check(core.removeProvider(QStringLiteral("deepseek")) == 0,
|
||||||
|
QStringLiteral("a preset cannot be removed (there is no user entry to drop)"));
|
||||||
|
check(core.setGenParams(QStringLiteral("deepseek"), 0, 0, 0).isEmpty() &&
|
||||||
|
core.genParams(QStringLiteral("deepseek"))
|
||||||
|
.value(QStringLiteral("temperature"))
|
||||||
|
.toDouble() == 0.0,
|
||||||
|
QStringLiteral("parameters can be cleared again"));
|
||||||
|
|
||||||
|
/* The dialog drives the same two engine calls: open it, fold the advanced
|
||||||
|
* section out and put a provider in through the form. */
|
||||||
|
clickMouse(win, QStringLiteral("settingsButton"));
|
||||||
|
QTest::qWait(150); /* the dialog's layout settles before it is clicked into */
|
||||||
|
if (panel && mfield) {
|
||||||
|
clickMouse(win, QStringLiteral("settingsAdvanced"));
|
||||||
|
QTest::qWait(150); /* the fold-out re-lays the body out */
|
||||||
|
QQuickItem *nfield = item(win, QStringLiteral("advName"));
|
||||||
|
QQuickItem *bfield = item(win, QStringLiteral("advBase"));
|
||||||
|
check(nfield && nfield->isVisible(), QStringLiteral("Advanced options opens in the dialog"));
|
||||||
|
maybeShot(win, QStringLiteral("-advanced"));
|
||||||
|
if (nfield && bfield) {
|
||||||
|
nfield->setProperty("text", QStringLiteral("cst-form"));
|
||||||
|
bfield->setProperty("text", QStringLiteral("https://form.example.com/v1"));
|
||||||
|
/* The form sits at the end of the dialog's scrolling body: scroll it
|
||||||
|
* into view so the click lands on the button, not on the frame. */
|
||||||
|
QQuickItem *scroller = item(win, QStringLiteral("settingsBody"));
|
||||||
|
if (scroller) {
|
||||||
|
scroller->setProperty("contentY", scroller->property("contentHeight").toReal() -
|
||||||
|
scroller->height());
|
||||||
|
QTest::qWait(120); /* the flickable settles */
|
||||||
|
}
|
||||||
|
clickMouse(win, QStringLiteral("settingsSaveProvider"));
|
||||||
|
bool found = false;
|
||||||
|
const QVariantList list = core.providers();
|
||||||
|
for (const QVariant &v : list)
|
||||||
|
if (v.toMap().value(QStringLiteral("name")).toString() ==
|
||||||
|
QLatin1String("cst-form"))
|
||||||
|
found = true;
|
||||||
|
check(found, QStringLiteral("the form in the dialog adds a provider"));
|
||||||
|
QQuickItem *rm = item(win, QStringLiteral("settingsRemoveProvider"));
|
||||||
|
check(rm && rm->property("enabled").toBool(),
|
||||||
|
QStringLiteral("its entry can be removed from the form"));
|
||||||
|
clickMouse(win, QStringLiteral("settingsRemoveProvider"));
|
||||||
|
check(core.removeProvider(QStringLiteral("cst-form")) == 0,
|
||||||
|
QStringLiteral("the form's provider is gone again"));
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("the advanced form has name and base URL fields"));
|
||||||
|
}
|
||||||
|
clickMouse(win, QStringLiteral("settingsClose"));
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("the settings dialog can be reopened"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- attachments: a file goes next to the session -------------------- */
|
||||||
|
const QString att = core.workspace() + QStringLiteral("/note.txt");
|
||||||
|
{
|
||||||
|
QFile f(att);
|
||||||
|
f.open(QIODevice::WriteOnly);
|
||||||
|
f.write("hello from a fixture\n");
|
||||||
|
}
|
||||||
|
check(core.attachFile(att).isEmpty(),
|
||||||
|
QStringLiteral("a file on disk can be attached"));
|
||||||
|
check(core.pendingAttachments().size() == 1,
|
||||||
|
QStringLiteral("the engine reports one attachment for the next prompt"));
|
||||||
|
check(core.pendingAttachments().first().contains(QLatin1String("note.txt")),
|
||||||
|
QStringLiteral("the attachment names the file it came from"));
|
||||||
|
check(!core.attachFile(QStringLiteral("/no/such/file-here")).isEmpty(),
|
||||||
|
QStringLiteral("a missing file is refused with a reason"));
|
||||||
|
QTest::qWait(120);
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("attachChip")) == 1,
|
||||||
|
QStringLiteral("the composer lists it"));
|
||||||
|
maybeShot(win, QStringLiteral("-attach"));
|
||||||
|
clickMouse(win, QStringLiteral("clearAttachments"));
|
||||||
|
check(core.pendingAttachments().isEmpty(),
|
||||||
|
QStringLiteral("clear all drops them"));
|
||||||
|
|
||||||
|
/* the paste path: an image on the clipboard becomes an attachment */
|
||||||
|
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||||
|
if (gui && gui->clipboard()) {
|
||||||
|
QImage img(8, 8, QImage::Format_RGB32);
|
||||||
|
img.fill(Qt::red);
|
||||||
|
gui->clipboard()->setImage(img);
|
||||||
|
check(core.clipboardHasImage(), QStringLiteral("the clipboard image is seen"));
|
||||||
|
check(core.attachClipboard().isEmpty() &&
|
||||||
|
core.pendingAttachments().size() == 1,
|
||||||
|
QStringLiteral("an image on the clipboard is attached"));
|
||||||
|
core.clearPending();
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("there is a clipboard to paste from"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- tool calls: two calls of one tool are two cards ------------------ */
|
||||||
|
const int rows0 = list ? list->property("count").toInt() : -1;
|
||||||
|
auto rowAt = [&](int i) {
|
||||||
|
QVariant v;
|
||||||
|
QVariantMap r;
|
||||||
|
if (!QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
||||||
|
Q_ARG(QVariant, i)))
|
||||||
|
return r;
|
||||||
|
return QJsonDocument::fromJson(v.toString().toUtf8()).object().toVariantMap();
|
||||||
|
};
|
||||||
|
auto event4 = [&](const char *fn, const QVariant &a, const QVariant &b,
|
||||||
|
const QVariant &c, const QVariant &d) {
|
||||||
|
return QMetaObject::invokeMethod(win, fn, Q_ARG(QVariant, a),
|
||||||
|
Q_ARG(QVariant, b), Q_ARG(QVariant, c),
|
||||||
|
Q_ARG(QVariant, d));
|
||||||
|
};
|
||||||
|
check(event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-1"),
|
||||||
|
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"vendor\"}")) &&
|
||||||
|
list->property("count").toInt() == rows0 + 1,
|
||||||
|
QStringLiteral("a tool call opens a card"));
|
||||||
|
event4("engineToolEvent", QStringLiteral("run"), QStringLiteral("call-1"),
|
||||||
|
QStringLiteral("list_dir"), QString());
|
||||||
|
/* the same tool a second time, while the first call is still open: pairing
|
||||||
|
by name is what left the first card on "running" forever */
|
||||||
|
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-2"),
|
||||||
|
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"src\"}"));
|
||||||
|
event4("engineToolResult", QStringLiteral("call-1"), QStringLiteral("list_dir"),
|
||||||
|
QVariant(0), QStringLiteral("exit=0 bytes=532"));
|
||||||
|
check(list->property("count").toInt() == rows0 + 2,
|
||||||
|
QStringLiteral("two calls of one tool are two cards (%1 -> %2)")
|
||||||
|
.arg(rows0)
|
||||||
|
.arg(list->property("count").toInt()));
|
||||||
|
{
|
||||||
|
const QVariantMap first = rowAt(rows0), second = rowAt(rows0 + 1);
|
||||||
|
check(first.value(QStringLiteral("state")).toString() == QLatin1String("done") &&
|
||||||
|
first.value(QStringLiteral("output")).toString() ==
|
||||||
|
QLatin1String("exit=0 bytes=532"),
|
||||||
|
QStringLiteral("the result lands on the card of its own call "
|
||||||
|
"(state '%1', output '%2', keys [%3])")
|
||||||
|
.arg(first.value(QStringLiteral("state")).toString(),
|
||||||
|
first.value(QStringLiteral("output")).toString(),
|
||||||
|
first.keys().join(QLatin1Char(','))));
|
||||||
|
check(second.value(QStringLiteral("state")).toString() == QLatin1String("pending") &&
|
||||||
|
second.value(QStringLiteral("detail")).toString().contains(
|
||||||
|
QLatin1String("\"src\"")) &&
|
||||||
|
second.value(QStringLiteral("output")).toString().isEmpty(),
|
||||||
|
QStringLiteral("the other card is untouched (state '%1')")
|
||||||
|
.arg(second.value(QStringLiteral("state")).toString()));
|
||||||
|
}
|
||||||
|
/* and a turn that ends with the call still open settles it */
|
||||||
|
check(QMetaObject::invokeMethod(win, "engineTurnFinished", Q_ARG(QVariant, 0),
|
||||||
|
Q_ARG(QVariant, QString())),
|
||||||
|
QStringLiteral("the turn end is reachable too"));
|
||||||
|
{
|
||||||
|
const QVariantMap second = rowAt(rows0 + 1);
|
||||||
|
check(second.value(QStringLiteral("complete")).toBool() &&
|
||||||
|
second.value(QStringLiteral("state")).toString() ==
|
||||||
|
QLatin1String("unfinished"),
|
||||||
|
QStringLiteral("an open call is settled when the turn ends"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the answer is rendered while it streams -------------------------- */
|
||||||
|
if (QMetaObject::invokeMethod(win, "engineAssistant", Q_ARG(QVariant, QStringLiteral("**bold** text")))) {
|
||||||
|
const int last = list->property("count").toInt() - 1;
|
||||||
|
QMetaObject::invokeMethod(win, "renderStreaming");
|
||||||
|
const QVariantMap row = rowAt(last);
|
||||||
|
check(row.value(QStringLiteral("kind")).toString() == QLatin1String("message") &&
|
||||||
|
!row.value(QStringLiteral("complete")).toBool(),
|
||||||
|
QStringLiteral("a delta opens a streaming answer"));
|
||||||
|
check(row.value(QStringLiteral("html")).toString().contains(QLatin1Char('<')) &&
|
||||||
|
row.value(QStringLiteral("text")).toString().contains(QLatin1String("bold")),
|
||||||
|
QStringLiteral("markdown is rendered while the answer streams"));
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("assistant deltas are reachable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the image provider: only the session's own files ----------------- */
|
||||||
|
{
|
||||||
|
const QString dir = core.attachmentDir();
|
||||||
|
/* one directory per session since v0.9.5: <sessions>/<id>/attachments */
|
||||||
|
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) &&
|
||||||
|
dir.contains(QLatin1String("/sessions/")),
|
||||||
|
QStringLiteral("the agent names its image directory (%1)").arg(dir));
|
||||||
|
QImage pic(32, 16, QImage::Format_RGB32);
|
||||||
|
pic.fill(Qt::darkGreen);
|
||||||
|
QByteArray png;
|
||||||
|
QBuffer buf(&png);
|
||||||
|
buf.open(QIODevice::WriteOnly);
|
||||||
|
pic.save(&buf, "PNG");
|
||||||
|
char aerr[256] = "";
|
||||||
|
cst_attachment_t *stored = cst_attach_import_bytes(
|
||||||
|
cst_agent_session_path(core.agent()), "probe.png", png.constData(),
|
||||||
|
static_cast<size_t>(png.size()), aerr, sizeof(aerr));
|
||||||
|
check(stored != nullptr,
|
||||||
|
QStringLiteral("a picture can be stored next to the session"));
|
||||||
|
if (stored) {
|
||||||
|
QSize got;
|
||||||
|
const QImage shrunk =
|
||||||
|
images->requestImage(QString::fromUtf8(stored->rel),
|
||||||
|
&got, QSize(8, 8));
|
||||||
|
check(!shrunk.isNull() && shrunk.width() == 8 && shrunk.height() == 4,
|
||||||
|
QStringLiteral("the provider serves it, shrunk to what was asked "
|
||||||
|
"(%1x%2)")
|
||||||
|
.arg(shrunk.width())
|
||||||
|
.arg(shrunk.height()));
|
||||||
|
check(images->requestImage(QStringLiteral("../escape.png"), &got,
|
||||||
|
QSize(64, 64))
|
||||||
|
.isNull(),
|
||||||
|
QStringLiteral("a name outside the attachment directory is refused"));
|
||||||
|
check(images->requestImage(QStringLiteral("no-such-file.png"), &got,
|
||||||
|
QSize(64, 64))
|
||||||
|
.isNull(),
|
||||||
|
QStringLiteral("a file that is not there is refused"));
|
||||||
|
cst_attach_free(stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- a tool that made a picture: its card shows it -------------------- */
|
||||||
|
{
|
||||||
|
const int before = list->property("count").toInt();
|
||||||
|
const int thumbsBefore =
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
|
||||||
|
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
|
||||||
|
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
||||||
|
const QString imgs =
|
||||||
|
QStringLiteral("img-fixture.png|shot.png|view_image|24x8");
|
||||||
|
check(QMetaObject::invokeMethod(win, "engineToolImages",
|
||||||
|
Q_ARG(QVariant, QStringLiteral("call-img")),
|
||||||
|
Q_ARG(QVariant, imgs)),
|
||||||
|
QStringLiteral("images a tool made reach the view"));
|
||||||
|
QTest::qWait(150);
|
||||||
|
const QVariantMap card = rowAt(before);
|
||||||
|
check(card.value(QStringLiteral("images"))
|
||||||
|
.toString()
|
||||||
|
.contains(QLatin1String("img-fixture.png")) &&
|
||||||
|
card.value(QStringLiteral("name")).toString() ==
|
||||||
|
QLatin1String("view_image"),
|
||||||
|
QStringLiteral("the card of that call carries the image"));
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
||||||
|
thumbsBefore,
|
||||||
|
QStringLiteral("its thumbnail is in the card"));
|
||||||
|
maybeShot(win, QStringLiteral("-toolimage"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the order the engine uses on a live turn: the result first, the
|
||||||
|
* files after it (a card is complete by then, which used to lose them) --- */
|
||||||
|
{
|
||||||
|
const int before = list->property("count").toInt();
|
||||||
|
const int cardsBefore =
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||||
|
event4("engineToolEvent", QStringLiteral("open"),
|
||||||
|
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||||
|
QStringLiteral("{\"path\":\"report.md\"}"));
|
||||||
|
event4("engineToolEvent", QStringLiteral("done"),
|
||||||
|
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||||
|
QStringLiteral("exit=0 bytes=120"));
|
||||||
|
event4("engineToolResult", QStringLiteral("call-file"),
|
||||||
|
QStringLiteral("deliver_file"), QVariant(0),
|
||||||
|
QStringLiteral("delivered report.md (text/markdown, 120 B)"));
|
||||||
|
const QString files =
|
||||||
|
QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|"
|
||||||
|
"text/markdown|120|0x0");
|
||||||
|
check(QMetaObject::invokeMethod(win, "engineToolImages",
|
||||||
|
Q_ARG(QVariant, QStringLiteral("call-file")),
|
||||||
|
Q_ARG(QVariant, files)),
|
||||||
|
QStringLiteral("the files a tool delivered reach the view"));
|
||||||
|
QTest::qWait(150);
|
||||||
|
const QVariantMap card = rowAt(before);
|
||||||
|
check(card.value(QStringLiteral("images"))
|
||||||
|
.toString()
|
||||||
|
.contains(QLatin1String("art-0123456789abcdef.md")),
|
||||||
|
QStringLiteral("a card that is already complete still takes them"));
|
||||||
|
check(countNamed(win->contentItem(), QStringLiteral("fileCard")) >
|
||||||
|
cardsBefore,
|
||||||
|
QStringLiteral("so the delivered file is on the card (%1 -> %2)")
|
||||||
|
.arg(cardsBefore)
|
||||||
|
.arg(countNamed(win->contentItem(), QStringLiteral("fileCard"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||||
|
resizeTo(win, 460, 900);
|
||||||
|
check(docked && !docked->isVisible(),
|
||||||
|
QStringLiteral("narrow window: the docked sidebar folds away"));
|
||||||
|
check(navButton && navButton->isVisible(),
|
||||||
|
QStringLiteral("narrow window: the drawer button appears"));
|
||||||
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
|
check(navPanel && navPanel->isVisible(),
|
||||||
|
QStringLiteral("drawer opens over the transcript"));
|
||||||
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
|
check(navPanel && !navPanel->isVisible(),
|
||||||
|
QStringLiteral("drawer closes again"));
|
||||||
|
|
||||||
|
resizeTo(win, 1200, 820);
|
||||||
|
check(docked && docked->isVisible(),
|
||||||
|
QStringLiteral("widening again docks the sidebar back"));
|
||||||
|
|
||||||
|
/* A picture of the last state on request (see maybeShot). */
|
||||||
|
maybeShot(win, QString());
|
||||||
|
|
||||||
|
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,17 +2,25 @@
|
|||||||
# ./scripts/build_third_party_mingw.sh curl
|
# ./scripts/build_third_party_mingw.sh curl
|
||||||
# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||||
# -DCST_BUILD_TUI=OFF
|
# -DCST_BUILD_TUI=OFF
|
||||||
|
# $MINGW_HOST picks the tool prefix (default x86_64-w64-mingw32; Debian's UCRT
|
||||||
|
# cross gcc is x86_64-w64-mingw32ucrt).
|
||||||
set(CMAKE_SYSTEM_NAME Windows)
|
set(CMAKE_SYSTEM_NAME Windows)
|
||||||
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
set(CMAKE_SYSTEM_PROCESSOR x86_64)
|
||||||
|
|
||||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
if(DEFINED ENV{MINGW_HOST} AND NOT "$ENV{MINGW_HOST}" STREQUAL "")
|
||||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
set(_MINGW_HOST "$ENV{MINGW_HOST}")
|
||||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
else()
|
||||||
|
set(_MINGW_HOST x86_64-w64-mingw32)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(CMAKE_C_COMPILER ${_MINGW_HOST}-gcc)
|
||||||
|
set(CMAKE_CXX_COMPILER ${_MINGW_HOST}-g++)
|
||||||
|
set(CMAKE_RC_COMPILER ${_MINGW_HOST}-windres)
|
||||||
|
|
||||||
get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
|
||||||
set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
|
set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
|
||||||
list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}")
|
list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}")
|
||||||
set(CMAKE_FIND_ROOT_PATH "/usr/x86_64-w64-mingw32" "${_MINGW_PREFIX}")
|
set(CMAKE_FIND_ROOT_PATH "/usr/${_MINGW_HOST}" "${_MINGW_PREFIX}")
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
|
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
|
||||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
|
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
|
||||||
|
|||||||
@@ -5,6 +5,20 @@
|
|||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
|
### 安装包
|
||||||
|
|
||||||
|
从 [Release](https://git.whcself.xyz/WHC/CST-agent/releases) 下载
|
||||||
|
`cst-agent-VERSION-windows-x86_64-setup.exe` 运行即可:按当前用户安装到
|
||||||
|
`%LOCALAPPDATA%\Programs\cst-agent`(不需要管理员),把这个目录加进**用户** PATH,
|
||||||
|
开始菜单里放一个“CST-Agent WebUI”(在用户目录启动 `cst-agent --web --web-open`)。
|
||||||
|
系统低于 Windows 10 1903 时安装包会直接拒绝。
|
||||||
|
|
||||||
|
卸载:设置 → 应用 → CST-Agent,或运行安装目录里的 `uninstall.exe`;会去掉 PATH 里的这一项。
|
||||||
|
API key 和设置在 `%APPDATA%\cst-agent`,卸载时保留。
|
||||||
|
安装包装的文件和 zip 一样,所以之后也能用 `cst-agent-update` 升级,或者重新运行新版安装包覆盖。
|
||||||
|
|
||||||
|
### PowerShell 脚本
|
||||||
|
|
||||||
在 PowerShell 里(Windows PowerShell 5.1 或 PowerShell 7 都行):
|
在 PowerShell 里(Windows PowerShell 5.1 或 PowerShell 7 都行):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -123,7 +137,9 @@ windows-x86_64/
|
|||||||
|
|
||||||
## 从源码构建
|
## 从源码构建
|
||||||
|
|
||||||
两种方式产物一样:`dist/windows-x86_64/` 和 `dist/cst-agent-VERSION-windows-x86_64.zip`。
|
两种方式产物一样:`dist/windows-x86_64/` 和 `dist/cst-agent-VERSION-windows-x86_64.zip`;
|
||||||
|
装了 `makensis`(NSIS)时还有安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`
|
||||||
|
(脚本在 `scripts/windows/cst-agent.nsi`)。
|
||||||
第三方依赖(curl、ncurses、busybox-w32)都按固定版本从源码编译、静态链接。
|
第三方依赖(curl、ncurses、busybox-w32)都按固定版本从源码编译、静态链接。
|
||||||
|
|
||||||
### 在 Linux 上交叉编译
|
### 在 Linux 上交叉编译
|
||||||
@@ -134,8 +150,16 @@ cd CST-agent
|
|||||||
scripts/bootstrap_windows.sh
|
scripts/bootstrap_windows.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`);否则需要 UCRT 版的
|
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`,带 NSIS);否则需要 UCRT 版的
|
||||||
`x86_64-w64-mingw32-gcc`(带 winpthreads)、cmake、ninja、git、perl。
|
mingw-w64 gcc(带 winpthreads)、cmake、ninja、git、perl。工具前缀由 `MINGW_HOST` 指定,默认
|
||||||
|
`x86_64-w64-mingw32`;Debian trixie 起的 `gcc-mingw-w64-ucrt64` 是 `x86_64-w64-mingw32ucrt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis cmake ninja-build perl
|
||||||
|
MINGW_HOST=x86_64-w64-mingw32ucrt scripts/bootstrap_windows.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`CST_WIN_CFLAGS` 给 `cst-agent.exe` 追加编译选项(CI 用 `-Wall -Wextra -Werror`)。
|
||||||
|
|
||||||
### 在 Windows 上用 MSYS2 原生编译
|
### 在 Windows 上用 MSYS2 原生编译
|
||||||
|
|
||||||
@@ -159,17 +183,18 @@ scripts/bootstrap_msys2.sh
|
|||||||
|
|
||||||
### 发布
|
### 发布
|
||||||
|
|
||||||
CI:推 `main` 或 `v*` tag 且 `windows-cross` 成功后,自动把 zip / `.sha256` 发到
|
CI:推 `main` 或 `v*` tag 且 `windows` 任务成功后,自动把 zip、安装包和各自的 `.sha256` 发到
|
||||||
Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets
|
Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets
|
||||||
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
|
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
|
||||||
|
|
||||||
本地:
|
本地:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=... scripts/release_windows.sh --build
|
scripts/bootstrap_windows.sh
|
||||||
|
GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*-windows-x86_64.zip dist/cst-agent-*-windows-x86_64-setup.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
创建(或复用)`vVERSION` Release,上传 zip 和 `.sha256`。token 只从环境变量读取,经 stdin
|
创建(或复用)`vVERSION` Release,上传给出的文件和旁边的 `.sha256`。token 只从环境变量读取,经 stdin
|
||||||
交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推
|
交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推
|
||||||
`vX.Y.Z` tag 时必须与头文件一致。
|
`vX.Y.Z` tag 时必须与头文件一致。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# 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.
|
||||||
|
* Still to come: the SAF picker for attaching a picture from another app, 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.
|
||||||
@@ -21,11 +21,11 @@ The Makefile picks `ncursesw`, then `ncurses`, through pkg-config. On macOS it
|
|||||||
adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
|
adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
|
||||||
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
||||||
|
|
||||||
Verified so far: NixOS x86_64 with gcc and clang (`-Werror`, `make check`,
|
Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
|
||||||
`make asan`, ctest), and every source compiled `-fsyntax-only` with
|
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`) checks
|
||||||
`x86_64-unknown-linux-musl-gcc` without warnings. Full musl, macOS and WSL
|
with TinyCC on Debian and builds the static musl releases for x86_64, x86 and
|
||||||
builds run in CI (`.gitea/workflows/ci.yml`; the macOS job needs a runner and
|
aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
|
||||||
the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
|
test on aarch64). macOS and WSL are checked by hand with the list below.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export OPENAI_MODEL='your-model'
|
|||||||
| `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) |
|
| `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) |
|
||||||
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) |
|
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) |
|
||||||
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) |
|
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) |
|
||||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认 120) |
|
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限,Ctrl+C / 停止按钮仍可随时中断) |
|
||||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
|
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
|
||||||
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
|
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
|
||||||
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
|
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# WebUI(内置 HTTP + SSE 前端)
|
||||||
|
|
||||||
|
`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端)
|
||||||
|
连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端,
|
||||||
|
**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。
|
||||||
|
|
||||||
|
这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现,
|
||||||
|
不需要读 C 代码。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认只监听 127.0.0.1:8787
|
||||||
|
cst-agent --web
|
||||||
|
|
||||||
|
# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL)
|
||||||
|
cst-agent --web --web-port 8787
|
||||||
|
|
||||||
|
# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上)
|
||||||
|
cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令
|
||||||
|
|
||||||
|
# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译
|
||||||
|
cst-agent --web --web-dev ./web
|
||||||
|
|
||||||
|
# 启动后自动打开浏览器
|
||||||
|
cst-agent --web --web-open
|
||||||
|
|
||||||
|
# 启动即跑一轮(不点页面也能用)
|
||||||
|
cst-agent --web -p "看看这个仓库有什么问题"
|
||||||
|
```
|
||||||
|
|
||||||
|
启动日志:
|
||||||
|
|
||||||
|
```
|
||||||
|
webui: http://127.0.0.1:8787/
|
||||||
|
webui: token 3f9a… (append #token=3f9a… to the URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器,
|
||||||
|
页面会用 `?token=` 再请求一次)。
|
||||||
|
|
||||||
|
环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。
|
||||||
|
|
||||||
|
退出:终端里 Ctrl+C,或 `POST /api/quit`。
|
||||||
|
|
||||||
|
## 安全模型
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding);带 `Origin` 的请求必须来自本页面自己(`http://<Host>`),否则 401 —— 防止任意网页跨站 POST 驱动 agent |
|
||||||
|
| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/<rel>` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) |
|
||||||
|
| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 |
|
||||||
|
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
|
||||||
|
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
|
||||||
|
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
|
||||||
|
| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`<script>` 全部丢弃) |
|
||||||
|
| 请求体 | 默认 1 MiB,图片上传 32 MiB;超限 413 |
|
||||||
|
|
||||||
|
**不要**把 WebUI 直接暴露到公网:没有 TLS,token 会以明文出现在网络里。需要远程访问时
|
||||||
|
放在反向代理(https)后面,并确认 `X-Accel-Buffering: no` 之类的缓冲设置不会掐断 SSE。
|
||||||
|
|
||||||
|
## 引擎 API(协议 `cst-webui/1`)
|
||||||
|
|
||||||
|
所有响应都是 JSON(`Content-Type: application/json`)。`GET /api/state` 的
|
||||||
|
`protocol` 字段给出版本号,客户端应当先看它再决定支持哪些端点。
|
||||||
|
|
||||||
|
### 事件流
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/events?since=<seq> → text/event-stream (chunked)
|
||||||
|
```
|
||||||
|
|
||||||
|
* 每条事件都有 `id: <seq>`(单调递增)与 `data: {"type":…,"seq":…, …}`。
|
||||||
|
* 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行),
|
||||||
|
服务端保留最近 2000 条,超出后从可用的最老一条开始;比当前 seq 还大(服务端重启过)
|
||||||
|
就当作新连接。
|
||||||
|
* 新连接(不带 `since`)只收之后的事件;但若有尚未回答的 `permission`/`ask_user`,
|
||||||
|
会从那条事件开始补发,刷新页面不会丢掉正在等的问题。
|
||||||
|
* 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。
|
||||||
|
* `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。
|
||||||
|
|
||||||
|
事件类型:
|
||||||
|
|
||||||
|
| type | 字段 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `state` | 见下表 | 会话/模型/审批/todo/用量快照 |
|
||||||
|
| `status` | `message` | 引擎自己的一句话状态("calling model (step 2)…") |
|
||||||
|
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
|
||||||
|
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
|
||||||
|
| `think_delta` | `text` | 推理过程增量(Thought) |
|
||||||
|
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`progress`/`done`;`progress` 是子代理的最近几步(`detail` 形如 `explore · grep foo · read_file x`),可以出现多次 |
|
||||||
|
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
|
||||||
|
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
|
||||||
|
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 |
|
||||||
|
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
|
||||||
|
| `ask_user` | `id`,`question`,`options[]` | 需要回答 |
|
||||||
|
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
|
||||||
|
| `turn_end` | `rc`,`error` | 本轮结束(`rc=0` 正常,非 0 见 `error`) |
|
||||||
|
|
||||||
|
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
|
||||||
|
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
|
||||||
|
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens,
|
||||||
|
completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
|
||||||
|
`turn_usage{…}`(上一轮,字段同 `usage`)`api_base` `key_set` `key_from`
|
||||||
|
(`saved` / 环境变量名 / `session` / 空,**从不含 key 本身**)
|
||||||
|
`model_info{label,context,vision,reasoning,source,price?}` `effort_applies`
|
||||||
|
`sees_images`(1 / 0 / -1 未知)`max_steps` `tool_profile` `perm_timeout`
|
||||||
|
`show_thinking`(思考过程默认展开,存在 `settings.json`,和 TUI 的 `/thinking` 是同一个开关)。
|
||||||
|
`cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。
|
||||||
|
|
||||||
|
`think_delta` / `assistant_delta` 的 `text` 总是完整的 UTF-8:上游在多字节字符中间
|
||||||
|
断开时,残缺的尾巴留到下一块再发。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/state` | 上面那份快照 |
|
||||||
|
| 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":"…"}` |
|
||||||
|
| POST | `/api/sessions/rename` | `{"id":"…"?,"title":"…"}`(不带 `id` = 当前会话);标题按字符截到 120 字节,空串 = 恢复默认(第一句提问);没这个会话 → 404 |
|
||||||
|
| POST | `/api/sessions/delete` | `{"id":"…"}` → `{ok,current}`;删的是当前会话时先开一个新会话(并发 `session` 事件);没这个会话 → 404 |
|
||||||
|
| POST | `/api/retry` | `{}` = 重新生成最后一轮;`{"text":"…"}` = 把最后一句提问换成新文字再跑(编辑重发)。先把最后一轮从会话里退掉,再开新一轮 → 202 `{ok,text}`;没有可重来的轮次 / 轮次在跑 → 409;空串或非字符串 → 400 |
|
||||||
|
| GET | `/api/export` | 当前会话的 Markdown(`text/markdown`,与 TUI `/export` 同一格式) |
|
||||||
|
| GET | `/api/diff[?path=p]` | 工作区的真实 diff:`{git,path,files:[{status,path}],diff,truncated}`。`files` 来自 `git status --short`(含未跟踪),`diff` 是 `git diff`(未暂存部分,限 512 KiB);不是 git 仓库 / 没装 git → `git:false`;10 s 超时 |
|
||||||
|
| GET | `/api/settings` | 运行设置:`protocol`,`protocols[]`,`max_steps`,`tool_profile`,`profiles[]`,`confirm{shell,write,net}`,`approval`,`tools[{name,perm,on,desc,…}]`,`sandbox{kind,active,labels?}` |
|
||||||
|
| POST | `/api/settings` | `{protocol?,max_steps?,tool_profile?,tools?:{名字:bool},confirm?:{shell?,write?,net?}}`;整体校验后才生效(任何一项不对 → 400,什么都不改),成功后发 `state` |
|
||||||
|
| GET | `/api/permissions` | 已保存的"总是允许"规则:`{text,tools[],shell_prefixes[]}`(来自 `<workspace>/.agent/permissions.json`) |
|
||||||
|
| DELETE | `/api/permissions` | 清空这些规则 |
|
||||||
|
| POST | `/api/init` | `{}` → 202 `{ok,text}`:开一轮,让 agent 分析仓库写 `AGENTS.md`(与 TUI `/init` 同一段提示词) |
|
||||||
|
| POST | `/api/skill` | `{"name":"…","task":"…"?}` → 202 `{ok,text}`:把 skill 正文内联进这条消息再开一轮(同 TUI `/skill`);没有这个 skill → 404;缺 `name` / 超过 63 字节 / `task` 不是字符串 → 400 |
|
||||||
|
| GET | `/api/extensions` | `{enabled,trusted,report,task_tool,mcp:[{name,source,command,state,tools,error}],plugins:[{name,source,desc,tools,mcp,skills,error}],skills:[{name,source,desc}],roles:[{name,source,desc,model,read_only}],runs:[{file,role,desc,status,steps,tools,ms}]}`;`state` = `starting/ready/failed/exited/off`,`runs` 是当前会话的子代理运行 |
|
||||||
|
| POST | `/api/extensions/reload` | 停掉 MCP 服务器和插件,重新读取它们和 skills → `{ok,trusted,report}` |
|
||||||
|
| POST | `/api/extensions/trust` | `{"on":true\|false}`:信任(或取消)当前工作区——决定它的 `.agent/plugins`、`.agent/mcp.json` 是否加载;写进 `settings.json` 后重新加载,回复同上 |
|
||||||
|
| POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换,扩展按新目录重新加载),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 |
|
||||||
|
| POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** |
|
||||||
|
| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程);挂起的权限问题按拒绝、提问按忽略处理 |
|
||||||
|
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix |
|
||||||
|
| POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` |
|
||||||
|
| POST | `/api/config` | `{model?,effort?,approval?,plan_mode?,show_thinking?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) |
|
||||||
|
| GET | `/api/providers` | `{current, providers:[{name,api_base,model,has_key,key_from,key_env,source,current}]}` |
|
||||||
|
| GET | `/api/models[?refresh=1]` | 当前提供商的模型:`{provider,current,api_base,key_set,api_count,models:[{id,api,label,context,vision,reasoning,source,price?}]}`;先用缓存,过期/缺失/`refresh=1` 时向 `/v1/models` 拉;key 被拒 → 401(仍带内置模型和 `auth:true`),其它失败放在 `api_error` |
|
||||||
|
| POST | `/api/provider` | `{"name":"…","key":"…"?}`:切换提供商。缺 key → 400 `{need_key:true}`;key 被拒 → 401;没这个提供商 → 404;成功 → `{ok,provider,model,warning?}`,key 验证后存进 `credentials`(0600) |
|
||||||
|
| POST | `/api/key` | `{"key":"…"}`:换当前提供商的 key,验证与回复同上 |
|
||||||
|
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
|
||||||
|
| POST | `/api/compact` | 手动压缩上下文 |
|
||||||
|
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 |
|
||||||
|
| DELETE | `/api/attach` | 清空待发送图片 |
|
||||||
|
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
|
||||||
|
| POST | `/api/quit` | 关闭进程 |
|
||||||
|
|
||||||
|
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
|
||||||
|
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`sessions/delete`、
|
||||||
|
`workspace`、`undo`、`compact`、`attach`(上传/清空)、`provider`、`key`、`retry`、`diff`、
|
||||||
|
`POST /api/settings`、`DELETE /api/permissions`、`init`、`skill`、`extensions/reload`、
|
||||||
|
`extensions/trust` 以及改 `model/effort` 的 `config` 一律 **409**;
|
||||||
|
`approval`/`plan_mode`/`show_thinking` 可以中途改,`sessions/rename`、`export` 与
|
||||||
|
`GET /api/extensions` 在轮次进行中也能用。
|
||||||
|
|
||||||
|
`/api/session` 的每条消息在会话文件的字段之外还带:
|
||||||
|
|
||||||
|
| 字段 | 出现在 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `html` | assistant | 已消毒的 Markdown 渲染结果(文本协议时只渲染说明文字,不含调用帧) |
|
||||||
|
| `text` | 文本协议的 assistant | 去掉调用帧后的说明文字(`content` 仍是原文) |
|
||||||
|
| `calls` | 带工具调用的 assistant | `[{id,name,args}]`:原生协议来自 `tool_calls`,文本协议从 `content` 解析 |
|
||||||
|
| `tool_result` | 工具结果 | `true`;原生协议 role 是 `tool`,文本协议 role 是 `user`(凭 `tool_call_id` 区分) |
|
||||||
|
| `out`,`exit`,`truncated` | 工具结果 | 去掉 `[tool …]` / `result … end` 外框后的输出(截到 32 KiB,按字符边界)与退出码 |
|
||||||
|
| `note` | 引擎插入的提示 | `true`;如中途切换计划模式时的 `[cst] …` 说明,不是用户说的话 |
|
||||||
|
|
||||||
|
客户端据此在刷新后重建"本轮工具调用"列表:`calls` 建行,`tool_call_id` 相同的结果填进去。
|
||||||
|
|
||||||
|
key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key`
|
||||||
|
跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。
|
||||||
|
|
||||||
|
### 客户端接入示例(10 行)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, urllib.request
|
||||||
|
base = "http://127.0.0.1:8787"
|
||||||
|
def api(path, body=None, token=None):
|
||||||
|
req = urllib.request.Request(base + path, method="POST" if body is not None else "GET")
|
||||||
|
if token: req.add_header("Authorization", "Bearer " + token)
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
return urllib.request.urlopen(req, data).read()
|
||||||
|
api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流
|
||||||
|
```
|
||||||
|
|
||||||
|
### 空 body 的 POST
|
||||||
|
|
||||||
|
`POST /api/cancel`、`/api/undo`、`/api/compact`、`/api/quit` 不需要 body。
|
||||||
|
没有 `Content-Length` 头的 POST 按 RFC 9112 §6.3 视为**空 body**(`curl -X POST`
|
||||||
|
正是这样发的),但该连接不再复用(服务端随后关闭它):后面是否跟着未声明的
|
||||||
|
字节无法判断。
|
||||||
|
|
||||||
|
## 前端实现(web/)
|
||||||
|
|
||||||
|
没有构建步骤:`web/index.html` + `web/app.js` + `web/style.css` 是纯 ES module,
|
||||||
|
编译时被 `scripts/embed_web.sh` 塞进二进制(`.gen/web_assets.inc`,跟内置模型数据同一招),
|
||||||
|
所以 Windows 发布包依旧是单个 exe。`--web-dev DIR` 可以绕过内嵌版本直接读目录。
|
||||||
|
|
||||||
|
Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML;
|
||||||
|
因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。
|
||||||
|
|
||||||
|
界面本身:左栏可收(顶栏按钮 / `?side=0`),右栏可收(`?panel=0`),主题 `?theme=dark|light`;
|
||||||
|
三个开关都会记进 `localStorage`,带查询参数时以参数为准。空状态只留一句提示,第一轮
|
||||||
|
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
|
||||||
|
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
|
||||||
|
|
||||||
|
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`);
|
||||||
|
左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。
|
||||||
|
|
||||||
|
过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行
|
||||||
|
("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条
|
||||||
|
细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。
|
||||||
|
出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块——
|
||||||
|
历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。
|
||||||
|
`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行;
|
||||||
|
`/details` 只管工具行是否展开。
|
||||||
|
|
||||||
|
图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是
|
||||||
|
152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在
|
||||||
|
输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/<rel>`);
|
||||||
|
从别的客户端加的待发图片仍然显示为文字条。
|
||||||
|
|
||||||
|
### 命令与快捷键
|
||||||
|
|
||||||
|
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全,
|
||||||
|
Enter 执行),`/model`、`/effort`、`/approval`、`/provider`、`/skill`、`/thinking`、`/details`
|
||||||
|
还会补全参数。`/usr/bin 是什么` 这种第一个词里带 `/` 的照常发送;未知命令留在输入框里并提示,
|
||||||
|
开头加个空格即可当普通消息发出。
|
||||||
|
|
||||||
|
| 命令 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `/new` `/sessions` `/rename [标题]` | 新建 / 会话列表 / 重命名 |
|
||||||
|
| `/retry` `/edit` `/stop` `/undo` `/compact` | 重新生成 / 编辑重发 / 停止 / 撤销写入 / 压缩上下文 |
|
||||||
|
| `/export` `/copy` `/diff [文件]` | 导出 Markdown / 复制最后一条回答 / 工作区 diff |
|
||||||
|
| `/model [id]` `/effort [级别]` `/provider [名字]` `/key` | 模型、推理强度、提供商、API key |
|
||||||
|
| `/approval [ask\|auto\|yolo]` `/plan` `/build` | 审批权限、计划/构建模式 |
|
||||||
|
| `/settings`(`/protocol` `/tools` `/max-steps` `/permissions`) | 运行设置 |
|
||||||
|
| `/init` `/skill 名字 [任务]` `/skills` `/extensions`(`/mcp` `/plugins` `/agents`) | 写 AGENTS.md、用 skill、扩展状态 |
|
||||||
|
| `/thinking [on\|off]` `/details [on\|off]` | 思考过程默认展开(存 `settings.json`)、工具输出全部展开(存浏览器) |
|
||||||
|
| `/theme` `/palette` `/help` | 主题、命令面板、帮助 |
|
||||||
|
|
||||||
|
快捷键:Ctrl+P 命令面板,Ctrl+K 会话列表,Shift+Tab 循环审批(每次询问 → 自动 → 完全放行),
|
||||||
|
F1 或 `?` 帮助,Esc 关闭弹窗 / 取消编辑 / 停止当前一轮,Ctrl+B / Ctrl+J 收起左栏 / 右栏。
|
||||||
|
|
||||||
|
引擎的英文状态、错误和会话里的 `[cst]` 提示在页面上换成中文;一轮失败时(`turn_end.rc≠0`)
|
||||||
|
显示可读的原因(比如"API key 无效或已过期(HTTP 401)"、"用满了 30 步上限"),不再是 `rc=-1`。
|
||||||
|
|
||||||
|
TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目录> --new`),
|
||||||
|
日志在 `<workspace>/.agent/webui.log`:TUI 不能被 `cst_webui_run()` 的阻塞循环占住,
|
||||||
|
而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。
|
||||||
|
|
||||||
|
自动压缩的门槛是模型上下文窗口的 **75%**(`CST_CTX_BUDGET` 可覆盖);到点后下一轮
|
||||||
|
开始前先总结历史,而不是等到塞满。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
|
||||||
|
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
|
||||||
|
# (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流;
|
||||||
|
# e2e_webui_sessions.py:重新生成/编辑重发/导出/重命名/删除/历史字段;
|
||||||
|
# e2e_webui_workspace.py:diff、运行设置、权限规则;
|
||||||
|
# e2e_webui_ext.py:/init、/skill、扩展、信任、思考开关)
|
||||||
|
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
|
||||||
|
# (deepseek-ctl 录的是一次契约错误连发的失败运行,不能拿来跑这个)
|
||||||
|
```
|
||||||
|
|
||||||
|
手工验收清单(浏览器):
|
||||||
|
|
||||||
|
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
|
||||||
|
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
|
||||||
|
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
|
||||||
|
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行
|
||||||
|
过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。
|
||||||
|
`/thinking off` 时流式输出的思考也保持收起。
|
||||||
|
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
|
||||||
|
5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。
|
||||||
|
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
|
||||||
|
|
||||||
|
## 限制与已知问题
|
||||||
|
|
||||||
|
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
|
||||||
|
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
|
||||||
|
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
|
||||||
|
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
|
||||||
|
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
|
||||||
|
这时应当重新拉一次 `/api/session`。
|
||||||
@@ -47,17 +47,24 @@ int cst_protocol_is_text(cst_protocol_t p);
|
|||||||
/* Eager protocols execute a call during the stream, at confirmation time. */
|
/* Eager protocols execute a call during the stream, at confirmation time. */
|
||||||
int cst_protocol_is_eager(cst_protocol_t p);
|
int cst_protocol_is_eager(cst_protocol_t p);
|
||||||
|
|
||||||
/* One image carried by a message (see cst_attach.h). The bytes live beside
|
/* One file carried by a message (see cst_attach.h): an image, or a document
|
||||||
* the session; this is the metadata plus the name of the stored copy. */
|
* whose stored payload is its text. The payload lives beside the session;
|
||||||
|
* this is the metadata plus the name of the stored copy. */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char id[32]; /* unique inside the session ("img-1a2b3c4d") */
|
char id[32]; /* unique inside the session ("img-1a2b3c4d") */
|
||||||
char name[256]; /* display name (original file name) */
|
char name[256]; /* display name (original file name) */
|
||||||
char mime[32]; /* image/png, image/jpeg, image/gif, image/webp, image/bmp */
|
char mime[32]; /* image/png, ...; application/pdf for a PDF whose text
|
||||||
|
* layer the stored file holds; text/plain for text */
|
||||||
char rel[256]; /* file name inside the session's attachment directory */
|
char rel[256]; /* file name inside the session's attachment directory */
|
||||||
long long bytes; /* stored size */
|
long long bytes; /* stored size (the text, for documents) */
|
||||||
int width, height; /* pixels, 0 when the header could not be read */
|
int width, height; /* pixels, 0 when the header could not be read */
|
||||||
|
int pages; /* PDF page count, 0 for anything else */
|
||||||
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
|
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
|
||||||
char origin[32]; /* user | view_image | mcp (for display only) */
|
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;
|
} cst_attachment_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -82,9 +89,17 @@ typedef struct {
|
|||||||
char model[128];
|
char model[128];
|
||||||
char provider[32];
|
char provider[32];
|
||||||
char effort[16]; /* low | medium | high | max */
|
char effort[16]; /* low | medium | high | max */
|
||||||
|
char mode[16]; /* coding | general (CST_DEFAULT_MODE) */
|
||||||
cst_protocol_t protocol;
|
cst_protocol_t protocol;
|
||||||
int max_steps; /* model calls per user turn */
|
int max_steps; /* model calls per user turn */
|
||||||
size_t tool_out_cap; /* bytes of one tool output sent to the model */
|
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 headless;
|
||||||
int confirm_shell; /* confirm run_shell */
|
int confirm_shell; /* confirm run_shell */
|
||||||
int confirm_write; /* confirm file-changing tools */
|
int confirm_write; /* confirm file-changing tools */
|
||||||
@@ -114,6 +129,14 @@ typedef enum {
|
|||||||
const char *cst_approval_name(int a);
|
const char *cst_approval_name(int a);
|
||||||
int cst_approval_parse(const char *s, int *out);
|
int cst_approval_parse(const char *s, int *out);
|
||||||
|
|
||||||
|
/* Working mode: how the system prompt frames the agent. "coding" (default)
|
||||||
|
* is code framed; "general" is for tasks that are not about a codebase
|
||||||
|
* (documents, data, research, everyday files). Orthogonal to plan mode. */
|
||||||
|
#define CST_DEFAULT_MODE "coding"
|
||||||
|
int cst_mode_valid(const char *m);
|
||||||
|
/* Canonical name of m, or the default when m is unknown / empty. */
|
||||||
|
const char *cst_mode_name(const char *m);
|
||||||
|
|
||||||
typedef struct cst_session cst_session_t;
|
typedef struct cst_session cst_session_t;
|
||||||
typedef struct cst_agent cst_agent_t;
|
typedef struct cst_agent cst_agent_t;
|
||||||
|
|
||||||
@@ -130,6 +153,12 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i);
|
|||||||
const char *cst_session_path(const cst_session_t *s);
|
const char *cst_session_path(const cst_session_t *s);
|
||||||
int cst_session_clear(cst_session_t *s, int keep_system);
|
int cst_session_clear(cst_session_t *s, int keep_system);
|
||||||
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
|
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
|
||||||
|
/* Tool output: role=tool, or a text-protocol user message answering a call. */
|
||||||
|
int cst_message_is_tool_result(const cst_message_t *m);
|
||||||
|
/* The transcript as Markdown, malloc'd (NULL on OOM). Tool output and call
|
||||||
|
* arguments are cut at 4000 bytes. Takes the session lock. */
|
||||||
|
char *cst_session_export_md(cst_session_t *s, const char *title,
|
||||||
|
const char *sid, const char *model);
|
||||||
/* Readers on another thread must hold the lock while using pointers returned
|
/* Readers on another thread must hold the lock while using pointers returned
|
||||||
* by cst_session_get (append may reallocate). Append locks internally. */
|
* by cst_session_get (append may reallocate). Append locks internally. */
|
||||||
void cst_session_lock(cst_session_t *s);
|
void cst_session_lock(cst_session_t *s);
|
||||||
@@ -138,7 +167,14 @@ void cst_session_unlock(cst_session_t *s);
|
|||||||
/* --- workspace .agent/sessions store (OpenCode-like) --- */
|
/* --- workspace .agent/sessions store (OpenCode-like) --- */
|
||||||
int cst_agent_store_ensure(const char *workspace);
|
int cst_agent_store_ensure(const char *workspace);
|
||||||
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
|
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);
|
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,
|
int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||||
char *out, size_t out_sz);
|
char *out, size_t out_sz);
|
||||||
int cst_agent_store_set_current(const char *workspace, const char *id);
|
int cst_agent_store_set_current(const char *workspace, const char *id);
|
||||||
@@ -148,6 +184,10 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out,
|
|||||||
size_t path_sz, char *id_out,
|
size_t path_sz, char *id_out,
|
||||||
size_t id_sz);
|
size_t id_sz);
|
||||||
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
|
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 {
|
typedef struct {
|
||||||
char id[128];
|
char id[128];
|
||||||
long long mtime;
|
long long mtime;
|
||||||
@@ -160,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,
|
int cst_agent_store_title_set(const char *workspace, const char *id,
|
||||||
const char *title);
|
const char *title);
|
||||||
int cst_agent_store_delete(const char *workspace, const char *id);
|
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 --- */
|
/* --- tools --- */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -253,6 +300,9 @@ typedef struct {
|
|||||||
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
|
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 */
|
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 */
|
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 */
|
const char *session_path; /* where stored images live; NULL = none */
|
||||||
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
|
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
|
||||||
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
||||||
@@ -272,6 +322,8 @@ int cst_llm_list_models(const char *api_base, const char *api_key,
|
|||||||
char *cst_llm_build_body(const cst_llm_request_t *req);
|
char *cst_llm_build_body(const cst_llm_request_t *req);
|
||||||
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
|
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
|
||||||
void cst_llm_trace_reset(void);
|
void cst_llm_trace_reset(void);
|
||||||
|
/* Set CURLOPT_CAINFO on a CURL * when a CA bundle file is known. */
|
||||||
|
void cst_curl_apply_ca(void *curl);
|
||||||
|
|
||||||
/* on_permission answers */
|
/* on_permission answers */
|
||||||
#define CST_PERM_DENY 0
|
#define CST_PERM_DENY 0
|
||||||
@@ -304,6 +356,12 @@ typedef struct {
|
|||||||
/* Full tool output, fired right after execution (before it is persisted). */
|
/* Full tool output, fired right after execution (before it is persisted). */
|
||||||
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
|
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
|
||||||
size_t len, int exit_code, void *ud);
|
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;
|
void *userdata;
|
||||||
} cst_agent_hooks_t;
|
} cst_agent_hooks_t;
|
||||||
|
|
||||||
@@ -321,6 +379,12 @@ cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a);
|
|||||||
void cst_agent_request_cancel(cst_agent_t *a);
|
void cst_agent_request_cancel(cst_agent_t *a);
|
||||||
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
|
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
|
||||||
int cst_agent_switch_session(cst_agent_t *a, const char *id);
|
int cst_agent_switch_session(cst_agent_t *a, const char *id);
|
||||||
|
/* Re-root the agent at another directory. Per-workspace state (permission
|
||||||
|
* rules, metrics, project notes) follows, the system prompt is rebuilt and the
|
||||||
|
* token budget is re-measured. The session is not touched: the caller decides
|
||||||
|
* whether to keep it or start a new one. */
|
||||||
|
int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err,
|
||||||
|
size_t err_sz);
|
||||||
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
|
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
|
||||||
/* Bare id of the current session (file name without .jsonl). */
|
/* Bare id of the current session (file name without .jsonl). */
|
||||||
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
|
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
|
||||||
@@ -342,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. */
|
/* 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,
|
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
|
||||||
size_t out_sz);
|
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);
|
void cst_agent_pending_clear(cst_agent_t *a);
|
||||||
/* Latest todo_write list of this session, or NULL. */
|
/* Latest todo_write list of this session, or NULL. */
|
||||||
const char *cst_agent_todo(const cst_agent_t *a);
|
const char *cst_agent_todo(const cst_agent_t *a);
|
||||||
@@ -392,13 +462,24 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
|
|||||||
/* Hidden console prompt (no echo). */
|
/* Hidden console prompt (no echo). */
|
||||||
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
|
int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
|
||||||
|
|
||||||
/* Last used provider, model per provider and effort, in
|
/* Last used provider, model per provider, effort and working mode, in
|
||||||
* <config>/settings.json. */
|
* <config>/settings.json. */
|
||||||
#define CST_SETTINGS_MAX_MODELS 16
|
#define CST_SETTINGS_MAX_MODELS 16
|
||||||
#define CST_SETTINGS_MAX_TRUSTED 32
|
#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 {
|
typedef struct {
|
||||||
char provider[32];
|
char provider[32];
|
||||||
char effort[16];
|
char effort[16];
|
||||||
|
char mode[16]; /* coding | general, "" = not chosen yet */
|
||||||
char approval[8]; /* "" = not chosen yet */
|
char approval[8]; /* "" = not chosen yet */
|
||||||
int show_thinking; /* TUI: Thought blocks start expanded */
|
int show_thinking; /* TUI: Thought blocks start expanded */
|
||||||
/* workspaces whose .agent/plugins and .agent/mcp.json may run */
|
/* workspaces whose .agent/plugins and .agent/mcp.json may run */
|
||||||
@@ -409,15 +490,25 @@ typedef struct {
|
|||||||
char provider[32];
|
char provider[32];
|
||||||
char model[128];
|
char model[128];
|
||||||
} models[CST_SETTINGS_MAX_MODELS];
|
} models[CST_SETTINGS_MAX_MODELS];
|
||||||
|
int nparams;
|
||||||
|
cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS];
|
||||||
} cst_settings_t;
|
} cst_settings_t;
|
||||||
int cst_settings_load(cst_settings_t *s);
|
int cst_settings_load(cst_settings_t *s);
|
||||||
int cst_settings_save(const 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);
|
const char *cst_settings_model(const cst_settings_t *s, const char *provider);
|
||||||
/* Record cfg's provider, model and effort as the last used ones. */
|
/* 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);
|
int cst_settings_remember(const cst_agent_config_t *cfg);
|
||||||
/* Record an approval mode the user picked (CST_APPROVAL_*). */
|
/* Record an approval mode the user picked (CST_APPROVAL_*). */
|
||||||
int cst_settings_remember_approval(int approval);
|
int cst_settings_remember_approval(int approval);
|
||||||
int cst_settings_remember_thinking(int show_thinking);
|
int cst_settings_remember_thinking(int show_thinking);
|
||||||
|
/* Record the working mode the user picked (coding | general). */
|
||||||
|
int cst_settings_remember_mode(const char *mode);
|
||||||
/* Workspace trust for project plugins / MCP servers (by real path). */
|
/* Workspace trust for project plugins / MCP servers (by real path). */
|
||||||
int cst_settings_trusted(const char *workspace);
|
int cst_settings_trusted(const char *workspace);
|
||||||
int cst_settings_trust(const char *workspace, int on);
|
int cst_settings_trust(const char *workspace, int on);
|
||||||
|
|||||||
@@ -9,16 +9,28 @@
|
|||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/* --- image attachments ---------------------------------------------------
|
/* --- attachments ---------------------------------------------------------
|
||||||
* An attachment is a validated copy of an image file, stored beside the
|
* An attachment is a validated copy of a file, stored beside the session so
|
||||||
* session so the bytes a model is shown stay the bytes that were picked.
|
* what a model is shown stays what was picked. Images are stored as they are
|
||||||
* Only the metadata travels in the JSONL; the bytes are encoded into the
|
* and encoded into the request when it is built; a PDF is reduced to its text
|
||||||
* request when it is built. */
|
* layer and a text file is stored as is, both of which travel inline as text
|
||||||
|
* (so a text-only model still gets them). Only the metadata goes in the
|
||||||
|
* JSONL. */
|
||||||
|
|
||||||
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */
|
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images), PDF, UTF-8 text. */
|
||||||
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */
|
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one picked file */
|
||||||
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
|
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
|
||||||
#define CST_ATTACH_MAX_COUNT 8 /* per message */
|
#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);
|
||||||
|
int cst_attach_is_text(const cst_attachment_t *a);
|
||||||
|
|
||||||
/* Magic-byte test: 0 and a canonical mime when data holds a supported
|
/* Magic-byte test: 0 and a canonical mime when data holds a supported
|
||||||
* image, -1 otherwise (an extension alone never decides). */
|
* image, -1 otherwise (an extension alone never decides). */
|
||||||
@@ -35,23 +47,33 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
|
|||||||
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
||||||
char *out, size_t out_sz);
|
char *out, size_t out_sz);
|
||||||
|
|
||||||
/* Read src_path, verify it is a supported image within limits, and copy it
|
/* Read src_path and copy it into the session's attachment directory: an image
|
||||||
* into the session's attachment directory. Returns a malloc'd entry the
|
* as it is, a PDF as its extracted text, anything else only when it is text.
|
||||||
* caller owns (cst_attach_free), or NULL with err filled. */
|
* Returns a malloc'd entry the caller owns (cst_attach_free), or NULL with
|
||||||
|
* err filled. */
|
||||||
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||||
const char *src_path, char *err,
|
const char *src_path, char *err,
|
||||||
size_t err_sz);
|
size_t err_sz);
|
||||||
/* Store already-read bytes (clipboard, downloads); name is display-only. */
|
/* Store already-read image bytes (clipboard, downloads); name is
|
||||||
|
* display-only. */
|
||||||
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||||
const char *name, const char *data,
|
const char *name, const char *data,
|
||||||
size_t n, char *err, size_t err_sz);
|
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 image, or NULL when it is gone. */
|
/* 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,
|
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||||
size_t *out_len, char *err, size_t err_sz);
|
size_t *out_len, char *err, size_t err_sz);
|
||||||
/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */
|
/* "data:image/png;base64,...." for the wire; images only, NULL otherwise. */
|
||||||
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
|
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
|
||||||
/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */
|
/* "1920x1080 png 412 KB" / "pdf 1.2 MB, 12 pages" one-liner for prompts. */
|
||||||
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
|
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
|
||||||
|
|
||||||
void cst_attach_free(cst_attachment_t *a);
|
void cst_attach_free(cst_attachment_t *a);
|
||||||
|
|||||||
@@ -13,10 +13,28 @@ extern "C" {
|
|||||||
* ("no image on the clipboard", "install wl-clipboard", ...). */
|
* ("no image on the clipboard", "install wl-clipboard", ...). */
|
||||||
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
|
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Cap on text moved through the clipboard (read and write). */
|
||||||
|
#define CST_CLIPBOARD_TEXT_MAX (1024 * 1024)
|
||||||
|
|
||||||
|
/* UTF-8 text on the system clipboard: Wayland (wl-paste), X11 (xclip),
|
||||||
|
* macOS (pbpaste), Windows (CF_UNICODETEXT). Returns 0 with *data malloc'd
|
||||||
|
* and NUL-terminated, or -1 with err filled in ("no text on the clipboard",
|
||||||
|
* "reading the clipboard needs wl-clipboard", ...). */
|
||||||
|
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Replace the clipboard with UTF-8 text: wl-copy / xclip / pbcopy, or the
|
||||||
|
* Windows clipboard API. Returns 0 on success, else -1 with err. */
|
||||||
|
int cst_clipboard_set_text(const char *text, size_t n, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
|
||||||
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
|
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
|
||||||
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
|
* 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);
|
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_MAX_PATHS 8
|
||||||
#define CST_PASTE_PATH_MAX 1024
|
#define CST_PASTE_PATH_MAX 1024
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#ifndef CST_HTTP_H
|
||||||
|
#define CST_HTTP_H
|
||||||
|
|
||||||
|
/* A small HTTP/1.1 server for the built-in WebUI (src/http_srv.c).
|
||||||
|
*
|
||||||
|
* Only what a local single-page app needs: exact-match routes, keep-alive,
|
||||||
|
* fixed-length bodies and chunked streaming responses (SSE). No TLS, no
|
||||||
|
* HTTP/2, no chunked *request* bodies, no CGI. Every connection is served
|
||||||
|
* by its own thread; a handler may keep the connection as long as it wants
|
||||||
|
* (that is how the event stream works). */
|
||||||
|
|
||||||
|
#include "cst_net.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct cst_http_conn cst_http_conn_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char method[8]; /* GET / POST / DELETE ... */
|
||||||
|
char path[1024]; /* percent-decoded, without the query string */
|
||||||
|
char query[1024]; /* raw, without '?' */
|
||||||
|
const char *body; /* NUL-terminated; may hold binary bytes */
|
||||||
|
size_t body_len;
|
||||||
|
/* Headers of interest, NULL when absent (pointers into the request). */
|
||||||
|
const char *authorization;
|
||||||
|
const char *cookie;
|
||||||
|
const char *origin;
|
||||||
|
const char *host;
|
||||||
|
const char *content_type;
|
||||||
|
const char *user_agent;
|
||||||
|
const char *last_event_id; /* an EventSource reconnecting */
|
||||||
|
int keep_alive;
|
||||||
|
} cst_http_req_t;
|
||||||
|
|
||||||
|
/* 0 = handled (a response was sent), -1 = not found (the server replies). */
|
||||||
|
typedef int (*cst_http_fn)(cst_http_conn_t *c, void *ud);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *method; /* "*" matches any */
|
||||||
|
const char *path; /* exact; "*" = fallback (try last) */
|
||||||
|
cst_http_fn fn;
|
||||||
|
void *ud;
|
||||||
|
int auth; /* ask the auth hook before the handler */
|
||||||
|
size_t max_body; /* request body cap; 0 = server default */
|
||||||
|
} cst_http_route_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *host; /* NULL/"" = 127.0.0.1 */
|
||||||
|
int port; /* 0 = pick a free port */
|
||||||
|
size_t max_body; /* default 1 MiB */
|
||||||
|
int max_conn; /* default 32 */
|
||||||
|
int read_timeout_ms; /* waiting for the request headers (default 15000) */
|
||||||
|
int idle_timeout_ms; /* keep-alive pause between requests (default 60000) */
|
||||||
|
const cst_http_route_t *routes;
|
||||||
|
int nroutes;
|
||||||
|
/* 0 = allow, -1 = the server answers 401 before the handler runs. */
|
||||||
|
int (*auth)(const cst_http_req_t *req, void *ud);
|
||||||
|
void *auth_ud;
|
||||||
|
/* One line per request, e.g. "GET /api/state -> 200". */
|
||||||
|
void (*on_log)(const char *line, void *ud);
|
||||||
|
void *log_ud;
|
||||||
|
} cst_http_opts_t;
|
||||||
|
|
||||||
|
typedef struct cst_http_server cst_http_server_t;
|
||||||
|
|
||||||
|
/* Start listening. NULL on failure with a reason in err. */
|
||||||
|
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
int cst_http_port(const cst_http_server_t *s);
|
||||||
|
/* Non-zero once stop() was called: long-lived handlers must return. */
|
||||||
|
int cst_http_stopping(const cst_http_server_t *s);
|
||||||
|
/* Close the listener and every connection, then wait (30 s at most) for the
|
||||||
|
* handler threads. Returns how many still run: their ud must stay alive. */
|
||||||
|
int cst_http_stop(cst_http_server_t *s);
|
||||||
|
/* Does nothing while handlers still run on s. */
|
||||||
|
void cst_http_free(cst_http_server_t *s);
|
||||||
|
|
||||||
|
/* --- inside a handler --- */
|
||||||
|
const cst_http_req_t *cst_http_request(cst_http_conn_t *c);
|
||||||
|
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
|
||||||
|
const void *body, size_t n);
|
||||||
|
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
|
||||||
|
const char *s);
|
||||||
|
/* json may be NULL (then "null" is sent); it is copied, not owned. */
|
||||||
|
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json);
|
||||||
|
/* {"error":"..."} */
|
||||||
|
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg);
|
||||||
|
/* True once a response (or stream) began for this request. */
|
||||||
|
int cst_http_sent(const cst_http_conn_t *c);
|
||||||
|
|
||||||
|
/* Chunked response: begin(), any number of write()s, end(). */
|
||||||
|
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype);
|
||||||
|
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n);
|
||||||
|
int cst_http_stream_end(cst_http_conn_t *c);
|
||||||
|
|
||||||
|
/* Query parameter, percent-decoded ('+' = space). 0 when found. */
|
||||||
|
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
|
||||||
|
size_t out_sz);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* CST_HTTP_H */
|
||||||
@@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p);
|
|||||||
/* The user file's path (it may not exist). */
|
/* The user file's path (it may not exist). */
|
||||||
int cst_models_user_path(char *out, size_t out_sz);
|
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
|
/* Drop everything and read the built-in data, the user file and the caches
|
||||||
* again. Provider pointers stay valid but may name another provider. */
|
* again. Provider pointers stay valid but may name another provider. */
|
||||||
void cst_models_reload(void);
|
void cst_models_reload(void);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#ifndef CST_NET_H
|
||||||
|
#define CST_NET_H
|
||||||
|
|
||||||
|
/* Minimal TCP sockets for the embedded HTTP server (src/net.c).
|
||||||
|
*
|
||||||
|
* The point of this layer is that src/http_srv.c and src/webui.c never
|
||||||
|
* include a socket header: Unix file descriptors and Windows SOCKETs differ
|
||||||
|
* in type, close() and error reporting, and Windows needs WSAStartup before
|
||||||
|
* anything works. */
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
typedef intptr_t os_socket_t;
|
||||||
|
#define OS_SOCKET_BAD ((os_socket_t)-1)
|
||||||
|
#else
|
||||||
|
typedef int os_socket_t;
|
||||||
|
#define OS_SOCKET_BAD (-1)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Longest "address:port" os_net_peer writes (IPv6 hosts are bracketed so a
|
||||||
|
* URL built from it stays unambiguous). */
|
||||||
|
#define OS_NET_ADDR_MAX 64
|
||||||
|
|
||||||
|
/* Winsock startup (no-op on Unix, which also ignores SIGPIPE so a client
|
||||||
|
* that hangs up cannot kill the process). 0 / -1. */
|
||||||
|
int os_net_init(void);
|
||||||
|
void os_net_cleanup(void);
|
||||||
|
|
||||||
|
/* Bind and listen on host:port. host may be NULL/"" (then "127.0.0.1"),
|
||||||
|
* "0.0.0.0"/"::" (every interface) or any literal address / name; port 0
|
||||||
|
* picks a free one. Returns the listening socket, or OS_SOCKET_BAD with a
|
||||||
|
* short reason in err. */
|
||||||
|
os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz);
|
||||||
|
/* The port actually bound (0 when unknown). */
|
||||||
|
int os_net_port(os_socket_t s);
|
||||||
|
/* A one-line "host:port" for messages. */
|
||||||
|
void os_net_peer(os_socket_t s, char *out, size_t out_sz);
|
||||||
|
|
||||||
|
/* Connect to host:port, waiting up to timeout_ms: the socket, or
|
||||||
|
* OS_SOCKET_BAD. Used by the tests and by anything that drives the WebUI
|
||||||
|
* API from inside the process. */
|
||||||
|
os_socket_t os_net_connect(const char *host, int port, int timeout_ms);
|
||||||
|
|
||||||
|
/* Accept one connection, waiting up to timeout_ms (<0 = forever).
|
||||||
|
* Returns the socket, OS_SOCKET_BAD on error, or OS_SOCKET_BAD with
|
||||||
|
* *timed_out set when the wait ran out. */
|
||||||
|
os_socket_t os_net_accept(os_socket_t s, int timeout_ms, int *timed_out);
|
||||||
|
|
||||||
|
/* Read at most n bytes, waiting up to timeout_ms: bytes read, 0 at EOF,
|
||||||
|
* -1 on error, -2 when nothing arrived in time. */
|
||||||
|
long os_net_recv(os_socket_t s, void *buf, size_t n, int timeout_ms);
|
||||||
|
/* Write everything (timeout_ms per call): 0 / -1. */
|
||||||
|
int os_net_send_all(os_socket_t s, const void *buf, size_t n, int timeout_ms);
|
||||||
|
/* Wake up a thread blocked in recv/send on this socket (shutdown, no close). */
|
||||||
|
void os_net_shutdown(os_socket_t s);
|
||||||
|
void os_net_close(os_socket_t s);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* CST_NET_H */
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#ifndef CST_PDF_H
|
||||||
|
#define CST_PDF_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Text layer of a PDF, in page order, with no external dependency (no
|
||||||
|
* poppler, no zlib): classic objects and object streams, FlateDecode plus
|
||||||
|
* ASCIIHex/ASCII85, PNG/TIFF predictors, Tj/TJ/'/" text with per-font
|
||||||
|
* ToUnicode CMaps and a WinAnsi / Latin-1 fallback. Scanned pages have no
|
||||||
|
* text layer and are reported as such.
|
||||||
|
* Returns a malloc'd NUL-terminated UTF-8 string, or NULL with err filled in
|
||||||
|
* ("PDF is encrypted ...", "no extractable text ..."). *pages (optional) gets
|
||||||
|
* the page count; the result is truncated at CST_PDF_TEXT_MAX bytes. */
|
||||||
|
char *cst_pdf_text(const void *data, size_t n, int *pages, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
|
||||||
|
#define CST_PDF_TEXT_MAX (4u * 1024u * 1024u)
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#ifndef CST_PROVIDER_SWITCH_H
|
||||||
|
#define CST_PROVIDER_SWITCH_H
|
||||||
|
|
||||||
|
/* Model lists, API keys and provider switches, shared by the TUI and the
|
||||||
|
* WebUI so both front ends validate and persist the same way.
|
||||||
|
*
|
||||||
|
* A key is checked with GET /models: a 401/403 (CST_MODELS_AUTH) rejects
|
||||||
|
* it, any other failure (offline, no /models route) lets it through
|
||||||
|
* unverified. A key that passes is saved to <config>/credentials. Keys are
|
||||||
|
* never copied into err. */
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_models.h"
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define CST_PS_MAX_MODELS 512
|
||||||
|
#define CST_PS_UNKNOWN (-1) /* no such provider */
|
||||||
|
#define CST_PS_NEED_KEY (-10) /* no key given, none saved or in the env */
|
||||||
|
/* Non-negative results are flags: */
|
||||||
|
#define CST_PS_UNVERIFIED 1 /* /models failed for another reason; err says */
|
||||||
|
#define CST_PS_UNSAVED 2 /* the credentials file could not be written */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char id[128];
|
||||||
|
int api; /* listed by the provider's /models (else built-in only) */
|
||||||
|
cst_model_info_t info;
|
||||||
|
} cst_ps_model_t;
|
||||||
|
|
||||||
|
/* GET /models; a good answer refreshes models-cache/<provider>.json. The
|
||||||
|
* count, CST_MODELS_AUTH, or another negative value with err set. */
|
||||||
|
int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
|
||||||
|
char ids[][128], int max, char *err, size_t err_sz);
|
||||||
|
/* The cached list: count, or -1; *fresh when younger than the TTL. */
|
||||||
|
int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh);
|
||||||
|
|
||||||
|
/* Base URL for provider: cfg's own when it is the current provider (it may
|
||||||
|
* be overridden), else the preset's; "" when unknown. */
|
||||||
|
const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider);
|
||||||
|
|
||||||
|
/* The model picker list for cfg's provider: the API's ids (the cache while
|
||||||
|
* it is fresh, else a fetch; refresh always fetches) followed by the
|
||||||
|
* built-in names it did not list. *api_rc gets the fetch result (count,
|
||||||
|
* CST_MODELS_AUTH, -1 with err) or the cached count. Returns the count. */
|
||||||
|
int cst_ps_models(const cst_agent_config_t *cfg, int refresh,
|
||||||
|
cst_ps_model_t *out, int max, int *api_rc, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
|
||||||
|
/* Checks key against /models and saves it: CST_MODELS_AUTH (err set), or
|
||||||
|
* the CST_PS_* flags. When ids is given it gets the fetched list and *nids
|
||||||
|
* the raw fetch result. */
|
||||||
|
int cst_ps_verify_save(const char *provider, const char *api_base,
|
||||||
|
const char *key, char ids[][128], int max, int *nids,
|
||||||
|
char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Point cfg at provider p with key: base URL, the remembered model (else
|
||||||
|
* the preset's), settings saved. */
|
||||||
|
void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p,
|
||||||
|
const char *key);
|
||||||
|
|
||||||
|
/* The whole switch without a prompt: key NULL/"" uses the saved or env
|
||||||
|
* key (else CST_PS_NEED_KEY); a given key is verified and saved first. A
|
||||||
|
* preset with no model takes the first one the API lists. The CST_PS_*
|
||||||
|
* flags, or CST_PS_UNKNOWN / CST_PS_NEED_KEY / CST_MODELS_AUTH. */
|
||||||
|
int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
|
||||||
|
const char *key, char *err, size_t err_sz);
|
||||||
|
|
||||||
|
/* Replace the current provider's key: verified, saved, put in cfg. Same
|
||||||
|
* results as cst_ps_verify_save. */
|
||||||
|
int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
|
||||||
|
size_t err_sz);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -45,6 +45,8 @@ char *cst_skill_read(const char *workspace, const char *name,
|
|||||||
* task. NULL when the skill does not exist. malloc'd. */
|
* task. NULL when the skill does not exist. malloc'd. */
|
||||||
char *cst_skill_prompt(const char *workspace, const char *name,
|
char *cst_skill_prompt(const char *workspace, const char *name,
|
||||||
const char *task);
|
const char *task);
|
||||||
|
/* User message for `/init`: have the agent write AGENTS.md. */
|
||||||
|
const char *cst_init_prompt(void);
|
||||||
/* Register the `skill` tool (idempotent). */
|
/* Register the `skill` tool (idempotent). */
|
||||||
int cst_skills_register_tool(void);
|
int cst_skills_register_tool(void);
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx,
|
|||||||
const ctl_tool_call_t *call,
|
const ctl_tool_call_t *call,
|
||||||
cst_tool_result_t *r, void *ud);
|
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 {
|
typedef struct {
|
||||||
const char *name;
|
const char *name;
|
||||||
const char *desc;
|
const char *desc;
|
||||||
@@ -81,10 +89,34 @@ typedef struct {
|
|||||||
const char *owner; /* "plugin:<name>", "mcp:<server>", ... */
|
const char *owner; /* "plugin:<name>", "mcp:<server>", ... */
|
||||||
/* Non-READ tools that may still join a parallel batch for this call. */
|
/* Non-READ tools that may still join a parallel batch for this call. */
|
||||||
int (*parallel)(const ctl_tool_call_t *call, void *ud);
|
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;
|
} 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
|
/* 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);
|
int cst_tool_register(const cst_tool_def_t *def);
|
||||||
/* Drop every dynamic tool of owner (call only while no turn runs). */
|
/* Drop every dynamic tool of owner (call only while no turn runs). */
|
||||||
int cst_tool_unregister_owner(const char *owner);
|
int cst_tool_unregister_owner(const char *owner);
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ size_t cst_utf8_floor(const char *s, size_t n);
|
|||||||
char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
|
char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
|
||||||
int cst_utf8_valid(const char *s, size_t n);
|
int cst_utf8_valid(const char *s, size_t n);
|
||||||
|
|
||||||
|
/* Re-chunks a byte stream so no piece ends inside a UTF-8 sequence: the
|
||||||
|
* incomplete tail (at most 3 bytes) waits in the hold for the next feed. */
|
||||||
|
typedef struct {
|
||||||
|
char b[4];
|
||||||
|
size_t n;
|
||||||
|
} cst_utf8_hold_t;
|
||||||
|
/* Returns the complete prefix of hold+s[0..n) (malloc'd, NUL-terminated,
|
||||||
|
* *out_len may be 0), or NULL on OOM. */
|
||||||
|
char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n,
|
||||||
|
size_t *out_len);
|
||||||
|
|
||||||
/* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3),
|
/* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3),
|
||||||
* and an in-place strip of the NUL-terminated s (*len updated when given). */
|
* and an in-place strip of the NUL-terminated s (*len updated when given). */
|
||||||
size_t cst_bom_len(const char *s, size_t n);
|
size_t cst_bom_len(const char *s, size_t n);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
#define CST_VERSION_H
|
#define CST_VERSION_H
|
||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 7
|
#define CST_VERSION_MINOR 9
|
||||||
#define CST_VERSION_PATCH 1
|
#define CST_VERSION_PATCH 6
|
||||||
|
|
||||||
#define CST_VERSION_STR_(x) #x
|
#define CST_VERSION_STR_(x) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#ifndef CST_WEB_MD_H
|
||||||
|
#define CST_WEB_MD_H
|
||||||
|
|
||||||
|
/* Markdown for the WebUI: the same sashidown renderer the TUI uses, but
|
||||||
|
* HTML output with a strict allowlist.
|
||||||
|
*
|
||||||
|
* sashidown renders straight to HTML and escapes text, quotes and '&', so
|
||||||
|
* no raw markup can reach the browser. Two holes remain, and both matter
|
||||||
|
* because every byte rendered here comes from a model or a tool: a link's
|
||||||
|
* href keeps any scheme ("javascript:") and an image's src may point
|
||||||
|
* anywhere (a tracking pixel, or worse). cst_web_md_* therefore rewrites
|
||||||
|
* tags instead of passing them through: unknown tags are dropped, hrefs
|
||||||
|
* are limited to http/https/mailto/relative, and images to the session's
|
||||||
|
* own attachment endpoint.
|
||||||
|
*
|
||||||
|
* The streaming form renders confirmed HTML as the model talks, which is
|
||||||
|
* the CST property the project is built on: nothing is emitted before it
|
||||||
|
* is safe to emit. A tag split across two deltas is held back until its
|
||||||
|
* '>' arrives. */
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Called with chunks of sanitized HTML; return non-zero to stop. */
|
||||||
|
typedef int (*cst_web_md_emit_fn)(const char *html, size_t len, void *ud);
|
||||||
|
|
||||||
|
typedef struct cst_web_md cst_web_md_t;
|
||||||
|
|
||||||
|
cst_web_md_t *cst_web_md_new(cst_web_md_emit_fn emit, void *ud);
|
||||||
|
/* Feed model output. Returns 0, or -1 on OOM. */
|
||||||
|
int cst_web_md_feed(cst_web_md_t *s, const char *data, size_t len);
|
||||||
|
/* Flush the pending input and close the renderer. */
|
||||||
|
int cst_web_md_finish(cst_web_md_t *s);
|
||||||
|
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);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* CST_WEB_MD_H */
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#ifndef CST_WEBUI_H
|
||||||
|
#define CST_WEBUI_H
|
||||||
|
|
||||||
|
/* The built-in WebUI: an HTTP + SSE front end for one agent process.
|
||||||
|
*
|
||||||
|
* It is a second implementation of the same interface the TUI uses
|
||||||
|
* (cst_agent_hooks_t), which is why the engine needs no changes: the turn
|
||||||
|
* runs on a worker thread and every live event goes out as a Server-Sent
|
||||||
|
* Event. The protocol is documented in docs/webui.md and is meant to be
|
||||||
|
* the contract a dedicated client speaks later. */
|
||||||
|
|
||||||
|
#include "cst_agent.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* What --web listens on when --web-port/CST_WEB_PORT says nothing (the
|
||||||
|
* README and --help quote this number, so keep them in sync). */
|
||||||
|
#define CST_WEBUI_DEFAULT_PORT 8787
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *host; /* NULL = 127.0.0.1; "0.0.0.0" = every interface */
|
||||||
|
int port; /* 0 = pick a free port */
|
||||||
|
const char *token; /* NULL = generate one when needed */
|
||||||
|
const char *root; /* serve the UI from this directory instead of the
|
||||||
|
copy embedded in the binary (development) */
|
||||||
|
int open_browser; /* best-effort xdg-open / open / start */
|
||||||
|
/* Prompt to run once at startup (--web -p ...); NULL = wait for the UI. */
|
||||||
|
const char *prompt;
|
||||||
|
int perm_timeout_s; /* how long a permission question waits (0 = 600) */
|
||||||
|
} cst_webui_opts_t;
|
||||||
|
|
||||||
|
/* Serve until interrupted (Ctrl+C) or the event stream asks to quit.
|
||||||
|
* Returns the process exit code. */
|
||||||
|
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* CST_WEBUI_H */
|
||||||
@@ -114,9 +114,10 @@ int os_win_get_label(const char *path, char *out, size_t n);
|
|||||||
int os_clipboard_set(const char *utf8, size_t n);
|
int os_clipboard_set(const char *utf8, size_t n);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/* Child processes get stdin=/dev/null, their own process group, and are
|
/* Child processes get stdin=/dev/null and their own process group. They run
|
||||||
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
|
* with no time limit unless one is asked for (CST_SHELL_TIMEOUT seconds, or
|
||||||
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
|
* os_set_run_timeout()); either way a non-zero *flag cancels them.
|
||||||
|
* Exit code 124 = killed by that timeout, 130 = cancelled. */
|
||||||
void os_set_cancel_flag(volatile int *flag);
|
void os_set_cancel_flag(volatile int *flag);
|
||||||
volatile int *os_get_cancel_flag(void);
|
volatile int *os_get_cancel_flag(void);
|
||||||
void os_set_run_timeout(int seconds); /* this thread; 0 = default */
|
void os_set_run_timeout(int seconds); /* this thread; 0 = default */
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static).
|
# Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static).
|
||||||
# scripts/bootstrap_windows.sh [repo-dir]
|
# scripts/bootstrap_windows.sh [repo-dir]
|
||||||
# With Nix the toolchain comes from shell-win.nix; otherwise x86_64-w64-mingw32-gcc
|
# With Nix the toolchain comes from shell-win.nix; otherwise $MINGW_HOST-gcc
|
||||||
# (UCRT flavour, with winpthreads), cmake, ninja, git and perl must be installed.
|
# (default x86_64-w64-mingw32; UCRT flavour, with winpthreads), cmake, ninja,
|
||||||
|
# git and perl must be installed. CST_WIN_CFLAGS adds C flags (CI: -Werror).
|
||||||
# Output: dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip
|
# Output: dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -21,15 +22,17 @@ if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
|
|||||||
fi
|
fi
|
||||||
cd "$DIR"
|
cd "$DIR"
|
||||||
|
|
||||||
if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] &&
|
H="${MINGW_HOST:-x86_64-w64-mingw32}"
|
||||||
|
if ! command -v "$H-gcc" >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] &&
|
||||||
command -v nix-shell >/dev/null; then
|
command -v nix-shell >/dev/null; then
|
||||||
exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")"
|
exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; }
|
need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; }
|
||||||
need git; need cmake; need ninja; need perl; need x86_64-w64-mingw32-gcc
|
export MINGW_HOST="$H"
|
||||||
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then
|
need git; need cmake; need ninja; need perl; need "$H-gcc"
|
||||||
echo "x86_64-w64-mingw32-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2
|
if ! printf '#include <stdio.h>\n' | "$H-gcc" -dM -E - | grep -qw _UCRT; then
|
||||||
|
echo "$H-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -40,7 +43,8 @@ fi
|
|||||||
echo "==> cmake (Windows, TUI)"
|
echo "==> cmake (Windows, TUI)"
|
||||||
cmake -S . -B build-win-tui -G Ninja \
|
cmake -S . -B build-win-tui -G Ninja \
|
||||||
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||||
-DCMAKE_BUILD_TYPE=Release -DCST_BUILD_TUI=ON
|
-DCMAKE_BUILD_TYPE=Release -DCST_BUILD_TUI=ON \
|
||||||
|
-DCMAKE_C_FLAGS="${CST_WIN_CFLAGS:-}"
|
||||||
# Package only needs the agent binary; skip host-unrunnable Win32 tests.
|
# Package only needs the agent binary; skip host-unrunnable Win32 tests.
|
||||||
cmake --build build-win-tui --target cst-agent
|
cmake --build build-win-tui --target cst-agent
|
||||||
|
|
||||||
|
|||||||
@@ -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,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build pinned static deps for a Linux release into third_party/prefix-linux-ARCH:
|
||||||
|
# mbedTLS, libcurl (HTTP(S) only, mbedTLS) and wide ncurses.
|
||||||
|
# scripts/build_third_party_linux.sh x86_64|x86|aarch64 [all|mbedtls|curl|ncurses]
|
||||||
|
# Uses LINUX_HOST-gcc from PATH, else fetches it via ci_linux_toolchain.sh.
|
||||||
|
# Host tools: cmake ninja make perl, and tic/infocmp with the fallback
|
||||||
|
# terminal entries (Debian: ncurses-bin ncurses-term).
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ARCH="${1:?usage: $0 x86_64|x86|aarch64 [all|mbedtls|curl|ncurses]}"
|
||||||
|
TP="$ROOT/third_party"
|
||||||
|
PREFIX="$TP/prefix-linux-$ARCH"
|
||||||
|
SRC="$TP/src"
|
||||||
|
WORK="$TP/build-linux-$ARCH"
|
||||||
|
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
|
||||||
|
|
||||||
|
MBEDTLS_VER=3.6.7
|
||||||
|
MBEDTLS_SHA=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6
|
||||||
|
CURL_VER=8.22.0
|
||||||
|
CURL_SHA=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7
|
||||||
|
NCURSES_VER=6.6
|
||||||
|
NCURSES_SHA=355b4cbbed880b0381a04c46617b7656e362585d52e9cf84a67e2009b749ff11
|
||||||
|
# Compiled in, so the TUI starts even where no terminfo database is found.
|
||||||
|
NCURSES_FALLBACKS=xterm-256color,xterm,screen-256color,tmux-256color,linux,vt100
|
||||||
|
|
||||||
|
if [[ -z "${LINUX_HOST:-}" ]] || ! command -v "$LINUX_HOST-gcc" >/dev/null; then
|
||||||
|
read -r LINUX_HOST bindir < <(bash "$ROOT/scripts/ci_linux_toolchain.sh" "$ARCH")
|
||||||
|
export PATH="$bindir:$PATH"
|
||||||
|
fi
|
||||||
|
T="$LINUX_HOST-"
|
||||||
|
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
|
||||||
|
need "${T}gcc"
|
||||||
|
need cmake
|
||||||
|
need ninja
|
||||||
|
need make
|
||||||
|
need tic
|
||||||
|
need infocmp
|
||||||
|
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_cross() { # src build args...
|
||||||
|
local s="$1" b="$2"
|
||||||
|
shift 2
|
||||||
|
cmake -S "$s" -B "$b" -G Ninja \
|
||||||
|
-DCMAKE_SYSTEM_NAME=Linux \
|
||||||
|
-DCMAKE_C_COMPILER="${T}gcc" -DCMAKE_AR="$(command -v "${T}ar")" \
|
||||||
|
-DCMAKE_RANLIB="$(command -v "${T}ranlib")" \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \
|
||||||
|
-DCMAKE_INSTALL_LIBDIR=lib -DCMAKE_PREFIX_PATH="$PREFIX" \
|
||||||
|
-DCMAKE_FIND_ROOT_PATH="$PREFIX" \
|
||||||
|
-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER \
|
||||||
|
-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY \
|
||||||
|
-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY \
|
||||||
|
-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"
|
||||||
|
cmake_cross "$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"
|
||||||
|
# Cross builds skip CA detection; this is only libcurl's default, the
|
||||||
|
# program probes the usual distro paths when it is missing.
|
||||||
|
cmake_cross "$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=/etc/ssl/certs/ca-certificates.crt -DCURL_CA_PATH=none \
|
||||||
|
-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"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_ncurses() {
|
||||||
|
local f="ncurses-$NCURSES_VER.tar.gz"
|
||||||
|
fetch "$f" "$NCURSES_SHA" \
|
||||||
|
"https://invisible-island.net/archives/ncurses/$f" \
|
||||||
|
"https://mirrors.tuna.tsinghua.edu.cn/gnu/ncurses/$f" \
|
||||||
|
"https://ftp.gnu.org/gnu/ncurses/$f"
|
||||||
|
unpack "$f" "ncurses-$NCURSES_VER"
|
||||||
|
local b="$WORK/ncurses-$NCURSES_VER/build"
|
||||||
|
mkdir -p "$b"
|
||||||
|
cd "$b"
|
||||||
|
CC="${T}gcc" AR="${T}ar" RANLIB="${T}ranlib" STRIP="${T}strip" \
|
||||||
|
../configure --host="$LINUX_HOST" --prefix="$PREFIX" \
|
||||||
|
--without-ada --without-cxx --without-cxx-binding --without-manpages \
|
||||||
|
--without-progs --without-tests --without-debug --without-shared \
|
||||||
|
--with-normal --enable-widec --disable-db-install \
|
||||||
|
--with-default-terminfo-dir=/usr/share/terminfo \
|
||||||
|
--with-terminfo-dirs=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo \
|
||||||
|
--with-fallbacks="$NCURSES_FALLBACKS" \
|
||||||
|
--with-tic-path="$(command -v tic)" --with-infocmp-path="$(command -v infocmp)" \
|
||||||
|
--enable-pc-files --with-pkg-config-libdir="$PREFIX/lib/pkgconfig" \
|
||||||
|
--with-build-cc="${BUILD_CC:-cc}" >/dev/null
|
||||||
|
make -j"$JOBS" libs >/dev/null
|
||||||
|
make install.libs >/dev/null
|
||||||
|
echo "ncurses $NCURSES_VER -> $PREFIX"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${2:-all}" in
|
||||||
|
mbedtls) build_mbedtls ;;
|
||||||
|
curl) build_curl ;;
|
||||||
|
ncurses) build_ncurses ;;
|
||||||
|
all)
|
||||||
|
build_mbedtls
|
||||||
|
build_curl
|
||||||
|
build_ncurses
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 ARCH [all|mbedtls|curl|ncurses]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "DONE $ARCH"
|
||||||
@@ -106,7 +106,10 @@ build_ncurses() {
|
|||||||
rm -rf "$src/build-mingw"
|
rm -rf "$src/build-mingw"
|
||||||
mkdir -p "$src/build-mingw"
|
mkdir -p "$src/build-mingw"
|
||||||
cd "$src/build-mingw"
|
cd "$src/build-mingw"
|
||||||
../configure --host="${HOST:-x86_64-w64-mingw32}" --prefix="$PREFIX" \
|
# --host stays canonical (config.sub may not know Debian's "mingw32ucrt");
|
||||||
|
# the tools themselves come from $HOST.
|
||||||
|
CC="${T}gcc" AR="${T}ar" RANLIB="${T}ranlib" STRIP="${T}strip" WINDRES="${T}windres" \
|
||||||
|
../configure --host=x86_64-w64-mingw32 --prefix="$PREFIX" \
|
||||||
--without-ada --without-cxx --without-cxx-binding --without-manpages \
|
--without-ada --without-cxx --without-cxx-binding --without-manpages \
|
||||||
--without-progs --without-tests --without-debug --without-shared \
|
--without-progs --without-tests --without-debug --without-shared \
|
||||||
--enable-widec --enable-sp-funcs --enable-term-driver \
|
--enable-widec --enable-sp-funcs --enable-term-driver \
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fetch a musl cross gcc for a static Linux release build.
|
||||||
|
# scripts/ci_linux_toolchain.sh x86_64|x86|aarch64
|
||||||
|
# Prints "HOST BINDIR" on stdout (e.g. "x86_64-buildroot-linux-musl
|
||||||
|
# /opt/x86-64--musl--stable-2025.08-1/bin"); progress goes to stderr.
|
||||||
|
# Bootlin's pinned stable release first, musl.cc as a fallback.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ARCH="${1:?usage: $0 x86_64|x86|aarch64}"
|
||||||
|
BOOTLIN_VER="${CST_BOOTLIN_VER:-2025.08-1}"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64)
|
||||||
|
bl=x86-64 bl_host=x86_64-buildroot-linux-musl mc=x86_64-linux-musl
|
||||||
|
sha=09fca3aa89540f1b01b5f4210d488cbeb00f522044c53e9989b1dd8a38076912
|
||||||
|
;;
|
||||||
|
x86)
|
||||||
|
bl=x86-i686 bl_host=i686-buildroot-linux-musl mc=i686-linux-musl
|
||||||
|
sha=3a340ebc386a057f5ddb24d8f4bbde07d2c013c99cebc02ac38565e2849bfda0
|
||||||
|
;;
|
||||||
|
aarch64)
|
||||||
|
bl=aarch64 bl_host=aarch64-buildroot-linux-musl mc=aarch64-linux-musl
|
||||||
|
sha=defba831ffa1175236f137069333e21ed46d4d19feb5080a90cf248b6fc2cb08
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown arch: $ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
[[ "$BOOTLIN_VER" == 2025.08-1 ]] || sha="" # other releases: no pinned hash
|
||||||
|
|
||||||
|
if [[ -n "${CST_TOOLCHAIN_ROOT:-}" ]]; then
|
||||||
|
ROOT_DIR="$CST_TOOLCHAIN_ROOT"
|
||||||
|
elif [[ -w /opt || "$(id -u)" -eq 0 ]]; then
|
||||||
|
ROOT_DIR=/opt
|
||||||
|
else
|
||||||
|
ROOT_DIR="$HOME/.local/toolchains"
|
||||||
|
fi
|
||||||
|
mkdir -p "$ROOT_DIR"
|
||||||
|
|
||||||
|
fetch() { # out url...
|
||||||
|
local out="$1" u
|
||||||
|
shift
|
||||||
|
for u in "$@"; do
|
||||||
|
echo " try $u" >&2
|
||||||
|
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 900 -o "$out" "$u"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
emit() { # host bindir
|
||||||
|
"$2/$1-gcc" -dumpmachine >&2
|
||||||
|
[[ -n "${GITHUB_PATH:-}" ]] && echo "$2" >>"$GITHUB_PATH"
|
||||||
|
echo "$1 $2"
|
||||||
|
}
|
||||||
|
|
||||||
|
name="$bl--musl--stable-$BOOTLIN_VER"
|
||||||
|
dir="$ROOT_DIR/$name"
|
||||||
|
if [[ -x "$dir/bin/$bl_host-gcc" ]]; then
|
||||||
|
emit "$bl_host" "$dir/bin"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> toolchain $name" >&2
|
||||||
|
tmp="$ROOT_DIR/$name.tar.xz"
|
||||||
|
if fetch "$tmp" "https://toolchains.bootlin.com/downloads/releases/toolchains/$bl/tarballs/$name.tar.xz" &&
|
||||||
|
{ [[ -z "$sha" ]] || echo "$sha $tmp" | sha256sum -c --quiet - >&2; }; then
|
||||||
|
tar -C "$ROOT_DIR" -xJf "$tmp"
|
||||||
|
rm -f "$tmp"
|
||||||
|
# Bootlin toolchains are relocatable but must be told once where they are.
|
||||||
|
[[ -x "$dir/relocate-sdk.sh" ]] && "$dir/relocate-sdk.sh" >&2
|
||||||
|
emit "$bl_host" "$dir/bin"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
|
||||||
|
echo "==> bootlin unavailable, falling back to musl.cc" >&2
|
||||||
|
mdir="$ROOT_DIR/$mc-cross"
|
||||||
|
if [[ ! -x "$mdir/bin/$mc-gcc" ]]; then
|
||||||
|
tgz="$ROOT_DIR/$mc-cross.tgz"
|
||||||
|
fetch "$tgz" "https://musl.cc/$mc-cross.tgz" "https://more.musl.cc/x86_64-linux-musl/$mc-cross.tgz" ||
|
||||||
|
{ echo "no musl toolchain for $ARCH" >&2; exit 1; }
|
||||||
|
tar -C "$ROOT_DIR" -xzf "$tgz"
|
||||||
|
rm -f "$tgz"
|
||||||
|
fi
|
||||||
|
emit "$mc" "$mdir/bin"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# CI helper: build the pinned TinyCC used for the verification job.
|
||||||
|
# Distro tcc packages are too old (no <stdatomic.h>), so this builds the same
|
||||||
|
# mob snapshot nixpkgs ships. Installs to $CST_TCC_PREFIX (default ~/.local/tcc)
|
||||||
|
# and, under Actions, puts its bin/ on $GITHUB_PATH.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMMIT="${CST_TCC_COMMIT:-f6385c05308f715bdd2c06336801193a21d69b50}"
|
||||||
|
PREFIX="${CST_TCC_PREFIX:-$HOME/.local/tcc}"
|
||||||
|
WORK="${TMPDIR:-/tmp}/tcc-build"
|
||||||
|
|
||||||
|
if [[ -x "$PREFIX/bin/tcc" ]]; then
|
||||||
|
echo "==> tcc already at $PREFIX"
|
||||||
|
else
|
||||||
|
rm -rf "$WORK"
|
||||||
|
mkdir -p "$WORK"
|
||||||
|
TGZ="$WORK/tinycc.tar.gz"
|
||||||
|
# repo.or.cz is upstream but often unreachable from CN runners; the GitHub
|
||||||
|
# mirror carries the same commits.
|
||||||
|
urls=(
|
||||||
|
"https://ghfast.top/https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
|
||||||
|
"https://ghproxy.net/https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
|
||||||
|
"https://github.com/TinyCC/tinycc/archive/${COMMIT}.tar.gz"
|
||||||
|
"https://repo.or.cz/tinycc.git/snapshot/${COMMIT}.tar.gz"
|
||||||
|
)
|
||||||
|
ok=0
|
||||||
|
for u in "${urls[@]}"; do
|
||||||
|
echo " try $u"
|
||||||
|
if curl -fsSL --retry 2 --connect-timeout 15 --max-time 300 -o "$TGZ" "$u" &&
|
||||||
|
tar -tzf "$TGZ" 2>/dev/null | sed -n 1p | grep -q "${COMMIT:0:7}"; then
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[[ $ok -eq 1 ]] || { echo "failed to download tinycc $COMMIT" >&2; exit 1; }
|
||||||
|
tar -C "$WORK" -xzf "$TGZ"
|
||||||
|
src=$(find "$WORK" -mindepth 1 -maxdepth 1 -type d -name 'tinycc*' | head -1)
|
||||||
|
cd "$src"
|
||||||
|
./configure --prefix="$PREFIX" --cc="${BUILD_CC:-cc}"
|
||||||
|
make -j"$(nproc)"
|
||||||
|
make install
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$PREFIX/bin/tcc" -v
|
||||||
|
if [[ -n "${GITHUB_PATH:-}" ]]; then
|
||||||
|
echo "$PREFIX/bin" >>"$GITHUB_PATH"
|
||||||
|
fi
|
||||||
@@ -1,64 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# CI helper: MinGW-w64 UCRT toolchain via llvm-mingw (no Nix / no /nix).
|
# CI helper: Windows x86_64 release with Debian's mingw-w64 UCRT gcc.
|
||||||
# Downloads a pinned release, puts it on PATH, then runs bootstrap_windows.sh.
|
# apt-get install gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis (trixie+)
|
||||||
|
# Checks the toolchain, then runs bootstrap_windows.sh, which builds the
|
||||||
|
# third-party libs, cst-agent.exe, the zip and the NSIS installer.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
VER="${CST_LLVM_MINGW_VER:-20241030}"
|
export MINGW_HOST="${MINGW_HOST:-x86_64-w64-mingw32ucrt}"
|
||||||
NAME="llvm-mingw-${VER}-ucrt-ubuntu-20.04-x86_64"
|
|
||||||
TGZ="/tmp/${NAME}.tar.xz"
|
|
||||||
# Prefer /opt when writable (root CI); else unpack under $HOME.
|
|
||||||
if [[ -w /opt || "$(id -u)" -eq 0 ]]; then
|
|
||||||
DIR="/opt/${NAME}"
|
|
||||||
PARENT=/opt
|
|
||||||
else
|
|
||||||
DIR="${HOME}/${NAME}"
|
|
||||||
PARENT="$HOME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -x "$DIR/bin/x86_64-w64-mingw32-gcc" ]]; then
|
|
||||||
echo "==> download $NAME"
|
|
||||||
# Prefer CN-reachable GitHub proxies; raw github.com is often unreachable.
|
|
||||||
urls=(
|
|
||||||
"https://ghfast.top/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
|
|
||||||
"https://ghproxy.net/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
|
|
||||||
"https://gitclone.com/github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
|
|
||||||
"https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
|
|
||||||
)
|
|
||||||
ok=0
|
|
||||||
for u in "${urls[@]}"; do
|
|
||||||
echo " try $u"
|
|
||||||
if curl -fL --retry 2 --connect-timeout 15 --max-time 600 \
|
|
||||||
--retry-delay 2 -o "$TGZ" "$u"; then
|
|
||||||
# Reject tiny error pages that some proxies return with HTTP 200.
|
|
||||||
sz=$(wc -c <"$TGZ")
|
|
||||||
if [[ "$sz" -gt 10000000 ]]; then
|
|
||||||
ok=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo " too small ($sz bytes), next mirror"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[[ $ok -eq 1 ]] || { echo "failed to download llvm-mingw" >&2; exit 1; }
|
|
||||||
rm -rf "$DIR"
|
|
||||||
mkdir -p "$PARENT"
|
|
||||||
tar -C "$PARENT" -xf "$TGZ"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export PATH="$DIR/bin:$PATH"
|
|
||||||
export MINGW_HOST=x86_64-w64-mingw32
|
|
||||||
export BUILD_CC="${BUILD_CC:-cc}"
|
export BUILD_CC="${BUILD_CC:-cc}"
|
||||||
|
export CST_REQUIRE_NSIS=1
|
||||||
|
|
||||||
need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; }
|
need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; }
|
||||||
need x86_64-w64-mingw32-gcc
|
need "$MINGW_HOST-gcc"
|
||||||
need x86_64-w64-mingw32-windres
|
need "$MINGW_HOST-windres"
|
||||||
need cmake; need ninja; need perl; need zip; need git; need make
|
need cmake; need ninja; need perl; need zip; need git; need make; need makensis
|
||||||
|
|
||||||
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then
|
if ! printf '#include <stdio.h>\n' | "$MINGW_HOST-gcc" -dM -E - | grep -qw _UCRT; then
|
||||||
echo "toolchain is not UCRT" >&2
|
echo "$MINGW_HOST-gcc is not UCRT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
"$MINGW_HOST-gcc" --version | head -1
|
||||||
|
|
||||||
# Do not set a global insteadOf: a single broken proxy (e.g. gitclone 502)
|
# Do not set a global insteadOf: a single broken proxy (e.g. gitclone 502)
|
||||||
# would take down every third_party clone. build_third_party_mingw.sh tries
|
# would take down every third_party clone. build_third_party_mingw.sh tries
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# End-to-end check of the WebUI without a real model: a recorded SSE stream
|
||||||
|
# (tests/fixtures/sse) drives a full turn, and the events the browser would
|
||||||
|
# see are read back from /api/events with curl.
|
||||||
|
#
|
||||||
|
# scripts/e2e_webui.sh [replay-dir] [protocol]
|
||||||
|
#
|
||||||
|
# Needs: curl. python3 only for the JSON field checks (they are skipped
|
||||||
|
# without it). Exit code 0 = every assertion passed.
|
||||||
|
set -eu
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
REPLAY="${1:-$ROOT/tests/fixtures/sse/deepseek-native-eos}"
|
||||||
|
PROTO="${2:-native-eos}"
|
||||||
|
BIN="${CST_AGENT:-$ROOT/cst-agent}"
|
||||||
|
[ -x "$BIN" ] || { echo "build cst-agent first ($BIN)"; exit 1; }
|
||||||
|
|
||||||
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/cst-webui-XXXXXX")" 2>/dev/null || {
|
||||||
|
WORK="$ROOT/.build-tmp/e2e-webui"; rm -rf "$WORK"; mkdir -p "$WORK"; }
|
||||||
|
EVENTS="$WORK/events.txt"
|
||||||
|
LOG="$WORK/server.log"
|
||||||
|
cleanup() {
|
||||||
|
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
echo "== start cst-agent --web (replay $REPLAY)"
|
||||||
|
# --yolo: the recorded stream calls write tools, and a permission question
|
||||||
|
# would wait for a browser nobody is driving here.
|
||||||
|
CST_REPLAY_SSE="$REPLAY" \
|
||||||
|
CST_REPLAY_CHUNK=512 \
|
||||||
|
"$BIN" --web --web-port 0 --workspace "$WORK" --new --api-key sk-e2e \
|
||||||
|
--protocol "$PROTO" --tools default --yolo > "$LOG" 2>&1 &
|
||||||
|
SRV_PID=$!
|
||||||
|
|
||||||
|
# The port is picked by the OS; the server prints the URL it bound.
|
||||||
|
i=0
|
||||||
|
PORT=""
|
||||||
|
while [ $i -lt 50 ]; do
|
||||||
|
PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$LOG" | head -1)"
|
||||||
|
[ -n "$PORT" ] && break
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[ -n "$PORT" ] || { echo "FAIL: the server did not report a port"; cat "$LOG"; exit 1; }
|
||||||
|
BASE="http://127.0.0.1:$PORT"
|
||||||
|
echo " port $PORT"
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $1"; echo "--- events"; cat "$EVENTS" 2>/dev/null | head -40; exit 1; }
|
||||||
|
# SIGTERM must shut the server down cleanly, even mid-turn (128+ = a crash)
|
||||||
|
stop_server() {
|
||||||
|
kill "$1" 2>/dev/null || return 0
|
||||||
|
n=0
|
||||||
|
while kill -0 "$1" 2>/dev/null && [ $n -lt 200 ]; do n=$((n + 1)); sleep 0.2; done
|
||||||
|
if kill -0 "$1" 2>/dev/null; then
|
||||||
|
kill -9 "$1" 2>/dev/null || true
|
||||||
|
fail "$2 hung on SIGTERM"
|
||||||
|
fi
|
||||||
|
st=0
|
||||||
|
wait "$1" 2>/dev/null || st=$?
|
||||||
|
[ "$st" -lt 128 ] || fail "$2 died with status $st on SIGTERM"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. the shell page and the state endpoint
|
||||||
|
curl -fsS --max-time 5 "$BASE/" | grep -q "<title>CST-Agent" || fail "GET / is not the shell page"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/state" > "$WORK/state.json" || fail "GET /api/state"
|
||||||
|
grep -q '"protocol":"cst-webui/1"' "$WORK/state.json" || fail "state has no protocol tag"
|
||||||
|
grep -q '"session":' "$WORK/state.json" || fail "state has no session id"
|
||||||
|
|
||||||
|
# 2. an unknown route is a 404, an oversized body a 413
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/does-not-exist")"
|
||||||
|
[ "$code" = "404" ] || fail "unknown route returned $code"
|
||||||
|
code="$(head -c 2000000 /dev/zero | tr '\0' 'a' |
|
||||||
|
curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
|
||||||
|
-X POST --data-binary @- "$BASE/api/turn")"
|
||||||
|
[ "$code" = "413" ] || fail "2 MB body returned $code (want 413)"
|
||||||
|
|
||||||
|
# 3. stream the events while a recorded turn runs
|
||||||
|
curl -sN --max-time 120 "$BASE/api/events" > "$EVENTS" &
|
||||||
|
CURL_PID=$!
|
||||||
|
sleep 0.5
|
||||||
|
code="$(curl -s -o "$WORK/turn.json" -w '%{http_code}' --max-time 5 \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"text":"e2e: what is in the workspace?"}' "$BASE/api/turn")"
|
||||||
|
[ "$code" = "202" ] || fail "POST /api/turn returned $code"
|
||||||
|
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 450 ]; do
|
||||||
|
grep -q "event: turn_end" "$EVENTS" && break
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
kill "$CURL_PID" 2>/dev/null || true
|
||||||
|
grep -q "event: turn_end" "$EVENTS" || fail "the turn never finished"
|
||||||
|
grep -q "event: assistant_html" "$EVENTS" || fail "no assistant markdown was streamed"
|
||||||
|
grep -q "event: tool" "$EVENTS" || fail "no tool event was streamed"
|
||||||
|
grep -q '"rc":0' "$EVENTS" || fail "the turn did not end with rc=0"
|
||||||
|
|
||||||
|
# 4. the session on disk matches what the UI would draw
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/session" > "$WORK/session.json" || fail "GET /api/session"
|
||||||
|
grep -q '"role":"assistant"' "$WORK/session.json" || fail "no assistant message stored"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/sessions" | grep -q '"sessions":' || fail "GET /api/sessions"
|
||||||
|
|
||||||
|
# 5. a second turn while the first is running is refused with 409
|
||||||
|
curl -s -o /dev/null -w '' --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"text":"x"}' "$BASE/api/turn" || true
|
||||||
|
|
||||||
|
# 6. asking to stop is accepted
|
||||||
|
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{}' "$BASE/api/cancel" > /dev/null || fail "POST /api/cancel"
|
||||||
|
|
||||||
|
# 6b. the write endpoints: config, undo, compact and image upload
|
||||||
|
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"approval":"ask","plan_mode":false}' "$BASE/api/config" |
|
||||||
|
grep -q '"ok":true' || fail "POST /api/config"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"approval":"ask"' ||
|
||||||
|
fail "the config change is not in the state"
|
||||||
|
code="$(curl -s -o "$WORK/undo.txt" -w '%{http_code}' --max-time 5 \
|
||||||
|
-X POST "$BASE/api/undo")"
|
||||||
|
case "$code" in
|
||||||
|
200) grep -q . "$WORK/undo.txt" || fail "an undo without a report" ;;
|
||||||
|
409) ;; # nothing to undo in this run
|
||||||
|
*) fail "POST /api/undo returned $code" ;;
|
||||||
|
esac
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
|
||||||
|
-X POST "$BASE/api/compact")"
|
||||||
|
# The recorded stream is spent by now, so a model failure is a valid answer.
|
||||||
|
case "$code" in 200 | 500) ;; *) fail "POST /api/compact returned $code" ;; esac
|
||||||
|
|
||||||
|
if command -v base64 > /dev/null; then
|
||||||
|
# a real 1x1 PNG: the server sniffs the content, not the extension
|
||||||
|
# (kept with a .bin suffix so the find below only sees the stored copy)
|
||||||
|
echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' |
|
||||||
|
base64 -d > "$WORK/tiny.bin" 2> /dev/null || : > "$WORK/tiny.bin"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST \
|
||||||
|
--data-binary @"$WORK/tiny.bin" "$BASE/api/attach?name=e2e.png")"
|
||||||
|
[ "$code" = "200" ] || fail "POST /api/attach returned $code"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/state" |
|
||||||
|
grep -q '"pending_images":\["' || fail "the upload is not pending"
|
||||||
|
# the stored name is the content hash, so ask the disk for it
|
||||||
|
STORED="$(find "$WORK" -name '*.png' -type f 2>/dev/null | head -1)"
|
||||||
|
[ -n "$STORED" ] || fail "the upload did not reach the session directory"
|
||||||
|
REL="${STORED##*/}"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/attach/$REL" > "$WORK/back.png" ||
|
||||||
|
fail "GET /api/attach/$REL"
|
||||||
|
cmp -s "$WORK/tiny.bin" "$WORK/back.png" ||
|
||||||
|
fail "the image did not round trip byte for byte"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
"$BASE/api/attach/..%2f..%2fetc%2fpasswd")"
|
||||||
|
case "$code" in 400 | 404) ;; *) fail "a traversal in attach returned $code" ;; esac
|
||||||
|
curl -fsS --max-time 5 -X DELETE "$BASE/api/attach" | grep -q '"ok":true' ||
|
||||||
|
fail "DELETE /api/attach"
|
||||||
|
curl -fsS --max-time 5 "$BASE/api/state" |
|
||||||
|
grep -q '"pending_images":\[\]' || fail "the pending image was not cleared"
|
||||||
|
else
|
||||||
|
echo " (no base64 tool: the image upload check is skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6c. a request with a foreign Host header is refused while no token is set:
|
||||||
|
# that is the DNS-rebinding guard.
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
-H 'Host: evil.example' "$BASE/api/state")"
|
||||||
|
[ "$code" = "401" ] || fail "a foreign Host header returned $code (want 401)"
|
||||||
|
|
||||||
|
if command -v python3 > /dev/null; then
|
||||||
|
python3 - "$WORK/session.json" <<'PY' || fail "the session JSON is not valid JSON / is empty"
|
||||||
|
import json, sys
|
||||||
|
d = json.load(open(sys.argv[1]))
|
||||||
|
assert isinstance(d.get("messages"), list) and d["messages"], d
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. the interactive approval path: a fresh server in ask mode must raise a
|
||||||
|
# permission event, and answering it must let the turn continue.
|
||||||
|
echo "== permission round trip (-A ask)"
|
||||||
|
stop_server "$SRV_PID" "the first server" # its replay is spent
|
||||||
|
rm -rf "$WORK/pw"; mkdir -p "$WORK/pw"
|
||||||
|
P_EVENTS="$WORK/permission-events.txt"
|
||||||
|
P_LOG="$WORK/permission-server.log" # its own log: the port parse must not
|
||||||
|
# pick up the first server's URL
|
||||||
|
CST_REPLAY_SSE="$REPLAY" CST_REPLAY_CHUNK=512 \
|
||||||
|
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
|
||||||
|
--protocol "$PROTO" --tools default --approval ask --confirm all \
|
||||||
|
> "$P_LOG" 2>&1 &
|
||||||
|
P_PID=$!
|
||||||
|
i=0
|
||||||
|
P_PORT=""
|
||||||
|
while [ $i -lt 50 ]; do
|
||||||
|
P_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$P_LOG" | tail -1)"
|
||||||
|
[ -n "$P_PORT" ] && break
|
||||||
|
i=$((i + 1)); sleep 0.2
|
||||||
|
done
|
||||||
|
[ -n "$P_PORT" ] || fail "the ask-mode server did not start (see $P_LOG)"
|
||||||
|
P_BASE="http://127.0.0.1:$P_PORT"
|
||||||
|
curl -sN --max-time 60 "$P_BASE/api/events" > "$P_EVENTS" &
|
||||||
|
P_CURL=$!
|
||||||
|
sleep 0.5
|
||||||
|
curl -s -o /dev/null --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data '{"text":"e2e: ask me first"}' "$P_BASE/api/turn"
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 300 ]; do
|
||||||
|
grep -q "event: permission" "$P_EVENTS" && break
|
||||||
|
i=$((i + 1)); sleep 0.2
|
||||||
|
done
|
||||||
|
if [ $i -ge 300 ]; then
|
||||||
|
kill "$P_PID" 2>/dev/null || true
|
||||||
|
kill "$P_CURL" 2>/dev/null || true
|
||||||
|
echo " (no permission question within 60 s - skipped; $(grep -c '^event: ' "$P_EVENTS") events seen)"
|
||||||
|
else
|
||||||
|
PID_REQ="$(sed -n 's/.*"id":\([0-9]*\),"tool".*/\1/p' "$P_EVENTS" | head -1)"
|
||||||
|
[ -n "$PID_REQ" ] || fail "a permission event without an id"
|
||||||
|
# Answer while the server is still waiting for it.
|
||||||
|
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||||
|
--data "{\"id\":$PID_REQ,\"answer\":1}" "$P_BASE/api/permission" > /dev/null ||
|
||||||
|
fail "POST /api/permission was refused"
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 150 ]; do
|
||||||
|
grep -q "event: permission_done" "$P_EVENTS" && break
|
||||||
|
i=$((i + 1)); sleep 0.2
|
||||||
|
done
|
||||||
|
grep -q "event: permission_done" "$P_EVENTS" || fail "the answer was not acknowledged"
|
||||||
|
stop_server "$P_PID" "the ask-mode server"
|
||||||
|
kill "$P_CURL" 2>/dev/null || true
|
||||||
|
echo " answered request $PID_REQ with 'allow once'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. token auth, --web-dev, and a clean shutdown
|
||||||
|
echo "== token auth and --web-dev"
|
||||||
|
T_LOG="$WORK/token-server.log"
|
||||||
|
CST_REPLAY_SSE="$REPLAY" \
|
||||||
|
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
|
||||||
|
--protocol "$PROTO" --tools default --yolo --web-dev "$ROOT/web" \
|
||||||
|
--web-token tok-e2e > "$T_LOG" 2>&1 &
|
||||||
|
T_PID=$!
|
||||||
|
i=0
|
||||||
|
T_PORT=""
|
||||||
|
while [ $i -lt 50 ]; do
|
||||||
|
T_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$T_LOG" | tail -1)"
|
||||||
|
[ -n "$T_PORT" ] && break
|
||||||
|
i=$((i + 1)); sleep 0.2
|
||||||
|
done
|
||||||
|
[ -n "$T_PORT" ] || fail "the token server did not start (see $T_LOG)"
|
||||||
|
T_BASE="http://127.0.0.1:$T_PORT"
|
||||||
|
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$T_BASE/api/state")"
|
||||||
|
[ "$code" = "401" ] || fail "a token-protected server answered $code without one"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
-H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state")"
|
||||||
|
[ "$code" = "200" ] || fail "the Bearer token was rejected ($code)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
"$T_BASE/api/state?token=tok-e2e")"
|
||||||
|
[ "$code" = "200" ] || fail "the query token was rejected ($code)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
-H 'Cookie: a=b; cst_token=tok-e2e; z=y' "$T_BASE/api/state")"
|
||||||
|
[ "$code" = "200" ] || fail "the cookie token was rejected ($code)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||||
|
-H 'Authorization: Bearer tok-e2' "$T_BASE/api/state")"
|
||||||
|
[ "$code" = "401" ] || fail "a wrong token was accepted ($code)"
|
||||||
|
|
||||||
|
# --web-dev serves straight from the directory, so the bytes must match it
|
||||||
|
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/app.js" \
|
||||||
|
> "$WORK/app.js" || fail "GET /app.js from --web-dev"
|
||||||
|
cmp -s "$WORK/app.js" "$ROOT/web/app.js" ||
|
||||||
|
fail "--web-dev did not serve web/app.js from disk"
|
||||||
|
|
||||||
|
# POST /api/quit must return and then take the server down on its own
|
||||||
|
curl -fsS --max-time 5 -X POST -H 'Authorization: Bearer tok-e2e' \
|
||||||
|
"$T_BASE/api/quit" | grep -q '"ok":true' || fail "POST /api/quit"
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 50 ] && kill -0 "$T_PID" 2> /dev/null; do
|
||||||
|
i=$((i + 1)); sleep 0.2
|
||||||
|
done
|
||||||
|
kill -0 "$T_PID" 2> /dev/null && fail "the server ignored /api/quit"
|
||||||
|
wait "$T_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
# 9. ask_user waits for the answer, and the HTTP hardening checks (both
|
||||||
|
# need a scripted model: python3)
|
||||||
|
if command -v python3 > /dev/null; then
|
||||||
|
echo "== ask_user round trip"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ask.py" > "$WORK/ask.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/ask.txt"; fail "the ask_user round trip"; }
|
||||||
|
echo "== HTTP hardening"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/http.txt"; fail "the HTTP hardening checks"; }
|
||||||
|
echo "== settings and streaming"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/settings.txt"; fail "the settings checks"; }
|
||||||
|
echo "== sessions, retry and export"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_sessions.py" > "$WORK/sessions.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/sessions.txt"; fail "the session checks"; }
|
||||||
|
echo "== diff, run settings and permission rules"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_workspace.py" > "$WORK/workspace.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/workspace.txt"; fail "the workspace checks"; }
|
||||||
|
echo "== /init, /skill, extensions and the thinking switch"
|
||||||
|
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ext.py" > "$WORK/ext.txt" 2>&1 ||
|
||||||
|
{ cat "$WORK/ext.txt"; fail "the extension checks"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""WebUI ask_user round trip against a fake OpenAI-compatible API.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_ask.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Checks: the turn really waits for the answer, a page opened while the
|
||||||
|
question is open still receives it, and Last-Event-ID resumes the stream.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
REQS = []
|
||||||
|
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
REQS.append(json.loads(self.rfile.read(n)))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
|
||||||
|
|
||||||
|
def ev(o):
|
||||||
|
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
if len(REQS) == 1:
|
||||||
|
args = json.dumps({"question": "Pick a colour?", "options": "red\nblue"})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
|
||||||
|
{"index": 0, "id": "call_1", "type": "function",
|
||||||
|
"function": {"name": "ask_user", "arguments": args}}]}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
|
||||||
|
else:
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "done"}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
class Sse:
|
||||||
|
def __init__(self, base, last_id=None):
|
||||||
|
self.lines = []
|
||||||
|
req = urllib.request.Request(base + "/api/events")
|
||||||
|
if last_id is not None:
|
||||||
|
req.add_header("Last-Event-ID", str(last_id))
|
||||||
|
self.r = urllib.request.urlopen(req, timeout=60)
|
||||||
|
threading.Thread(target=self.pump, daemon=True).start()
|
||||||
|
|
||||||
|
def pump(self):
|
||||||
|
try:
|
||||||
|
for raw in self.r:
|
||||||
|
self.lines.append(raw.decode())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def events(self, name):
|
||||||
|
out = []
|
||||||
|
for i, l in enumerate(self.lines):
|
||||||
|
if l.strip() == "event: " + name and i + 1 < len(self.lines):
|
||||||
|
out.append(json.loads(self.lines[i + 1][6:]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def ids(self):
|
||||||
|
return [int(l[4:]) for l in self.lines if l.startswith("id: ")]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.r.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wait(fn, t=15):
|
||||||
|
end = time.time() + t
|
||||||
|
while time.time() < end:
|
||||||
|
v = fn()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def post(base, path, obj):
|
||||||
|
req = urllib.request.Request(base + path, data=json.dumps(obj).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as r:
|
||||||
|
return r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code
|
||||||
|
|
||||||
|
|
||||||
|
api = ThreadingHTTPServer(("127.0.0.1", 0), H)
|
||||||
|
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||||
|
ws = tempfile.mkdtemp(prefix="cst-ask-")
|
||||||
|
log = open(ws + "/server.log", "w+")
|
||||||
|
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
|
||||||
|
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto"],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT)
|
||||||
|
try:
|
||||||
|
def port():
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return l.split(":")[3].split("/")[0]
|
||||||
|
pt = wait(port)
|
||||||
|
base = "http://127.0.0.1:%s" % pt
|
||||||
|
a = Sse(base)
|
||||||
|
time.sleep(0.3)
|
||||||
|
check(post(base, "/api/turn", {"text": "ask me"}) == 202, "turn accepted")
|
||||||
|
ask = wait(lambda: a.events("ask_user"))
|
||||||
|
check(bool(ask), "ask_user event raised")
|
||||||
|
time.sleep(1.5)
|
||||||
|
check(len(REQS) == 1, "the turn waits for the answer (requests so far: %d)" % len(REQS))
|
||||||
|
# a page opened now must still see the open question
|
||||||
|
b = Sse(base)
|
||||||
|
check(bool(wait(lambda: b.events("ask_user"), 5)), "a late page gets the open question")
|
||||||
|
# reconnect with Last-Event-ID: nothing before that id is resent
|
||||||
|
last = a.ids()[-1]
|
||||||
|
c = Sse(base, last_id=last - 1)
|
||||||
|
got = wait(lambda: c.ids(), 5)
|
||||||
|
check(bool(got) and got[0] == last, "Last-Event-ID resumes after that id (%s, want %d)" % (got[:1] if got else got, last))
|
||||||
|
if ask:
|
||||||
|
check(post(base, "/api/ask_user", {"id": ask[0]["id"], "text": "blue"}) == 200, "answer accepted")
|
||||||
|
end = wait(lambda: a.events("turn_end"), 20)
|
||||||
|
check(bool(end) and end[0].get("rc") == 0, "turn ended rc=0")
|
||||||
|
tool_msgs = [m for m in (REQS[1]["messages"] if len(REQS) > 1 else []) if m.get("role") == "tool"]
|
||||||
|
check(bool(tool_msgs) and "blue" in json.dumps(tool_msgs), "the answer reached the model: %s" % json.dumps(tool_msgs)[:200])
|
||||||
|
for s in (a, b, c):
|
||||||
|
s.close()
|
||||||
|
finally:
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
check(False, "server hung on SIGTERM")
|
||||||
|
check(p.returncode is not None and p.returncode < 128 and p.returncode >= 0, "clean exit (%s)" % p.returncode)
|
||||||
|
log.close()
|
||||||
|
shutil.rmtree(ws, ignore_errors=True)
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""WebUI /init, /skill, extensions and the thinking switch against a fake API.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_ext.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Checks: show_thinking is validated, lands in the state and in settings.json;
|
||||||
|
/api/skill inlines the skill and 404s an unknown one; /api/init sends the
|
||||||
|
AGENTS.md prompt; /api/extensions lists skills and the built-in roles;
|
||||||
|
trust and reload work and write settings.json; the changing calls are a 409
|
||||||
|
while a turn runs, the listing is not. HOME points at a temp dir.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
SEEN = [] # the full last user message of every request
|
||||||
|
|
||||||
|
|
||||||
|
class Api(BaseHTTPRequestHandler):
|
||||||
|
"""Answers "好的"; a prompt with "slow" takes 2 s."""
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||||
|
users = [m.get("content") for m in req.get("messages", []) if m.get("role") == "user"]
|
||||||
|
last = users[-1] if users and isinstance(users[-1], str) else ""
|
||||||
|
SEEN.append(last)
|
||||||
|
if "slow" in last:
|
||||||
|
time.sleep(2)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
for o in ({"delta": {"role": "assistant", "content": "好的"}},
|
||||||
|
{"delta": {}, "finish_reason": "stop"}):
|
||||||
|
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
|
||||||
|
"choices": [dict(index=0, **o)]}
|
||||||
|
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def wait(fn, t=15):
|
||||||
|
end = time.time() + t
|
||||||
|
while time.time() < end:
|
||||||
|
v = fn()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(base, path, obj=None, method=None):
|
||||||
|
data = None if obj is None else json.dumps(obj).encode()
|
||||||
|
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, json.loads(r.read() or b"{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read() or b"{}")
|
||||||
|
except ValueError:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def skill(ws, name, desc, body):
|
||||||
|
d = os.path.join(ws, ".agent", "skills", name)
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("---\nname: %s\ndescription: %s\n---\n\n%s\n" % (name, desc, body))
|
||||||
|
|
||||||
|
|
||||||
|
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||||
|
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||||
|
ws = tempfile.mkdtemp(prefix="cst-ext-")
|
||||||
|
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||||
|
skill(ws, "demo", "演示用的 skill", "DEMO-INSTRUCTIONS: 先说你好。")
|
||||||
|
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||||
|
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
|
||||||
|
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||||
|
env.pop(k, None)
|
||||||
|
log = open(ws + "/server.log", "w+")
|
||||||
|
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||||
|
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto"],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def port():
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return l.split(":")[3].split("/")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def settings():
|
||||||
|
try:
|
||||||
|
with open(home + "/.config/cst-agent/settings.json", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def busy(base):
|
||||||
|
return call(base, "/api/state")[1].get("status_busy")
|
||||||
|
|
||||||
|
|
||||||
|
def turn(base, path, body):
|
||||||
|
code, d = call(base, path, body)
|
||||||
|
if code == 202:
|
||||||
|
time.sleep(0.3)
|
||||||
|
wait(lambda: not busy(base), 20)
|
||||||
|
return code, d
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
base = "http://127.0.0.1:%s" % wait(port)
|
||||||
|
|
||||||
|
# 1. the thinking switch
|
||||||
|
st = call(base, "/api/state")[1]
|
||||||
|
check(st.get("show_thinking") is False, "show_thinking starts off")
|
||||||
|
check(call(base, "/api/config", {"show_thinking": "yes"})[0] == 400, "show_thinking must be a bool")
|
||||||
|
code, _ = call(base, "/api/config", {"show_thinking": True})
|
||||||
|
check(code == 200 and call(base, "/api/state")[1].get("show_thinking") is True, "show_thinking on")
|
||||||
|
check(settings().get("show_thinking") is True, "show_thinking is saved in settings.json")
|
||||||
|
call(base, "/api/config", {"show_thinking": False})
|
||||||
|
check(settings() and not settings().get("show_thinking"), "and cleared again (%s)" % settings())
|
||||||
|
|
||||||
|
# 2. the extensions listing
|
||||||
|
code, ex = call(base, "/api/extensions")
|
||||||
|
check(code == 200 and ex.get("enabled") is True and ex.get("trusted") is False,
|
||||||
|
"extensions: enabled, not trusted (%s)" % code)
|
||||||
|
names = [s["name"] for s in ex.get("skills", [])]
|
||||||
|
check(names == ["demo"] and ex["skills"][0].get("desc") == "演示用的 skill"
|
||||||
|
and ex["skills"][0].get("source") == "workspace", "the workspace skill is listed (%s)" % ex.get("skills"))
|
||||||
|
roles = [r["name"] for r in ex.get("roles", [])]
|
||||||
|
check("explore" in roles and all(r.get("source") for r in ex["roles"]), "built-in roles are listed (%s)" % roles)
|
||||||
|
check(ex.get("mcp") == [] and ex.get("plugins") == [] and ex.get("runs") == [], "no MCP, plugins or runs")
|
||||||
|
check(ex.get("task_tool") is True and "1 skill" in ex.get("report", ""), "task tool on, report %r" % ex.get("report"))
|
||||||
|
|
||||||
|
# 3. /skill
|
||||||
|
code, d = turn(base, "/api/skill", {"name": "demo", "task": "做点事"})
|
||||||
|
check(code == 202 and "DEMO-INSTRUCTIONS" in d.get("text", "") and d["text"].endswith("做点事"),
|
||||||
|
"skill turn accepted (%s)" % code)
|
||||||
|
check(SEEN and "DEMO-INSTRUCTIONS" in SEEN[-1] and "做点事" in SEEN[-1], "the model saw the skill and the task")
|
||||||
|
code, d = turn(base, "/api/skill", {"name": "demo"})
|
||||||
|
check(code == 202 and SEEN[-1].endswith("Apply this skill now."), "a skill without a task")
|
||||||
|
check(call(base, "/api/skill", {"name": "nope"})[0] == 404, "unknown skill: 404")
|
||||||
|
check(call(base, "/api/skill", {"name": "../demo"})[0] == 404, "skill ../demo: 404")
|
||||||
|
check(call(base, "/api/skill", {})[0] == 400, "skill without a name: 400")
|
||||||
|
check(call(base, "/api/skill", {"name": "x" * 100})[0] == 400, "a long skill name: 400")
|
||||||
|
check(call(base, "/api/skill", {"name": "demo", "task": 5})[0] == 400, "a non-string task: 400")
|
||||||
|
|
||||||
|
# 4. /init
|
||||||
|
code, d = turn(base, "/api/init", {})
|
||||||
|
check(code == 202 and "AGENTS.md" in d.get("text", ""), "init accepted (%s)" % code)
|
||||||
|
check("AGENTS.md" in SEEN[-1], "the model saw the AGENTS.md prompt")
|
||||||
|
|
||||||
|
# 5. trust and reload
|
||||||
|
code, d = call(base, "/api/extensions/trust", {"on": True})
|
||||||
|
check(code == 200 and d.get("trusted") is True, "trust the workspace (%s %s)" % (code, d))
|
||||||
|
tw = settings().get("trusted_workspaces", [])
|
||||||
|
check(any(os.path.realpath(ws) == os.path.realpath(p) for p in tw), "settings.json lists it (%s)" % tw)
|
||||||
|
check(call(base, "/api/extensions")[1].get("trusted") is True, "the listing says trusted")
|
||||||
|
code, d = call(base, "/api/extensions/trust", {"on": False})
|
||||||
|
check(code == 200 and d.get("trusted") is False and not settings().get("trusted_workspaces"),
|
||||||
|
"untrust (%s)" % settings().get("trusted_workspaces"))
|
||||||
|
check(call(base, "/api/extensions/trust", {"on": "yes"})[0] == 400, "trust needs a bool")
|
||||||
|
skill(ws, "zeta", "第二个", "ZETA")
|
||||||
|
code, d = call(base, "/api/extensions/reload", {})
|
||||||
|
check(code == 200 and "2 skills" in d.get("report", ""), "reload sees the new skill (%s)" % d)
|
||||||
|
check([s["name"] for s in call(base, "/api/extensions")[1]["skills"]] == ["demo", "zeta"], "both listed")
|
||||||
|
|
||||||
|
# 6. while a turn runs
|
||||||
|
call(base, "/api/turn", {"text": "slow 慢一点"})
|
||||||
|
time.sleep(0.4)
|
||||||
|
check(call(base, "/api/extensions/reload", {})[0] == 409, "reload during a turn: 409")
|
||||||
|
check(call(base, "/api/extensions/trust", {"on": True})[0] == 409, "trust during a turn: 409")
|
||||||
|
check(call(base, "/api/skill", {"name": "demo"})[0] == 409, "skill during a turn: 409")
|
||||||
|
check(call(base, "/api/init", {})[0] == 409, "init during a turn: 409")
|
||||||
|
check(call(base, "/api/extensions")[0] == 200, "the listing works during a turn")
|
||||||
|
check(call(base, "/api/config", {"show_thinking": True})[0] == 200, "the thinking switch works during a turn")
|
||||||
|
wait(lambda: not busy(base), 20)
|
||||||
|
check(not settings().get("trusted_workspaces"), "the refused trust changed nothing")
|
||||||
|
|
||||||
|
# 7. another workspace brings its own skills
|
||||||
|
ws2 = tempfile.mkdtemp(prefix="cst-ext2-")
|
||||||
|
skill(ws2, "other", "另一个工作区的", "OTHER")
|
||||||
|
code, _ = call(base, "/api/workspace", {"path": ws2})
|
||||||
|
ex = call(base, "/api/extensions")[1]
|
||||||
|
check(code == 200 and [s["name"] for s in ex.get("skills", [])] == ["other"]
|
||||||
|
and ex.get("report") == "1 skill", "a workspace switch reloads the extensions (%s)" % ex.get("report"))
|
||||||
|
code, d = turn(base, "/api/skill", {"name": "other"})
|
||||||
|
check(code == 202 and "OTHER" in SEEN[-1], "and its skill runs")
|
||||||
|
shutil.rmtree(ws2, ignore_errors=True)
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
check(False, "server hung on SIGTERM")
|
||||||
|
log.close()
|
||||||
|
shutil.rmtree(ws, ignore_errors=True)
|
||||||
|
shutil.rmtree(home, ignore_errors=True)
|
||||||
|
api.shutdown()
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""WebUI HTTP hardening checks over raw sockets, against a scripted model.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_http.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Cross-site requests, request parsing (Content-Length, NUL, long lines,
|
||||||
|
keep-alive), auth before the body, changes refused while a turn runs,
|
||||||
|
cancel waking a pending question, token mode and shutdown.
|
||||||
|
"""
|
||||||
|
import json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
REQS = []
|
||||||
|
|
||||||
|
|
||||||
|
class Api(BaseHTTPRequestHandler):
|
||||||
|
"""First call: ask_user. Later calls: a plain answer."""
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
REQS.append(json.loads(self.rfile.read(n)))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
|
||||||
|
|
||||||
|
def ev(o):
|
||||||
|
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
if len(REQS) == 1:
|
||||||
|
args = json.dumps({"question": "Pick?", "options": "a\nb"})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
|
||||||
|
{"index": 0, "id": "call_1", "type": "function",
|
||||||
|
"function": {"name": "ask_user", "arguments": args}}]}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
|
||||||
|
else:
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def raw(port, data, timeout=5, read_all=True):
|
||||||
|
"""Send bytes, return everything read until EOF/timeout."""
|
||||||
|
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
||||||
|
s.sendall(data)
|
||||||
|
out = b""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
b = s.recv(65536)
|
||||||
|
if not b:
|
||||||
|
break
|
||||||
|
out += b
|
||||||
|
if not read_all:
|
||||||
|
break
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
s.close()
|
||||||
|
return out.decode("latin1")
|
||||||
|
|
||||||
|
|
||||||
|
def status(resp):
|
||||||
|
# a pipelined response starts right after the previous body, not on a new line
|
||||||
|
return [int(m) for m in re.findall(r"HTTP/1\.1 (\d{3}) ", resp)]
|
||||||
|
|
||||||
|
|
||||||
|
def req(method, path, headers=None, body=b"", host=None):
|
||||||
|
h = {"Host": host or "127.0.0.1:%d" % PORT, "Connection": "close"}
|
||||||
|
h.update(headers or {})
|
||||||
|
if body or method == "POST":
|
||||||
|
h["Content-Length"] = str(len(body))
|
||||||
|
head = "%s %s HTTP/1.1\r\n" % (method, path) + "".join("%s: %s\r\n" % kv for kv in h.items()) + "\r\n"
|
||||||
|
return head.encode() + body
|
||||||
|
|
||||||
|
|
||||||
|
def start(ws, *extra):
|
||||||
|
log = open(os.path.join(ws, "server.log"), "w+")
|
||||||
|
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
|
||||||
|
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto", *extra],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT)
|
||||||
|
end = time.time() + 10
|
||||||
|
while time.time() < end:
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return p, int(l.split(":")[3].split("/")[0]), log
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise SystemExit("server did not start")
|
||||||
|
|
||||||
|
|
||||||
|
def stop(p, name):
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
check(False, name + " hung on SIGTERM")
|
||||||
|
return
|
||||||
|
check(0 <= p.returncode < 128, "%s exits cleanly (%s)" % (name, p.returncode))
|
||||||
|
|
||||||
|
|
||||||
|
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||||
|
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||||
|
root = tempfile.mkdtemp(prefix="cst-http-")
|
||||||
|
try:
|
||||||
|
ws = os.path.join(root, "a")
|
||||||
|
os.makedirs(ws)
|
||||||
|
p, PORT, log = start(ws)
|
||||||
|
me = "http://127.0.0.1:%d" % PORT
|
||||||
|
cfg = b'{"approval":"ask"}'
|
||||||
|
# 1. cross-site requests
|
||||||
|
r = raw(PORT, req("POST", "/api/config", {"Origin": "http://evil.example", "Content-Type": "text/plain"}, cfg))
|
||||||
|
check(status(r)[:1] == [401], "a cross-site POST is refused (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("POST", "/api/config", {"Origin": "null"}, cfg))
|
||||||
|
check(status(r)[:1] == [401], "Origin: null is refused")
|
||||||
|
r = raw(PORT, req("POST", "/api/config", {"Origin": me, "Content-Type": "application/json"}, b'{"approval":"auto"}'))
|
||||||
|
check(status(r)[:1] == [200], "our own page may POST (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("POST", "/api/config", {}, b'{"approval":"auto"}'))
|
||||||
|
check(status(r)[:1] == [200], "a client without Origin (curl) may POST")
|
||||||
|
r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example"))
|
||||||
|
check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("GET", "/app.js"))
|
||||||
|
check(status(r)[:1] == [200], "assets are served")
|
||||||
|
# 2. request parsing
|
||||||
|
r = raw(PORT, req("GET", "/api/state%00x"))
|
||||||
|
check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r))
|
||||||
|
smuggle = b"GET /api/sessions HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
|
||||||
|
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: abc\r\n\r\n" % PORT).encode() + smuggle)
|
||||||
|
check(status(r) == [400], "Content-Length: abc is a 400, nothing smuggled (%s)" % status(r))
|
||||||
|
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 0\r\nContent-Length: %d\r\n\r\n"
|
||||||
|
% (PORT, len(smuggle))).encode() + smuggle)
|
||||||
|
check(status(r) == [400], "two different Content-Lengths are a 400 (%s)" % status(r))
|
||||||
|
r = raw(PORT, ("GET /%s HTTP/1.1\r\nHost: x\r\n\r\n" % ("a" * 9000)).encode())
|
||||||
|
check(status(r)[:1] == [400], "an over-long request line is a 400 (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("GET", "/api/state?x=http://a/b"))
|
||||||
|
check(status(r)[:1] == [200], "'://' in the query leaves the path alone (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("GET", "/api/state", {"Connection": "Close"}), timeout=3)
|
||||||
|
check("Connection: close" in r, "Connection: Close (any case) closes")
|
||||||
|
ka = ("GET /api/state HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode()
|
||||||
|
big = ("POST /api/turn HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 5000000\r\n\r\n" % PORT).encode()
|
||||||
|
r = raw(PORT, ka + big)
|
||||||
|
check(status(r) == [200, 413], "a 413 after a keep-alive request still arrives (%s)" % status(r))
|
||||||
|
# 3. a pending question: changes are refused, approval may change, cancel wakes it
|
||||||
|
events = []
|
||||||
|
|
||||||
|
def sse():
|
||||||
|
s = socket.create_connection(("127.0.0.1", PORT))
|
||||||
|
s.sendall(("GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode())
|
||||||
|
buf = b""
|
||||||
|
s.settimeout(60)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
b = s.recv(65536)
|
||||||
|
if not b:
|
||||||
|
break
|
||||||
|
buf += b
|
||||||
|
events[:] = [l[7:] for l in buf.decode("latin1").split("\n") if l.startswith("event: ")]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=sse, daemon=True).start()
|
||||||
|
time.sleep(0.4)
|
||||||
|
r = raw(PORT, req("POST", "/api/turn", {"Content-Type": "application/json"}, b'{"text":"go"}'))
|
||||||
|
check(status(r)[:1] == [202], "turn accepted")
|
||||||
|
end = time.time() + 15
|
||||||
|
while time.time() < end and "ask_user" not in events:
|
||||||
|
time.sleep(0.1)
|
||||||
|
check("ask_user" in events, "the question is open")
|
||||||
|
for path, body in (("/api/sessions/new", b""), ("/api/workspace", json.dumps({"path": root}).encode()),
|
||||||
|
("/api/compact", b""), ("/api/undo", b""), ("/api/config", b'{"model":"x"}')):
|
||||||
|
r = raw(PORT, req("POST", path, {"Content-Type": "application/json"}, body))
|
||||||
|
check(status(r)[:1] == [409], "%s is refused mid-turn (%s)" % (path, status(r)))
|
||||||
|
r = raw(PORT, req("POST", "/api/config", {"Content-Type": "application/json"}, b'{"approval":"yolo"}'))
|
||||||
|
check(status(r)[:1] == [200], "approval may change mid-turn")
|
||||||
|
r = raw(PORT, req("POST", "/api/cancel", {"Content-Type": "application/json"}, b"{}"))
|
||||||
|
end = time.time() + 10
|
||||||
|
while time.time() < end and "turn_end" not in events:
|
||||||
|
time.sleep(0.1)
|
||||||
|
check("turn_end" in events, "cancel ends a turn parked on a question")
|
||||||
|
r = raw(PORT, req("POST", "/api/sessions/new", {"Content-Type": "application/json"}))
|
||||||
|
check(status(r)[:1] == [200], "after the turn, a new session is fine (%s)" % status(r))
|
||||||
|
stop(p, "the server")
|
||||||
|
|
||||||
|
# 4. token mode: the page loads, the API and images do not without it
|
||||||
|
ws2 = os.path.join(root, "b")
|
||||||
|
os.makedirs(ws2)
|
||||||
|
p, PORT, log = start(ws2, "--web-token", "tok-x")
|
||||||
|
r = raw(PORT, req("GET", "/app.js"))
|
||||||
|
check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r))
|
||||||
|
r = raw(PORT, req("GET", "/api/state"))
|
||||||
|
check(status(r)[:1] == [401], "token mode: the API needs it")
|
||||||
|
r = raw(PORT, req("GET", "/api/attach/img-0000.png"))
|
||||||
|
check(status(r)[:1] == [401], "token mode: images need it")
|
||||||
|
r = raw(PORT, req("GET", "/api/state", {"Cookie": "cst_token=tok-x"}))
|
||||||
|
check(status(r)[:1] == [200], "token mode: the cookie works")
|
||||||
|
t0 = time.time()
|
||||||
|
r = raw(PORT, ("POST /api/attach HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 33554431\r\n"
|
||||||
|
"Expect: 100-continue\r\n\r\n" % PORT).encode(), timeout=5)
|
||||||
|
check(status(r) == [401] and time.time() - t0 < 3,
|
||||||
|
"no token, no body read: 401 at once (%s, %.1fs)" % (status(r), time.time() - t0))
|
||||||
|
stop(p, "the token server")
|
||||||
|
|
||||||
|
# 5. option checks
|
||||||
|
rc = subprocess.run([BIN, "--web", "--web-port", "70000", "--workspace", ws2],
|
||||||
|
capture_output=True, timeout=10).returncode
|
||||||
|
check(rc != 0, "--web-port 70000 is refused")
|
||||||
|
rc = subprocess.run([BIN, "--web", "--web-token", "x" * 80, "--web-port", "0", "--workspace", ws2,
|
||||||
|
"--api-key", "k"], capture_output=True, timeout=10).returncode
|
||||||
|
check(rc != 0, "an over-long --web-token is refused")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""WebUI session management against a fake OpenAI-compatible API.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_sessions.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Checks: retry runs the last prompt again and edit-and-resend replaces it
|
||||||
|
(the model sees the new text, the session keeps one copy); retry is a 409
|
||||||
|
while a turn runs and when there is nothing to retry; export returns the
|
||||||
|
transcript as Markdown; rename, clear and delete work on the store, and
|
||||||
|
deleting the open session moves to a new one; history messages carry the
|
||||||
|
note / tool_result flags. HOME points at a temp dir.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
SEEN = []
|
||||||
|
|
||||||
|
|
||||||
|
def last_prompt(msgs):
|
||||||
|
"""The prompts are one line; a note before one shares its user message."""
|
||||||
|
for m in reversed(msgs):
|
||||||
|
c = m.get("content")
|
||||||
|
if m.get("role") == "user" and isinstance(c, str):
|
||||||
|
return c.rstrip("\n").split("\n")[-1]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class Api(BaseHTTPRequestHandler):
|
||||||
|
"""Answers "回答:<last prompt>"; a prompt with "slow" takes 2 s."""
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||||
|
msgs = req.get("messages", [])
|
||||||
|
p = last_prompt(msgs)
|
||||||
|
tail = msgs[-1] if msgs else {}
|
||||||
|
tc = tail.get("content") if isinstance(tail.get("content"), str) else ""
|
||||||
|
answered = tail.get("role") == "tool" or tc.startswith("result ")
|
||||||
|
if not answered:
|
||||||
|
SEEN.append(p)
|
||||||
|
if "slow" in p:
|
||||||
|
time.sleep(2)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
if answered:
|
||||||
|
chunks = ({"delta": {"role": "assistant", "content": "读完了"}},
|
||||||
|
{"delta": {}, "finish_reason": "stop"})
|
||||||
|
elif p.startswith("native"):
|
||||||
|
args = json.dumps({"path": "hello.txt"})
|
||||||
|
chunks = ({"delta": {"role": "assistant", "tool_calls": [
|
||||||
|
{"index": 0, "id": "c1", "type": "function",
|
||||||
|
"function": {"name": "read_file", "arguments": args}}]}},
|
||||||
|
{"delta": {}, "finish_reason": "tool_calls"})
|
||||||
|
elif p.startswith("ctl"):
|
||||||
|
chunks = ({"delta": {"role": "assistant",
|
||||||
|
"content": "我来读。\ncall t1 read_file\narg path 9\nhello.txtend\n"}},
|
||||||
|
{"delta": {}, "finish_reason": "stop"})
|
||||||
|
else:
|
||||||
|
chunks = ({"delta": {"role": "assistant", "content": "回答:" + p}},
|
||||||
|
{"delta": {}, "finish_reason": "stop"})
|
||||||
|
for o in chunks:
|
||||||
|
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
|
||||||
|
"choices": [dict(index=0, **o)]}
|
||||||
|
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def wait(fn, t=15):
|
||||||
|
end = time.time() + t
|
||||||
|
while time.time() < end:
|
||||||
|
v = fn()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(base, path, obj=None, method=None, raw_reply=False):
|
||||||
|
data = None if obj is None else json.dumps(obj).encode()
|
||||||
|
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
raw = r.read()
|
||||||
|
if raw_reply:
|
||||||
|
return r.status, r.headers.get("Content-Type", ""), raw
|
||||||
|
return r.status, json.loads(raw or b"{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
if raw_reply:
|
||||||
|
return e.code, e.headers.get("Content-Type", ""), raw
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw or b"{}")
|
||||||
|
except ValueError:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
class Sse:
|
||||||
|
def __init__(self, base):
|
||||||
|
self.lines = []
|
||||||
|
self.r = urllib.request.urlopen(base + "/api/events", timeout=60)
|
||||||
|
threading.Thread(target=self.pump, daemon=True).start()
|
||||||
|
|
||||||
|
def pump(self):
|
||||||
|
try:
|
||||||
|
for raw in self.r:
|
||||||
|
self.lines.append(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def count(self, name):
|
||||||
|
return sum(1 for l in self.lines if l.strip() == b"event: " + name.encode())
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.r.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||||
|
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||||
|
ws = tempfile.mkdtemp(prefix="cst-sess-")
|
||||||
|
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||||
|
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||||
|
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
|
||||||
|
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||||
|
env.pop(k, None)
|
||||||
|
log = open(ws + "/server.log", "w+")
|
||||||
|
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||||
|
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto"],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def port():
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return l.split(":")[3].split("/")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def messages(base):
|
||||||
|
return call(base, "/api/session")[1].get("messages", [])
|
||||||
|
|
||||||
|
|
||||||
|
def prompts(base):
|
||||||
|
return [m["content"] for m in messages(base)
|
||||||
|
if m["role"] == "user" and not m.get("note") and not m.get("tool_result")]
|
||||||
|
|
||||||
|
|
||||||
|
def answers(base):
|
||||||
|
return [m["content"] for m in messages(base) if m["role"] == "assistant"]
|
||||||
|
|
||||||
|
|
||||||
|
def turn(base, sse, path, body, want=202):
|
||||||
|
n = sse.count("turn_end")
|
||||||
|
code, d = call(base, path, body)
|
||||||
|
if code == want == 202:
|
||||||
|
wait(lambda: sse.count("turn_end") > n, 20)
|
||||||
|
return code, d
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
base = "http://127.0.0.1:%s" % wait(port)
|
||||||
|
sse = Sse(base)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 1. retry with nothing to retry, then a first turn
|
||||||
|
code, d = call(base, "/api/retry", {})
|
||||||
|
check(code == 409 and "nothing to retry" in d.get("error", ""), "retry on an empty session: 409 (%s)" % code)
|
||||||
|
turn(base, sse, "/api/turn", {"text": "第一句"})
|
||||||
|
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"], "first turn stored")
|
||||||
|
|
||||||
|
# 2. retry: same prompt again, one copy in the session
|
||||||
|
code, d = turn(base, sse, "/api/retry", {})
|
||||||
|
check(code == 202 and d.get("text") == "第一句", "retry accepted (%s %s)" % (code, d))
|
||||||
|
check(SEEN[-1] == "第一句", "the model saw the same prompt again")
|
||||||
|
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"],
|
||||||
|
"after retry: one prompt, one answer (%s)" % prompts(base))
|
||||||
|
|
||||||
|
# 3. edit and resend: the new text replaces the old
|
||||||
|
code, d = turn(base, sse, "/api/retry", {"text": "改过的问题"})
|
||||||
|
check(code == 202 and d.get("text") == "改过的问题", "edit-and-resend accepted")
|
||||||
|
check(SEEN[-1] == "改过的问题", "the model saw the edited prompt")
|
||||||
|
check(prompts(base) == ["改过的问题"] and answers(base) == ["回答:改过的问题"],
|
||||||
|
"after edit: %s / %s" % (prompts(base), answers(base)))
|
||||||
|
check(call(base, "/api/retry", {"text": ""})[0] == 400, "an empty edit is a 400")
|
||||||
|
check(call(base, "/api/retry", {"text": 5})[0] == 400, "a non-string edit is a 400")
|
||||||
|
|
||||||
|
# 4. a second turn, then retry only touches the last one
|
||||||
|
turn(base, sse, "/api/turn", {"text": "第二句"})
|
||||||
|
turn(base, sse, "/api/retry", {})
|
||||||
|
check(prompts(base) == ["改过的问题", "第二句"] and len(answers(base)) == 2,
|
||||||
|
"retry keeps earlier turns: %s" % prompts(base))
|
||||||
|
|
||||||
|
# 5. retry while a turn runs is a 409
|
||||||
|
n = sse.count("turn_end")
|
||||||
|
call(base, "/api/turn", {"text": "slow 请慢一点"})
|
||||||
|
code, d = call(base, "/api/retry", {})
|
||||||
|
check(code == 409, "retry during a turn: 409 (%s)" % code)
|
||||||
|
wait(lambda: sse.count("turn_end") > n, 20)
|
||||||
|
|
||||||
|
# 6. a note (plan mode changed mid-session) is flagged, and retry skips it
|
||||||
|
call(base, "/api/config", {"plan_mode": True})
|
||||||
|
turn(base, sse, "/api/turn", {"text": "计划一下"})
|
||||||
|
ms = messages(base)
|
||||||
|
notes = [m for m in ms if m.get("note")]
|
||||||
|
check(len(notes) >= 1 and all(m["role"] == "user" for m in notes),
|
||||||
|
"a mid-session note is flagged note:true (%d)" % len(notes))
|
||||||
|
turn(base, sse, "/api/retry", {})
|
||||||
|
check(SEEN[-1] == "计划一下" and prompts(base)[-1] == "计划一下", "retry after a note reruns the prompt")
|
||||||
|
call(base, "/api/config", {"plan_mode": False})
|
||||||
|
|
||||||
|
# 7. export
|
||||||
|
code, ctype, raw = call(base, "/api/export", raw_reply=True)
|
||||||
|
md = raw.decode("utf-8", "replace")
|
||||||
|
check(code == 200 and ctype.startswith("text/markdown"), "export: %s %s" % (code, ctype))
|
||||||
|
check(md.startswith("# ") and "## User" in md and "改过的问题" in md and "## Assistant" in md
|
||||||
|
and "回答:第二句" in md and "第一句" not in md, "export has the transcript")
|
||||||
|
|
||||||
|
# 8. rename and clear
|
||||||
|
st = call(base, "/api/state")[1]
|
||||||
|
cur = st.get("session")
|
||||||
|
code, _ = call(base, "/api/sessions/rename", {"title": "我的会话"})
|
||||||
|
check(code == 200, "rename the open session")
|
||||||
|
ss = call(base, "/api/sessions")[1]
|
||||||
|
check(any(s["id"] == cur and s["title"] == "我的会话" for s in ss.get("sessions", [])),
|
||||||
|
"the list shows the new title")
|
||||||
|
code, _, raw = call(base, "/api/export", raw_reply=True)
|
||||||
|
check(raw.decode("utf-8", "replace").startswith("# 我的会话"), "export uses the title")
|
||||||
|
long = "长" * 100
|
||||||
|
call(base, "/api/sessions/rename", {"id": cur, "title": long})
|
||||||
|
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
|
||||||
|
check(0 < len(t.encode()) <= 120 and t == "长" * len(t), "a long title is cut on a character (%d bytes)"
|
||||||
|
% len(t.encode()))
|
||||||
|
call(base, "/api/sessions/rename", {"title": ""})
|
||||||
|
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
|
||||||
|
check(t == "改过的问题", "an empty title falls back to the first prompt (%r)" % t)
|
||||||
|
check(call(base, "/api/sessions/rename", {"id": "nope", "title": "x"})[0] == 404, "rename unknown: 404")
|
||||||
|
check(call(base, "/api/sessions/rename", {"id": "../x", "title": "x"})[0] == 404, "rename ../x: 404")
|
||||||
|
check(call(base, "/api/sessions/rename", {})[0] == 400, "rename without a title: 400")
|
||||||
|
|
||||||
|
# 9. delete another session, then the open one
|
||||||
|
code, d = call(base, "/api/sessions/new", {})
|
||||||
|
second = d.get("id")
|
||||||
|
check(code == 200 and second and second != cur, "a second session")
|
||||||
|
code, d = call(base, "/api/sessions/delete", {"id": cur})
|
||||||
|
check(code == 200 and d.get("current") == second, "delete a closed session (%s %s)" % (code, d))
|
||||||
|
ids = [s["id"] for s in call(base, "/api/sessions")[1]["sessions"]]
|
||||||
|
check(cur not in ids and not os.path.exists(os.path.join(ws, ".agent", "sessions", cur + ".jsonl")),
|
||||||
|
"it is gone from the list and the disk")
|
||||||
|
n = sse.count("session")
|
||||||
|
code, d = call(base, "/api/sessions/delete", {"id": second})
|
||||||
|
check(code == 200 and d.get("current") and d["current"] != second, "delete the open session (%s)" % d)
|
||||||
|
check(call(base, "/api/state")[1].get("session") == d.get("current"), "the state moved to the new one")
|
||||||
|
check(wait(lambda: sse.count("session") > n, 5), "a session event was sent")
|
||||||
|
check(call(base, "/api/sessions/delete", {"id": "nope"})[0] == 404, "delete unknown: 404")
|
||||||
|
check(call(base, "/api/sessions/delete", {})[0] == 400, "delete without an id: 400")
|
||||||
|
|
||||||
|
# 10. history: calls and results come back apart, for native and ctl
|
||||||
|
with open(os.path.join(ws, "hello.txt"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("你好世界\n")
|
||||||
|
turn(base, sse, "/api/turn", {"text": "native 读文件"})
|
||||||
|
ms = messages(base)
|
||||||
|
a = [m for m in ms if m["role"] == "assistant" and m.get("calls")]
|
||||||
|
r = [m for m in ms if m.get("tool_result")]
|
||||||
|
check(len(a) == 1 and a[0]["calls"][0].get("name") == "read_file"
|
||||||
|
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt" and a[0]["calls"][0].get("id") == "c1",
|
||||||
|
"native: the call is listed with its args (%s)" % (a[0].get("calls") if a else ms))
|
||||||
|
check(len(r) == 1 and r[0]["role"] == "tool" and r[0].get("tool_call_id") == "c1"
|
||||||
|
and "你好世界" in r[0].get("out", "") and not r[0]["out"].startswith("[tool ")
|
||||||
|
and r[0].get("exit") == 0 and r[0].get("truncated") is False,
|
||||||
|
"native: the result carries out/exit without the header (%s)" % r)
|
||||||
|
check(answers(base)[-1] == "读完了", "native: the turn finished")
|
||||||
|
|
||||||
|
check(call(base, "/api/settings", {"protocol": "ctl"})[0] == 200, "switch to the ctl protocol")
|
||||||
|
turn(base, sse, "/api/turn", {"text": "ctl 读文件"})
|
||||||
|
ms = messages(base)
|
||||||
|
a = [m for m in ms if m["role"] == "assistant" and m.get("calls") and m["calls"][0].get("id") == "t1"]
|
||||||
|
r = [m for m in ms if m.get("tool_result") and m.get("tool_call_id") == "t1"]
|
||||||
|
check(len(a) == 1 and a[0].get("text", "").strip() == "我来读。" and "call t1" in a[0]["content"]
|
||||||
|
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt"
|
||||||
|
and "call t1" not in a[0].get("html", ""),
|
||||||
|
"ctl: prose and calls are split (%s)" % (a[0] if a else ms[-4:]))
|
||||||
|
check(len(r) == 1 and r[0]["role"] == "user" and "你好世界" in r[0].get("out", "")
|
||||||
|
and not r[0]["out"].startswith("result ") and not r[0]["out"].endswith("end")
|
||||||
|
and r[0].get("exit") == 0,
|
||||||
|
"ctl: the result is flagged with out/exit (%s)" % r)
|
||||||
|
check(r and r[0]["content"] not in prompts(base), "ctl: the result is not a prompt")
|
||||||
|
call(base, "/api/settings", {"protocol": "native-eos"})
|
||||||
|
sse.close()
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
check(False, "server hung on SIGTERM")
|
||||||
|
log.close()
|
||||||
|
shutil.rmtree(ws, ignore_errors=True)
|
||||||
|
shutil.rmtree(home, ignore_errors=True)
|
||||||
|
api.shutdown()
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""WebUI settings and streaming against fake OpenAI-compatible APIs.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_settings.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Checks: Chinese reasoning streams as whole characters in few events; the
|
||||||
|
provider list, the model list with capabilities, config validation and
|
||||||
|
persistence; a wrong key is rejected, a good one switches the provider
|
||||||
|
(requests then go to the new base URL) and is saved; and all of it
|
||||||
|
survives a restart. HOME and XDG_CONFIG_HOME point at a temp dir, so no
|
||||||
|
real credentials or settings are read or written.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, stat, subprocess, sys, tempfile, threading, time, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
THINK = ["好的,我先看看工作区里", "有哪些文件,然后用中文", "回答这个问题。😀 结束"]
|
||||||
|
ANSWER = "这是中文回答。"
|
||||||
|
GOOD_KEYS = {"sk-good", "sk-good2"}
|
||||||
|
B_REQS = []
|
||||||
|
|
||||||
|
|
||||||
|
def sse_reply(h, model, think, answer):
|
||||||
|
h.send_response(200)
|
||||||
|
h.send_header("Content-Type", "text/event-stream")
|
||||||
|
h.end_headers()
|
||||||
|
base = {"id": "x", "object": "chat.completion.chunk", "model": model}
|
||||||
|
|
||||||
|
def ev(o):
|
||||||
|
h.wfile.write(b"data: " + json.dumps({**base, **o}, ensure_ascii=False).encode() + b"\n\n")
|
||||||
|
h.wfile.flush()
|
||||||
|
for part in think:
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "reasoning_content": part}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {"content": answer}}]})
|
||||||
|
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
|
||||||
|
h.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
h.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def send_json(h, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
h.send_response(code)
|
||||||
|
h.send_header("Content-Type", "application/json")
|
||||||
|
h.send_header("Content-Length", str(len(body)))
|
||||||
|
h.end_headers()
|
||||||
|
h.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
class A(BaseHTTPRequestHandler):
|
||||||
|
"""The starting provider: any key, one model, Chinese reasoning."""
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
send_json(self, 200, {"data": [{"id": "deepseek-flash"}]})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||||
|
sse_reply(self, "deepseek-flash", THINK, ANSWER)
|
||||||
|
|
||||||
|
|
||||||
|
class B(BaseHTTPRequestHandler):
|
||||||
|
"""The provider switched to: /models checks the key."""
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def key_ok(self):
|
||||||
|
auth = self.headers.get("Authorization", "")
|
||||||
|
return auth.startswith("Bearer ") and auth[7:] in GOOD_KEYS
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.key_ok():
|
||||||
|
return send_json(self, 401, {"error": {"message": "invalid api key"}})
|
||||||
|
send_json(self, 200, {"data": [{"id": "fake-vis"}, {"id": "fake-text"}]})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||||
|
B_REQS.append({"auth": self.headers.get("Authorization", ""), "model": body.get("model")})
|
||||||
|
if not self.key_ok():
|
||||||
|
return send_json(self, 401, {"error": {"message": "invalid api key"}})
|
||||||
|
sse_reply(self, body.get("model") or "fake-vis", [], "B 的回答")
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
class Sse:
|
||||||
|
def __init__(self, base):
|
||||||
|
self.lines = []
|
||||||
|
self.r = urllib.request.urlopen(base + "/api/events", timeout=60)
|
||||||
|
threading.Thread(target=self.pump, daemon=True).start()
|
||||||
|
|
||||||
|
def pump(self):
|
||||||
|
try:
|
||||||
|
for raw in self.r:
|
||||||
|
self.lines.append(raw)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def events(self, name):
|
||||||
|
out = []
|
||||||
|
for i, l in enumerate(self.lines):
|
||||||
|
if l.strip() == b"event: " + name.encode() and i + 1 < len(self.lines):
|
||||||
|
out.append(self.lines[i + 1][6:])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.r.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wait(fn, t=15):
|
||||||
|
end = time.time() + t
|
||||||
|
while time.time() < end:
|
||||||
|
v = fn()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(base, path, obj=None, method=None):
|
||||||
|
data = None if obj is None else json.dumps(obj).encode()
|
||||||
|
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return r.status, json.loads(raw or b"{}"), raw
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw or b"{}"), raw
|
||||||
|
except ValueError:
|
||||||
|
return e.code, {}, raw
|
||||||
|
|
||||||
|
|
||||||
|
def serve(handler):
|
||||||
|
s = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
api_a, api_b = serve(A), serve(B)
|
||||||
|
ws = tempfile.mkdtemp(prefix="cst-set-")
|
||||||
|
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||||
|
cfgdir = home + "/.config/cst-agent"
|
||||||
|
os.makedirs(cfgdir)
|
||||||
|
with open(cfgdir + "/models.json", "w") as f:
|
||||||
|
json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % api_b.server_address[1],
|
||||||
|
"key_env": "FAKEB_API_KEY", "default_model": ""}},
|
||||||
|
"models": [{"match": "fake-vis", "vision": True, "context": 200000},
|
||||||
|
{"match": "fake-text", "vision": False}]}, f)
|
||||||
|
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||||
|
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
|
||||||
|
for k in ("DEEPSEEK_API_KEY", "FAKEB_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||||
|
env.pop(k, None)
|
||||||
|
procs = []
|
||||||
|
|
||||||
|
|
||||||
|
def start(args, tag):
|
||||||
|
log = open("%s/%s.log" % (ws, tag), "w+")
|
||||||
|
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws] + args,
|
||||||
|
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||||
|
procs.append((p, log))
|
||||||
|
|
||||||
|
def port():
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return l.split(":")[3].split("/")[0]
|
||||||
|
pt = wait(port)
|
||||||
|
return p, "http://127.0.0.1:%s" % pt
|
||||||
|
|
||||||
|
|
||||||
|
def stop(p):
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
check(False, "server hung on SIGTERM")
|
||||||
|
check(p.returncode is not None and 0 <= p.returncode < 128, "clean exit (%s)" % p.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
def leaked(raw):
|
||||||
|
return any(k.encode() in raw for k in GOOD_KEYS | {"sk-bad", "sk-x"})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
p, base = start(["--new", "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api_a.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto"], "first")
|
||||||
|
a = Sse(base)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# 1. Chinese reasoning: whole characters, a handful of events
|
||||||
|
code, _, _ = call(base, "/api/turn", {"text": "用中文想一想"})
|
||||||
|
check(code == 202, "turn accepted")
|
||||||
|
end = wait(lambda: a.events("turn_end"), 20)
|
||||||
|
check(bool(end) and json.loads(end[0]).get("rc") == 0, "turn ended rc=0")
|
||||||
|
raw = a.events("think_delta")
|
||||||
|
ok = True
|
||||||
|
for r in raw:
|
||||||
|
try:
|
||||||
|
r.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
ok = False
|
||||||
|
check(ok, "every think_delta frame is valid UTF-8")
|
||||||
|
text = "".join(json.loads(r.decode("utf-8", "replace"))["text"] for r in raw)
|
||||||
|
want = "".join(THINK)
|
||||||
|
check(text == want and "\ufffd" not in text, "the thinking text round trips: %r" % text[:60])
|
||||||
|
check(0 < len(raw) <= 2 * len(THINK) + 2,
|
||||||
|
"think_delta events: %d for %d bytes" % (len(raw), len(want.encode())))
|
||||||
|
ans = "".join(json.loads(r)["text"] for r in a.events("assistant_delta"))
|
||||||
|
check(ans == ANSWER, "the answer round trips: %r" % ans)
|
||||||
|
|
||||||
|
# 2. providers and models
|
||||||
|
code, d, rawp = call(base, "/api/providers")
|
||||||
|
names = {x["name"]: x for x in d.get("providers", [])}
|
||||||
|
check(code == 200 and "deepseek" in names and "fakeb" in names, "provider list: %s" % sorted(names))
|
||||||
|
check(names.get("deepseek", {}).get("current") and names["deepseek"].get("has_key")
|
||||||
|
and names["deepseek"].get("key_from") == "session", "deepseek: current, key for this run")
|
||||||
|
check(names.get("fakeb", {}).get("has_key") is False and names["fakeb"].get("source") == "user",
|
||||||
|
"fakeb: no key yet, from models.json")
|
||||||
|
check(not leaked(rawp), "no key in /api/providers")
|
||||||
|
code, d, _ = call(base, "/api/models")
|
||||||
|
ids = [m["id"] for m in d.get("models", [])]
|
||||||
|
first = (d.get("models") or [{}])[0]
|
||||||
|
check(code == 200 and "deepseek-flash" in ids and first.get("api") is True
|
||||||
|
and "context" in first and "label" in first, "model list: %s" % ids[:5])
|
||||||
|
|
||||||
|
# 3. config: validation and persistence
|
||||||
|
check(call(base, "/api/config", {"effort": "bogus"})[0] == 400, "a bad effort is a 400")
|
||||||
|
check(call(base, "/api/config", {"provider": "fakeb"})[0] == 400, "provider via /api/config is a 400")
|
||||||
|
check(call(base, "/api/config", {"model": ""})[0] == 400, "an empty model is a 400")
|
||||||
|
code, d, _ = call(base, "/api/config", {"model": "deepseek-v4-pro", "effort": "high"})
|
||||||
|
check(code == 200 and d.get("ok"), "model + effort accepted")
|
||||||
|
code, s, _ = call(base, "/api/state")
|
||||||
|
check(s.get("model") == "deepseek-v4-pro" and s.get("effort") == "high", "the state has them")
|
||||||
|
check(s.get("effort_applies") is True and s.get("key_set") is True and "model_info" in s
|
||||||
|
and s.get("perm_timeout", 0) > 0 and "turn_usage" in s, "the state has the new fields")
|
||||||
|
st = open(cfgdir + "/settings.json").read() if os.path.exists(cfgdir + "/settings.json") else ""
|
||||||
|
check("deepseek-v4-pro" in st and "high" in st, "settings.json remembers model and effort")
|
||||||
|
|
||||||
|
# 4. switching provider: no key, a wrong key, a good key
|
||||||
|
code, d, _ = call(base, "/api/provider", {"name": "fakeb"})
|
||||||
|
check(code == 400 and d.get("need_key"), "fakeb without a key: need_key (%s)" % code)
|
||||||
|
code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-bad"})
|
||||||
|
check(code == 401 and "拒绝" in d.get("error", ""), "a wrong key is rejected (%s %s)" % (code, d.get("error")))
|
||||||
|
check(not leaked(rawp), "no key in the rejection")
|
||||||
|
check(call(base, "/api/state")[1].get("provider") == "deepseek", "still on deepseek")
|
||||||
|
check(call(base, "/api/provider", {"name": "nope"})[0] == 404, "an unknown provider is a 404")
|
||||||
|
code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-good"})
|
||||||
|
check(code == 200 and d.get("provider") == "fakeb" and d.get("model") == "fake-vis",
|
||||||
|
"switched to fakeb, first listed model picked (%s %s)" % (code, d))
|
||||||
|
check(not leaked(rawp), "no key in the switch reply")
|
||||||
|
creds = cfgdir + "/credentials"
|
||||||
|
cr = open(creds).read() if os.path.exists(creds) else ""
|
||||||
|
check("fakeb.api_key=sk-good" in cr, "the key was saved to credentials")
|
||||||
|
if os.name == "posix" and os.path.exists(creds):
|
||||||
|
check(stat.S_IMODE(os.stat(creds).st_mode) == 0o600, "credentials is 0600")
|
||||||
|
code, d, _ = call(base, "/api/models")
|
||||||
|
ms = {m["id"]: m for m in d.get("models", [])}
|
||||||
|
check("fake-vis" in ms and ms["fake-vis"].get("vision") is True and ms["fake-vis"].get("context") == 200000
|
||||||
|
and ms.get("fake-text", {}).get("vision") is False, "fakeb models with capabilities")
|
||||||
|
code, d, _ = call(base, "/api/config", {"effort": "low"})
|
||||||
|
check(code == 200 and d.get("warning"), "effort on a model without it: saved with a warning")
|
||||||
|
|
||||||
|
# 5. a turn now goes to the new base URL with the new key
|
||||||
|
n0 = len(B_REQS)
|
||||||
|
code, _, _ = call(base, "/api/turn", {"text": "hi B"})
|
||||||
|
check(code == 202, "turn on fakeb accepted")
|
||||||
|
wait(lambda: len(a.events("turn_end")) >= 2, 20)
|
||||||
|
check(len(B_REQS) > n0 and B_REQS[-1]["auth"] == "Bearer sk-good" and B_REQS[-1]["model"] == "fake-vis",
|
||||||
|
"the request reached fakeb: %s" % (B_REQS[-1:] or "none"))
|
||||||
|
|
||||||
|
# 6. replacing the key
|
||||||
|
code, d, _ = call(base, "/api/key", {"key": "sk-bad"})
|
||||||
|
check(code == 401, "a wrong replacement key is rejected (%s)" % code)
|
||||||
|
code, d, _ = call(base, "/api/key", {"key": "sk-good2"})
|
||||||
|
check(code == 200 and d.get("ok"), "the replacement key is accepted")
|
||||||
|
check("fakeb.api_key=sk-good2" in open(creds).read(), "the replacement key was saved")
|
||||||
|
check(call(base, "/api/key", {})[0] == 400, "an empty key body is a 400")
|
||||||
|
a.close()
|
||||||
|
stop(p)
|
||||||
|
|
||||||
|
# 7. the next start remembers provider, model and key
|
||||||
|
p, base = start(["--new"], "second")
|
||||||
|
code, s, raws = call(base, "/api/state")
|
||||||
|
check(s.get("provider") == "fakeb" and s.get("model") == "fake-vis" and s.get("key_set") is True
|
||||||
|
and s.get("key_from") == "saved", "restart: %s/%s key %s" % (s.get("provider"), s.get("model"), s.get("key_from")))
|
||||||
|
check(not leaked(raws), "no key in /api/state")
|
||||||
|
stop(p)
|
||||||
|
|
||||||
|
logs = b"".join(open(lg.name, "rb").read() for _, lg in procs)
|
||||||
|
check(not leaked(logs.replace(b"sk-x", b"")) , "no key in the server logs")
|
||||||
|
finally:
|
||||||
|
for p, log in procs:
|
||||||
|
if p.poll() is None:
|
||||||
|
p.kill()
|
||||||
|
log.close()
|
||||||
|
shutil.rmtree(ws, ignore_errors=True)
|
||||||
|
shutil.rmtree(home, ignore_errors=True)
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""WebUI workspace views: git diff, run settings, saved permission rules.
|
||||||
|
|
||||||
|
python3 scripts/e2e_webui_workspace.py (CST_AGENT= picks the binary)
|
||||||
|
|
||||||
|
Checks: /api/diff lists git status and the working-tree diff (per file too),
|
||||||
|
an option-looking path is only ever a path, and a directory without git
|
||||||
|
says so; /api/settings reports protocol, tools, confirmations and the
|
||||||
|
sandbox, rejects a bad body without applying any of it, applies a good one,
|
||||||
|
and is a 409 while a turn runs; /api/permissions shows the saved rules and
|
||||||
|
DELETE clears them. HOME points at a temp dir.
|
||||||
|
"""
|
||||||
|
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
|
||||||
|
|
||||||
|
|
||||||
|
class Api(BaseHTTPRequestHandler):
|
||||||
|
"""A slow plain answer, so a turn is running for a while."""
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||||
|
time.sleep(2)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
|
self.end_headers()
|
||||||
|
for o in ({"delta": {"role": "assistant", "content": "好"}}, {"delta": {}, "finish_reason": "stop"}):
|
||||||
|
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "m", "choices": [dict(index=0, **o)]}
|
||||||
|
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
|
||||||
|
self.wfile.write(b"data: [DONE]\n\n")
|
||||||
|
|
||||||
|
|
||||||
|
fails = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(ok, msg):
|
||||||
|
global fails
|
||||||
|
print(("PASS " if ok else "FAIL ") + msg)
|
||||||
|
fails += 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
def wait(fn, t=15):
|
||||||
|
end = time.time() + t
|
||||||
|
while time.time() < end:
|
||||||
|
v = fn()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
time.sleep(0.1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def call(base, path, obj=None, method=None):
|
||||||
|
data = None if obj is None else json.dumps(obj).encode()
|
||||||
|
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, json.loads(r.read() or b"{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw or b"{}")
|
||||||
|
except ValueError:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(["git", "-C", ws] + list(args), capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
|
||||||
|
threading.Thread(target=api.serve_forever, daemon=True).start()
|
||||||
|
ws = tempfile.mkdtemp(prefix="cst-ws-")
|
||||||
|
plain = tempfile.mkdtemp(prefix="cst-nogit-")
|
||||||
|
home = tempfile.mkdtemp(prefix="cst-home-")
|
||||||
|
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
|
||||||
|
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache",
|
||||||
|
GIT_CONFIG_NOSYSTEM="1")
|
||||||
|
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
|
||||||
|
env.pop(k, None)
|
||||||
|
have_git = shutil.which("git") is not None
|
||||||
|
if have_git:
|
||||||
|
git("init", "-q")
|
||||||
|
git("config", "user.email", "e2e@example.com")
|
||||||
|
git("config", "user.name", "e2e")
|
||||||
|
open(ws + "/a.txt", "w").write("one\n")
|
||||||
|
git("add", "a.txt")
|
||||||
|
git("commit", "-q", "-m", "first")
|
||||||
|
open(ws + "/a.txt", "w").write("one\n第二行\n")
|
||||||
|
open(ws + "/new.txt", "w").write("new\n")
|
||||||
|
os.makedirs(ws + "/.agent", exist_ok=True)
|
||||||
|
json.dump({"tools": ["write_file"], "shell_prefixes": ["make"]}, open(ws + "/.agent/permissions.json", "w"))
|
||||||
|
|
||||||
|
log = open(home + "/server.log", "w+")
|
||||||
|
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
|
||||||
|
"--provider", "deepseek", "--model", "deepseek-flash",
|
||||||
|
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
|
||||||
|
"--protocol", "native-eos", "--approval", "auto"],
|
||||||
|
stdout=log, stderr=subprocess.STDOUT, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def port():
|
||||||
|
log.seek(0)
|
||||||
|
for l in log.read().splitlines():
|
||||||
|
if l.startswith("webui: http://127.0.0.1:"):
|
||||||
|
return l.split(":")[3].split("/")[0]
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
base = "http://127.0.0.1:%s" % wait(port)
|
||||||
|
|
||||||
|
# 1. diff
|
||||||
|
if have_git:
|
||||||
|
code, d = call(base, "/api/diff")
|
||||||
|
files = {f["path"]: f["status"] for f in d.get("files", [])}
|
||||||
|
check(code == 200 and d.get("git") is True, "diff: git repo seen")
|
||||||
|
check(files.get("a.txt") == " M" and files.get("new.txt") == "??", "status lists both files: %s" % files)
|
||||||
|
check("+第二行" in d.get("diff", "") and "a.txt" in d.get("diff", ""), "the diff has the change")
|
||||||
|
code, d = call(base, "/api/diff?path=a.txt")
|
||||||
|
check(code == 200 and "+第二行" in d.get("diff", "") and d.get("path") == "a.txt", "diff of one file")
|
||||||
|
code, d = call(base, "/api/diff?path=new.txt")
|
||||||
|
check(code == 200 and d.get("diff") == "", "an untracked file has no diff")
|
||||||
|
evil = ws + "/pwned"
|
||||||
|
code, d = call(base, "/api/diff?path=" + urllib.request.quote("--output=" + evil))
|
||||||
|
check(code == 200 and not os.path.exists(evil), "an option-looking path stays a path")
|
||||||
|
else:
|
||||||
|
print("SKIP diff checks: no git")
|
||||||
|
|
||||||
|
# 2. settings: what is there
|
||||||
|
code, s = call(base, "/api/settings")
|
||||||
|
names = [t["name"] for t in s.get("tools", [])]
|
||||||
|
check(code == 200 and s.get("protocol") == "native-eos" and len(s.get("protocols", [])) == 5,
|
||||||
|
"settings: protocol %s of %s" % (s.get("protocol"), s.get("protocols")))
|
||||||
|
check("read_file" in names and all("perm" in t and "on" in t for t in s["tools"]), "tool list: %d tools" % len(names))
|
||||||
|
check(set(s.get("confirm", {})) == {"shell", "write", "net"}, "confirm flags present")
|
||||||
|
check(isinstance(s.get("sandbox", {}).get("kind"), str), "sandbox kind: %s" % s.get("sandbox"))
|
||||||
|
victim = next((t["name"] for t in s["tools"] if t["perm"] == "net"), names[-1])
|
||||||
|
|
||||||
|
# 3. a bad body changes nothing
|
||||||
|
for body, why in (({"protocol": "nope"}, "unknown protocol"), ({"max_steps": 0}, "max_steps 0"),
|
||||||
|
({"max_steps": "5"}, "max_steps as text"), ({"tool_profile": "huge"}, "unknown profile"),
|
||||||
|
({"tools": {"nope": False}}, "unknown tool"), ({"tools": {victim: "off"}}, "tool value not bool"),
|
||||||
|
({"confirm": {"disk": True}}, "unknown confirm key"),
|
||||||
|
({"max_steps": 9, "protocol": "nope"}, "mixed good and bad")):
|
||||||
|
check(call(base, "/api/settings", body)[0] == 400, "400 for %s" % why)
|
||||||
|
check(call(base, "/api/settings")[1].get("max_steps") == s.get("max_steps"), "nothing applied from a bad body")
|
||||||
|
|
||||||
|
# 4. a good body applies
|
||||||
|
code, d = call(base, "/api/settings", {"protocol": "ctl", "max_steps": 7, "tool_profile": "minimal",
|
||||||
|
"tools": {victim: False}, "confirm": {"shell": True, "net": False}})
|
||||||
|
check(code == 200 and d.get("ok"), "settings accepted (%s %s)" % (code, d))
|
||||||
|
code, s2 = call(base, "/api/settings")
|
||||||
|
t2 = {t["name"]: t for t in s2.get("tools", [])}
|
||||||
|
check(s2.get("protocol") == "ctl" and s2.get("max_steps") == 7 and s2.get("tool_profile") == "minimal",
|
||||||
|
"protocol/steps/profile applied")
|
||||||
|
check(t2.get(victim, {}).get("off") is True and t2[victim]["on"] is False, "%s switched off" % victim)
|
||||||
|
check(s2["confirm"]["shell"] is True and s2["confirm"]["net"] is False, "confirm flags applied")
|
||||||
|
st = call(base, "/api/state")[1]
|
||||||
|
check(st.get("protocol_tools") == "ctl" and st.get("max_steps") == 7 and st.get("tool_profile") == "minimal",
|
||||||
|
"the state shows them")
|
||||||
|
call(base, "/api/settings", {"protocol": "native-eos", "tool_profile": "default", "tools": {victim: True}})
|
||||||
|
t3 = {t["name"]: t for t in call(base, "/api/settings")[1]["tools"]}
|
||||||
|
check(t3[victim]["off"] is False and t3[victim]["on"] is True, "%s back on" % victim)
|
||||||
|
|
||||||
|
# 5. permissions
|
||||||
|
code, p = call(base, "/api/permissions")
|
||||||
|
check(code == 200 and p.get("tools") == ["write_file"] and p.get("shell_prefixes") == ["make"]
|
||||||
|
and "write_file" in p.get("rules", ""), "saved rules: %s %s" % (p.get("tools"), p.get("shell_prefixes")))
|
||||||
|
code, _ = call(base, "/api/permissions", method="DELETE")
|
||||||
|
check(code == 200, "DELETE /api/permissions")
|
||||||
|
p = call(base, "/api/permissions")[1]
|
||||||
|
check(p.get("tools") == [] and p.get("shell_prefixes") == [], "the rules are gone")
|
||||||
|
saved = json.load(open(ws + "/.agent/permissions.json"))
|
||||||
|
check(saved.get("tools") == [] and saved.get("shell_prefixes") == [], "and gone from the file")
|
||||||
|
|
||||||
|
# 6. while a turn runs: settings and the rule reset are 409, reads still work
|
||||||
|
code, _ = call(base, "/api/turn", {"text": "慢慢来"})
|
||||||
|
check(code == 202, "a slow turn started")
|
||||||
|
check(call(base, "/api/settings", {"max_steps": 3})[0] == 409, "settings during a turn: 409")
|
||||||
|
check(call(base, "/api/permissions", method="DELETE")[0] == 409, "rule reset during a turn: 409")
|
||||||
|
check(call(base, "/api/settings")[0] == 200 and call(base, "/api/permissions")[0] == 200,
|
||||||
|
"reads during a turn: 200")
|
||||||
|
if have_git:
|
||||||
|
check(call(base, "/api/diff")[0] == 200, "diff during a turn: 200")
|
||||||
|
wait(lambda: not call(base, "/api/state")[1].get("status_busy"), 20)
|
||||||
|
|
||||||
|
# 7. a directory without git
|
||||||
|
if have_git:
|
||||||
|
code, _ = call(base, "/api/workspace", {"path": plain})
|
||||||
|
code, d = call(base, "/api/diff")
|
||||||
|
check(code == 200 and d.get("git") is False and "diff" not in d, "no git repo: git=false")
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
check(False, "server hung on SIGTERM")
|
||||||
|
log.close()
|
||||||
|
for d in (ws, plain, home):
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
api.shutdown()
|
||||||
|
print("FAILED:", fails)
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Embed web/ into .gen/web_assets.inc as C byte arrays (the same trick the
|
||||||
|
# Makefile uses for src/models_builtin.json). POSIX sh on purpose: the musl
|
||||||
|
# and busybox build environments have no bash. Usage:
|
||||||
|
# sh scripts/embed_web.sh [out.inc] [web-dir]
|
||||||
|
set -eu
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
OUT="${1:-$ROOT/.gen/web_assets.inc}"
|
||||||
|
DIR="${2:-$ROOT/web}"
|
||||||
|
|
||||||
|
FILES="index.html app.js style.css favicon.svg"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
{
|
||||||
|
echo "/* Generated by scripts/embed_web.sh from web/ - do not edit. */"
|
||||||
|
for f in $FILES; do
|
||||||
|
[ -f "$DIR/$f" ] || continue
|
||||||
|
var="wui_asset_$(echo "$f" | tr -c 'a-zA-Z0-9' '_')"
|
||||||
|
echo "static const unsigned char ${var}[] = {"
|
||||||
|
od -An -v -tx1 "$DIR/$f" | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g'
|
||||||
|
echo "0x00};"
|
||||||
|
done
|
||||||
|
echo "static const wui_asset_t wui_assets[] = {"
|
||||||
|
for f in $FILES; do
|
||||||
|
[ -f "$DIR/$f" ] || continue
|
||||||
|
var="wui_asset_$(echo "$f" | tr -c 'a-zA-Z0-9' '_')"
|
||||||
|
case "$f" in
|
||||||
|
*.html) ctype="text/html; charset=utf-8" ;;
|
||||||
|
*.js) ctype="text/javascript; charset=utf-8" ;;
|
||||||
|
*.css) ctype="text/css; charset=utf-8" ;;
|
||||||
|
*.svg) ctype="image/svg+xml" ;;
|
||||||
|
*) ctype="application/octet-stream" ;;
|
||||||
|
esac
|
||||||
|
echo " {\"$f\", \"$ctype\", ${var}, sizeof(${var}) - 1},"
|
||||||
|
done
|
||||||
|
echo "};"
|
||||||
|
echo "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));"
|
||||||
|
} > "$OUT.tmp"
|
||||||
|
mv "$OUT.tmp" "$OUT"
|
||||||
|
echo "embedded $(wc -c < "$OUT") bytes of C into $OUT"
|
||||||