Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
580d533656 | ||
|
|
08008a5a4c | ||
|
|
7299f39245 | ||
|
|
d4f65b17cf | ||
|
|
60cbce3c5c | ||
|
|
84f9585588 | ||
|
|
c2320f946b | ||
|
|
82dee65942 | ||
|
|
3fd039cb51 | ||
|
|
8694afca94 | ||
|
|
cbd71b83c7 | ||
|
|
be4025e276 | ||
|
|
69842edf16 | ||
|
|
f5d340bd2a | ||
|
|
da812cc7e8 | ||
|
|
5ba215b36c | ||
|
|
e0b0eddf3a | ||
|
|
50abcf1a91 |
@@ -11,13 +11,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
cc: [gcc, clang]
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
@@ -34,21 +32,14 @@ jobs:
|
||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||
$SUDO apt-get update
|
||||
$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
|
||||
- name: Build (warnings are errors)
|
||||
run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)"
|
||||
- name: Build TinyCC
|
||||
run: bash scripts/ci_tcc.sh
|
||||
- name: Build
|
||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)"
|
||||
- name: Unit tests
|
||||
run: |
|
||||
# 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
|
||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./cst-agent --help
|
||||
@@ -57,39 +48,22 @@ jobs:
|
||||
- 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
|
||||
timeout-minutes: 30
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Install tools + mirror
|
||||
run: |
|
||||
sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' \
|
||||
/etc/apk/repositories
|
||||
apk add --no-cache curl git bash
|
||||
- name: Checkout
|
||||
env:
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
||||
-o /tmp/ci_checkout.sh
|
||||
bash /tmp/ci_checkout.sh
|
||||
- name: Install 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
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
check: 1
|
||||
- arch: x86
|
||||
check: 1
|
||||
- arch: aarch64
|
||||
check: 0
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
@@ -105,20 +79,29 @@ jobs:
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO bash scripts/ci_pkg_mirror.sh
|
||||
$SUDO apt-get update
|
||||
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl git cmake ninja-build make pkg-config \
|
||||
python3 zip unzip perl xz-utils
|
||||
- name: Cross-build Windows zip
|
||||
run: bash scripts/ci_windows_cross.sh
|
||||
- name: Verify zip
|
||||
build-essential cmake ninja-build pkg-config python3 perl \
|
||||
curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
|
||||
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
||||
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
||||
fi
|
||||
- name: Build (static, -Werror)
|
||||
env:
|
||||
CST_LINUX_CHECK: ${{ matrix.check }}
|
||||
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
||||
- name: Smoke test
|
||||
run: |
|
||||
ls -lah dist/windows-x86_64/
|
||||
ls -lah dist/cst-agent-*-windows-x86_64.zip
|
||||
cat dist/cst-agent-*-windows-x86_64.zip.sha256
|
||||
unzip -l dist/cst-agent-*-windows-x86_64.zip | head -20
|
||||
# Auto-publish to Gitea Releases (v$CST_VERSION from include/cst_version.h).
|
||||
# Prefer repo secret RELEASE_TOKEN (write:repository); fall back to the
|
||||
# job token. Skipped on pull_request and when no token is available.
|
||||
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
|
||||
file "$d/cst-agent"
|
||||
run=""
|
||||
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
||||
$run "$d/cst-agent" --version
|
||||
$run "$d/cst-agent" --help >/dev/null
|
||||
$run "$d/cst-agent" --workspace "$PWD" --list-sessions
|
||||
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256
|
||||
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
|
||||
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
|
||||
- name: Publish Gitea Release
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
@@ -134,4 +117,59 @@ jobs:
|
||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
||||
exit 0
|
||||
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,9 +26,26 @@ third_party/ncurses-*.tar.gz
|
||||
third_party/busybox-w32/
|
||||
third_party/busybox-src/
|
||||
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/src/*.o
|
||||
vendor/sashidown/libsashidown.a
|
||||
vendor/sashidown/libsashidown-*.a
|
||||
vendor/sashidown/libsashidown-mingw.a
|
||||
.gen/
|
||||
.build-tmp/
|
||||
|
||||
# Android APK builds (scripts/build_apk_android.sh) and what gradle leaves
|
||||
build-apk-android/
|
||||
*.apk
|
||||
*.aab
|
||||
app/android/.gradle/
|
||||
app/android/build/
|
||||
app/android/local.properties
|
||||
|
||||
# the engine as a static library (app/ links it; built by `make`)
|
||||
libcstcore.a
|
||||
|
||||
@@ -6,6 +6,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
|
||||
option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON)
|
||||
option(CST_BUILD_APP "Build the Qt front end in app/ (in-process engine, no port)" OFF)
|
||||
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
|
||||
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
|
||||
|
||||
@@ -64,6 +65,29 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMENT "Building libsashidown for MinGW"
|
||||
VERBATIM)
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
|
||||
# The host archive has to stay host-built (the CLI, the tests and the
|
||||
# Makefile link it), so the NDK build keeps its own copy, one per ABI.
|
||||
# CMake's compiler is plain clang (the --target/--sysroot flags live in its
|
||||
# own variables), so the vendor make gets the per-target wrapper instead:
|
||||
# aarch64-linux-android24-clang carries both.
|
||||
get_filename_component(_SD_CC_BIN "${CMAKE_C_COMPILER}" DIRECTORY)
|
||||
string(REPLACE "-none" "" _SD_TRIPLE "${CMAKE_C_COMPILER_TARGET}")
|
||||
set(_SD_CC "${_SD_CC_BIN}/${_SD_TRIPLE}-clang")
|
||||
if(NOT EXISTS "${_SD_CC}")
|
||||
set(_SD_CC "${CMAKE_C_COMPILER}")
|
||||
endif()
|
||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown-${ANDROID_ABI}.a")
|
||||
add_custom_command(
|
||||
OUTPUT "${SASHIDOWN_A}"
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
|
||||
CC=${_SD_CC} AR=${CMAKE_AR} RANLIB=${CMAKE_RANLIB}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMENT "Building libsashidown for Android (${ANDROID_ABI})"
|
||||
VERBATIM)
|
||||
else()
|
||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
|
||||
add_custom_command(
|
||||
@@ -95,6 +119,7 @@ set(CST_CORE_SOURCES
|
||||
src/subagent.c
|
||||
src/attach.c
|
||||
src/clipboard.c
|
||||
src/pdf_text.c
|
||||
src/session.c
|
||||
src/tools/tools.c
|
||||
src/tools/patch.c
|
||||
@@ -102,6 +127,7 @@ set(CST_CORE_SOURCES
|
||||
src/tools/fs_tools.c
|
||||
src/tools/agent_tools.c
|
||||
src/tools/web.c
|
||||
src/tools/session_tools.c
|
||||
src/tools/diff.c
|
||||
src/undo.c
|
||||
src/permissions.c
|
||||
@@ -172,12 +198,42 @@ endif()
|
||||
if(WIN32)
|
||||
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (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/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()
|
||||
|
||||
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
|
||||
# --- the engine as a library ---------------------------------------------
|
||||
# cstcore is the core without main()/TUI: the CLI links it and the Qt front
|
||||
# end in app/ (CST_BUILD_APP) embeds the very same objects in-process, so the
|
||||
# engine is compiled once per build instead of once per front end.
|
||||
add_library(cstcore STATIC ${CST_CORE_SOURCES})
|
||||
add_dependencies(cstcore sashidown)
|
||||
target_include_directories(cstcore PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||
"${SD_DIR}/include"
|
||||
"${CJSON_DIR}"
|
||||
)
|
||||
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
|
||||
if(WIN32)
|
||||
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
|
||||
target_link_libraries(cstcore PUBLIC
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads
|
||||
ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
||||
else()
|
||||
target_compile_definitions(cstcore PRIVATE _XOPEN_SOURCE_EXTENDED)
|
||||
target_link_libraries(cstcore PUBLIC
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads m)
|
||||
endif()
|
||||
|
||||
if(CST_BUILD_APP)
|
||||
add_subdirectory(app)
|
||||
endif()
|
||||
|
||||
set(CST_SOURCES src/main.c)
|
||||
|
||||
if(CST_BUILD_TUI)
|
||||
list(APPEND CST_SOURCES src/textsel.c src/tui.c)
|
||||
@@ -200,14 +256,8 @@ if(NOT WIN32)
|
||||
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
|
||||
endif()
|
||||
|
||||
target_link_libraries(cst-agent PRIVATE
|
||||
"${SASHIDOWN_A}"
|
||||
CURL::libcurl
|
||||
Threads::Threads
|
||||
)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(cst-agent PRIVATE m)
|
||||
endif()
|
||||
# cstcore carries sashidown, curl, threads and libm as usage requirements.
|
||||
target_link_libraries(cst-agent PRIVATE cstcore)
|
||||
|
||||
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
|
||||
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
|
||||
@@ -276,7 +326,7 @@ endif()
|
||||
enable_testing()
|
||||
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_markdown)
|
||||
test_pdf_text test_markdown)
|
||||
foreach(t ${_CST_SMALL_TESTS})
|
||||
if(t STREQUAL test_textsel)
|
||||
add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
|
||||
@@ -284,6 +334,12 @@ foreach(t ${_CST_SMALL_TESTS})
|
||||
if(WIN32)
|
||||
target_link_libraries(${t} PRIVATE advapi32 user32)
|
||||
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)
|
||||
add_executable(${t} tests/${t}.c)
|
||||
target_include_directories(${t} PRIVATE "${SD_DIR}/include")
|
||||
@@ -307,7 +363,7 @@ foreach(t ${_CST_SMALL_TESTS})
|
||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(${t} PRIVATE m)
|
||||
target_link_libraries(${t} PRIVATE m Threads::Threads)
|
||||
endif()
|
||||
cst_win_exe(${t})
|
||||
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
@@ -315,12 +371,14 @@ endforeach()
|
||||
|
||||
# Tests that link the whole core (no TUI).
|
||||
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||
test_apply_patch test_session_roundtrip test_attach
|
||||
test_shell test_diff test_tools_fs test_registry
|
||||
test_apply_patch test_session_roundtrip test_store test_attach
|
||||
test_artifacts
|
||||
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
||||
test_undo test_config test_models test_model_registry test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent test_lowil
|
||||
test_win_utf8 test_os_dir test_tmpdir)
|
||||
test_win_utf8 test_os_dir test_tmpdir
|
||||
test_tool_meta test_embed)
|
||||
if(CST_BUILD_WEBUI)
|
||||
# test_webui drives the real server; the stub has nothing to test.
|
||||
list(APPEND _CST_CORE_TESTS test_webui)
|
||||
|
||||
@@ -7,6 +7,7 @@ PREFIX ?= /usr/local
|
||||
DESTDIR ?=
|
||||
UNAME_S := $(shell uname -s 2>/dev/null)
|
||||
UNAME_O := $(shell uname -o 2>/dev/null)
|
||||
comma := ,
|
||||
|
||||
# Android (Termux): pthreads live in libc.
|
||||
PTHREAD_LIBS := -lpthread
|
||||
@@ -26,6 +27,15 @@ CPPFLAGS += -D_DARWIN_C_SOURCE
|
||||
endif
|
||||
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).
|
||||
CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done)
|
||||
ifneq ($(CURSES_PC),)
|
||||
@@ -50,9 +60,19 @@ CST_WEBUI ?= 1
|
||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||
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)
|
||||
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) \
|
||||
@@ -76,6 +96,7 @@ CORE_SRC = \
|
||||
src/keymap.c \
|
||||
src/skills.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/tools/tools.c \
|
||||
src/tools/patch.c \
|
||||
@@ -83,6 +104,7 @@ CORE_SRC = \
|
||||
src/tools/fs_tools.c \
|
||||
src/tools/agent_tools.c \
|
||||
src/tools/web.c \
|
||||
src/tools/session_tools.c \
|
||||
src/tools/diff.c \
|
||||
src/undo.c \
|
||||
src/permissions.c \
|
||||
@@ -99,6 +121,10 @@ CORE_SRC = \
|
||||
|
||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||
OBJ = $(SRC:.c=.o)
|
||||
# The core as a static library: what the Qt app embeds in-process (app/), so
|
||||
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
|
||||
CORE_OBJ = $(CORE_SRC:.c=.o)
|
||||
CST_CORE_LIB = libcstcore.a
|
||||
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
|
||||
.gen/models_builtin.inc .gen/web_assets.inc
|
||||
|
||||
@@ -114,11 +140,17 @@ TESTS = \
|
||||
tests/test_sse_decode \
|
||||
tests/test_apply_patch \
|
||||
tests/test_session_roundtrip \
|
||||
tests/test_store \
|
||||
tests/test_attach \
|
||||
tests/test_artifacts \
|
||||
tests/test_pdf_text \
|
||||
tests/test_shell \
|
||||
tests/test_diff \
|
||||
tests/test_tools_fs \
|
||||
tests/test_tools_ext \
|
||||
tests/test_registry \
|
||||
tests/test_tool_meta \
|
||||
tests/test_embed \
|
||||
tests/test_undo \
|
||||
tests/test_config \
|
||||
tests/test_models \
|
||||
@@ -141,11 +173,19 @@ ifneq ($(CST_WEBUI),0)
|
||||
TESTS += tests/test_webui
|
||||
endif
|
||||
|
||||
TEST_LIBS = -L$(SD_DIR) -lsashidown -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
|
||||
|
||||
all: sashidown-lib cst-agent
|
||||
all: sashidown-lib cst-agent $(CST_CORE_LIB)
|
||||
|
||||
$(CST_CORE_LIB): $(CORE_OBJ) FORCE
|
||||
rm -f $@
|
||||
$(AR) rcs $@ $(CORE_OBJ)
|
||||
|
||||
cmake-gcc:
|
||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||
@@ -184,26 +224,29 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||
|
||||
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c
|
||||
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 $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
|
||||
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c
|
||||
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 $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
|
||||
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
||||
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 $(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
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
||||
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 $(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
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm
|
||||
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 $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||
|
||||
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c
|
||||
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 $(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
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c
|
||||
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 $(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
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
||||
@@ -228,7 +271,8 @@ demo: cst-agent
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
|
||||
clean:
|
||||
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc
|
||||
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
|
||||
.gen/web_assets.inc
|
||||
|
||||
install: cst-agent
|
||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||
|
||||
@@ -27,6 +27,10 @@ cst-agent --version
|
||||
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,不需要管理员):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
详见 [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`,代码有更新时干净重编,然后重新安装;
|
||||
同时装一个 `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) |
|
||||
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
||||
| 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/` |
|
||||
|
||||
### 迷你验收工程(论文案例)
|
||||
@@ -86,6 +92,7 @@ cd CST-agent
|
||||
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
|
||||
make check # 单元测试 + 截断实验(含真实录制流)
|
||||
make asan # AddressSanitizer + UBSan 构建并跑全部测试
|
||||
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
|
||||
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
|
||||
```
|
||||
|
||||
依赖:`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)已随仓库提供。
|
||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
||||
@@ -120,7 +127,7 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
|
||||
|
||||
| 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 文件系统内) |
|
||||
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
|
||||
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
|
||||
@@ -164,6 +171,18 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
|
||||
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
|
||||
并写回 `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 发行包
|
||||
|
||||
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
|
||||
@@ -172,10 +191,17 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
|
||||
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
||||
```
|
||||
|
||||
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)和
|
||||
`dist/cst-agent-VERSION-windows-x86_64.zip`。CI 在 `main` / `v*` tag 推送且交叉构建成功后会自动上传到
|
||||
Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
|
||||
本地:`GITEA_TOKEN=... scripts/release_windows.sh`。
|
||||
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
|
||||
`dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `makensis` 时的安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`。
|
||||
|
||||
### 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` 等。
|
||||
|
||||
@@ -207,6 +233,7 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
|
||||
|---|---|
|
||||
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
|
||||
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
|
||||
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
|
||||
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
|
||||
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
|
||||
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
|
||||
@@ -219,6 +246,7 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
|
||||
| `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_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 |
|
||||
|
||||
## Run
|
||||
@@ -245,16 +273,18 @@ cst-agent --headless --prompt 'List files'
|
||||
| `-N` / `--new` | 开新会话 |
|
||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
||||
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
|
||||
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
|
||||
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
|
||||
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
||||
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
||||
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
|
||||
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
|
||||
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
|
||||
|
||||
工作目录默认就是当前目录(`-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`,不会被误提交。
|
||||
|
||||
### 内置工具
|
||||
|
||||
@@ -270,10 +300,18 @@ cst-agent --headless --prompt 'List files'
|
||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||
| `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 开关) |
|
||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
|
||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||
|
||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
||||
@@ -281,6 +319,26 @@ cst-agent --headless --prompt 'List files'
|
||||
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
|
||||
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`):
|
||||
@@ -399,6 +457,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
|
||||
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
|
||||
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
|
||||
| `/protocol [name]` | 切换工具调用协议 |
|
||||
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
|
||||
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
|
||||
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
|
||||
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
|
||||
@@ -408,6 +467,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
|
||||
| `/diff [path]` * | `git status` + `git diff` |
|
||||
| `/compact` | 手动压缩上下文 |
|
||||
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
|
||||
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
|
||||
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
|
||||
| `/init` | 让 agent 写 `AGENTS.md` |
|
||||
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
|
||||
|
||||
@@ -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
|
||||
# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
# -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_PROCESSOR x86_64)
|
||||
|
||||
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
|
||||
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
|
||||
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
|
||||
if(DEFINED ENV{MINGW_HOST} AND NOT "$ENV{MINGW_HOST}" STREQUAL "")
|
||||
set(_MINGW_HOST "$ENV{MINGW_HOST}")
|
||||
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)
|
||||
set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
|
||||
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_LIBRARY 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
|
||||
@@ -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)都按固定版本从源码编译、静态链接。
|
||||
|
||||
### 在 Linux 上交叉编译
|
||||
@@ -134,8 +150,16 @@ cd CST-agent
|
||||
scripts/bootstrap_windows.sh
|
||||
```
|
||||
|
||||
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`);否则需要 UCRT 版的
|
||||
`x86_64-w64-mingw32-gcc`(带 winpthreads)、cmake、ninja、git、perl。
|
||||
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`,带 NSIS);否则需要 UCRT 版的
|
||||
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 原生编译
|
||||
|
||||
@@ -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
|
||||
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
|
||||
|
||||
本地:
|
||||
|
||||
```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`;推
|
||||
`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
|
||||
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
||||
|
||||
Verified so far: NixOS x86_64 with gcc and clang (`-Werror`, `make check`,
|
||||
`make asan`, ctest), and every source compiled `-fsyntax-only` with
|
||||
`x86_64-unknown-linux-musl-gcc` without warnings. Full musl, macOS and WSL
|
||||
builds run in CI (`.gitea/workflows/ci.yml`; the macOS job needs a runner and
|
||||
the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
|
||||
Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
|
||||
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`) checks
|
||||
with TinyCC on Debian and builds the static musl releases for x86_64, x86 and
|
||||
aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
|
||||
test on aarch64). macOS and WSL are checked by hand with the list below.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/state` | 上面那份快照 |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致,另加的字段见下) |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与转录 `.agent/sessions/<id>/session.jsonl` 的行一致,另加的字段见下) |
|
||||
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
|
||||
| POST | `/api/sessions/new` | `{}` → `{id}` |
|
||||
| POST | `/api/sessions/switch` | `{"id":"…"}` |
|
||||
|
||||
@@ -47,17 +47,24 @@ int cst_protocol_is_text(cst_protocol_t p);
|
||||
/* Eager protocols execute a call during the stream, at confirmation time. */
|
||||
int cst_protocol_is_eager(cst_protocol_t p);
|
||||
|
||||
/* One image carried by a message (see cst_attach.h). The bytes live beside
|
||||
* the session; this is the metadata plus the name of the stored copy. */
|
||||
/* One file carried by a message (see cst_attach.h): an image, or a document
|
||||
* 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 {
|
||||
char id[32]; /* unique inside the session ("img-1a2b3c4d") */
|
||||
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 */
|
||||
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 pages; /* PDF page count, 0 for anything else */
|
||||
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
|
||||
char origin[32]; /* user | view_image | mcp (for display only) */
|
||||
/* A delivered file (deliver_file): the front end shows it as a card the
|
||||
* person can open or share, and no request ever carries it. The stored copy
|
||||
* is the file as it was, not a text reduction -- see cst_attach.h. */
|
||||
int for_user;
|
||||
} cst_attachment_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -82,9 +89,17 @@ typedef struct {
|
||||
char model[128];
|
||||
char provider[32];
|
||||
char effort[16]; /* low | medium | high | max */
|
||||
char mode[16]; /* coding | general (CST_DEFAULT_MODE) */
|
||||
cst_protocol_t protocol;
|
||||
int max_steps; /* model calls per user turn */
|
||||
size_t tool_out_cap; /* bytes of one tool output sent to the model */
|
||||
/* What the model is asked to do (Chat Completions fields). 0 = the field
|
||||
* is not sent at all and the provider's own default stands; that is what a
|
||||
* config that was not told otherwise has, so a CLI run is unchanged.
|
||||
* temperature and top_p are doubles, max_tokens the completion cap. */
|
||||
double temperature;
|
||||
double top_p;
|
||||
int max_tokens;
|
||||
int headless;
|
||||
int confirm_shell; /* confirm run_shell */
|
||||
int confirm_write; /* confirm file-changing tools */
|
||||
@@ -114,6 +129,14 @@ typedef enum {
|
||||
const char *cst_approval_name(int a);
|
||||
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_agent cst_agent_t;
|
||||
|
||||
@@ -144,7 +167,14 @@ void cst_session_unlock(cst_session_t *s);
|
||||
/* --- workspace .agent/sessions store (OpenCode-like) --- */
|
||||
int cst_agent_store_ensure(const char *workspace);
|
||||
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
|
||||
/* A fresh session id: a uuid v4, so two machines sharing a workspace cannot
|
||||
* hand out the same one (a timestamp can). id_sz >= 37. */
|
||||
int cst_agent_store_new_id(char *id_out, size_t id_sz);
|
||||
/* The transcript of an id. A session owns a directory under the store
|
||||
* (<id>/session.jsonl, with its attachments and sub-agent transcripts inside
|
||||
* it); an id that still has the older flat file (<id>.jsonl) keeps it until
|
||||
* cst_agent_store_migrate() moves it. A new id gets its directory made here,
|
||||
* so the caller can open the file next. */
|
||||
int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
char *out, size_t out_sz);
|
||||
int cst_agent_store_set_current(const char *workspace, const char *id);
|
||||
@@ -154,6 +184,10 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out,
|
||||
size_t path_sz, char *id_out,
|
||||
size_t id_sz);
|
||||
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
|
||||
/* The id a session path belongs to: <dir>/<id>.jsonl (the flat layout) or
|
||||
* <dir>/<id>/session.jsonl (a session's own directory). 0 / -1. */
|
||||
int cst_agent_store_id_of_path(const char *session_path, char *out,
|
||||
size_t out_sz);
|
||||
typedef struct {
|
||||
char id[128];
|
||||
long long mtime;
|
||||
@@ -166,6 +200,13 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
int cst_agent_store_title_set(const char *workspace, const char *id,
|
||||
const char *title);
|
||||
int cst_agent_store_delete(const char *workspace, const char *id);
|
||||
/* Move every flat session of the store into the per-session layout: the
|
||||
* transcript to <id>/session.jsonl, the title and the shared attachment
|
||||
* directory (<id>.title, attachments/<id>/) along with it. Sessions that are
|
||||
* already directories are left alone, so running it twice is harmless.
|
||||
* *moved_out / *failed_out (optional) count what happened; 0 / -1. */
|
||||
int cst_agent_store_migrate(const char *workspace, int *moved_out,
|
||||
int *failed_out, char *err, size_t err_sz);
|
||||
|
||||
/* --- tools --- */
|
||||
typedef struct {
|
||||
@@ -259,6 +300,9 @@ typedef struct {
|
||||
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
|
||||
int images_off; /* the model does not take images: send a note instead */
|
||||
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
|
||||
double temperature; /* 0 = not sent */
|
||||
double top_p; /* 0 = not sent */
|
||||
int max_tokens; /* 0 = not sent */
|
||||
const char *session_path; /* where stored images live; NULL = none */
|
||||
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
|
||||
volatile int *stop_flag; /* non-zero → stop reading, not an error */
|
||||
@@ -278,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);
|
||||
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
|
||||
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 */
|
||||
#define CST_PERM_DENY 0
|
||||
@@ -310,6 +356,12 @@ typedef struct {
|
||||
/* Full tool output, fired right after execution (before it is persisted). */
|
||||
void (*on_tool_result)(const ctl_tool_call_t *call, const char *out,
|
||||
size_t len, int exit_code, void *ud);
|
||||
/* Images a tool produced (view_image, MCP image content), fired right after
|
||||
* the call ran and before the result is persisted. Only a call that made at
|
||||
* least one image is reported, so a front end can show it at once instead of
|
||||
* reading the store. */
|
||||
void (*on_tool_images)(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud);
|
||||
void *userdata;
|
||||
} cst_agent_hooks_t;
|
||||
|
||||
@@ -354,6 +406,12 @@ int cst_agent_pending_count(const cst_agent_t *a);
|
||||
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
|
||||
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
|
||||
size_t out_sz);
|
||||
/* The stored name of pending attachment i ("img-1a2b3c4d.png"), or NULL when
|
||||
* i is out of range. The file is already in the session store, so a front end
|
||||
* can show the image before the message is sent. */
|
||||
const char *cst_agent_pending_rel(const cst_agent_t *a, int i);
|
||||
/* 1 when pending attachment i is an image, 0 otherwise (or out of range). */
|
||||
int cst_agent_pending_is_image(const cst_agent_t *a, int i);
|
||||
void cst_agent_pending_clear(cst_agent_t *a);
|
||||
/* Latest todo_write list of this session, or NULL. */
|
||||
const char *cst_agent_todo(const cst_agent_t *a);
|
||||
@@ -404,13 +462,24 @@ int cst_creds_resolve(const char *provider, char *out, size_t out_sz,
|
||||
/* Hidden console prompt (no echo). */
|
||||
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. */
|
||||
#define CST_SETTINGS_MAX_MODELS 16
|
||||
#define CST_SETTINGS_MAX_TRUSTED 32
|
||||
#define CST_SETTINGS_MAX_PARAMS 16
|
||||
/* Generation parameters remembered per provider ("params" in settings.json):
|
||||
* what the user set for that provider's models. 0 = not set, the field is
|
||||
* not sent. */
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
double temperature;
|
||||
double top_p;
|
||||
int max_tokens;
|
||||
} cst_gen_params_t;
|
||||
typedef struct {
|
||||
char provider[32];
|
||||
char effort[16];
|
||||
char mode[16]; /* coding | general, "" = not chosen yet */
|
||||
char approval[8]; /* "" = not chosen yet */
|
||||
int show_thinking; /* TUI: Thought blocks start expanded */
|
||||
/* workspaces whose .agent/plugins and .agent/mcp.json may run */
|
||||
@@ -421,15 +490,25 @@ typedef struct {
|
||||
char provider[32];
|
||||
char model[128];
|
||||
} models[CST_SETTINGS_MAX_MODELS];
|
||||
int nparams;
|
||||
cst_gen_params_t params[CST_SETTINGS_MAX_PARAMS];
|
||||
} cst_settings_t;
|
||||
int cst_settings_load(cst_settings_t *s);
|
||||
int cst_settings_save(const cst_settings_t *s);
|
||||
const char *cst_settings_model(const cst_settings_t *s, const char *provider);
|
||||
/* 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);
|
||||
/* Record an approval mode the user picked (CST_APPROVAL_*). */
|
||||
int cst_settings_remember_approval(int approval);
|
||||
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). */
|
||||
int cst_settings_trusted(const char *workspace);
|
||||
int cst_settings_trust(const char *workspace, int on);
|
||||
|
||||
@@ -9,16 +9,28 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* --- image attachments ---------------------------------------------------
|
||||
* An attachment is a validated copy of an image file, stored beside the
|
||||
* session so the bytes a model is shown stay the bytes that were picked.
|
||||
* Only the metadata travels in the JSONL; the bytes are encoded into the
|
||||
* request when it is built. */
|
||||
/* --- attachments ---------------------------------------------------------
|
||||
* An attachment is a validated copy of a file, stored beside the session so
|
||||
* what a model is shown stays what was picked. Images are stored as they are
|
||||
* and encoded into the request when it is built; a PDF is reduced to its text
|
||||
* 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). */
|
||||
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */
|
||||
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images), PDF, UTF-8 text. */
|
||||
#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_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
|
||||
* 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,
|
||||
char *out, size_t out_sz);
|
||||
|
||||
/* Read src_path, verify it is a supported image within limits, and copy it
|
||||
* into the session's attachment directory. Returns a malloc'd entry the
|
||||
* caller owns (cst_attach_free), or NULL with err filled. */
|
||||
/* Read src_path and copy it into the session's attachment directory: an image
|
||||
* as it is, a PDF as its extracted text, anything else only when it is text.
|
||||
* 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,
|
||||
const char *src_path, char *err,
|
||||
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,
|
||||
const char *name, const char *data,
|
||||
size_t n, char *err, size_t err_sz);
|
||||
/* Copy src_path into the session's store as a finished file for the user:
|
||||
* any bytes up to CST_ARTIFACT_MAX_BYTES, mime from the name's extension, and
|
||||
* `for_user` set so it stays out of every request. title (optional) is the
|
||||
* display name, the source's file name the default. */
|
||||
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
|
||||
const char *src_path,
|
||||
const char *title, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Validate and read back a stored 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,
|
||||
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);
|
||||
/* "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_free(cst_attachment_t *a);
|
||||
|
||||
@@ -13,10 +13,28 @@ extern "C" {
|
||||
* ("no image on the clipboard", "install wl-clipboard", ...). */
|
||||
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
|
||||
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
|
||||
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
|
||||
|
||||
/* 0 when this host has a clipboard to talk to (a helper on PATH, or the
|
||||
* Win32/AppKit API), -1 with a reason otherwise. Cheap: no forks. */
|
||||
int cst_clipboard_available(char *why, size_t why_sz);
|
||||
|
||||
#define CST_PASTE_MAX_PATHS 8
|
||||
#define CST_PASTE_PATH_MAX 1024
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ const char *cst_provider_source(const cst_provider_t *p);
|
||||
/* The user file's path (it may not exist). */
|
||||
int cst_models_user_path(char *out, size_t out_sz);
|
||||
|
||||
/* Add or replace a provider in the user file (layer 1 above) and reload the
|
||||
* registry: this is how a provider the presets do not know becomes one.
|
||||
* name is 1-31 characters of letters, digits, '.', '_' or '-'; api_base is
|
||||
* required; key_env names the variable its key may come from; default_model
|
||||
* and models[] are what the model picker offers (a fetched /models list still
|
||||
* wins). An entry with a preset's name overrides that preset, everything else
|
||||
* in the file (model rules) is kept. 0, or -1 with err filled. */
|
||||
int cst_models_user_set_provider(const char *name, const char *api_base,
|
||||
const char *key_env,
|
||||
const char *default_model,
|
||||
const char *const *models, int nmodels,
|
||||
char *err, size_t err_sz);
|
||||
/* Drop a provider from the user file (a preset of the same name stays).
|
||||
* 1 = removed, 0 = it was not there, -1 with err. */
|
||||
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz);
|
||||
|
||||
/* Drop everything and read the built-in data, the user file and the caches
|
||||
* again. Provider pointers stay valid but may name another provider. */
|
||||
void cst_models_reload(void);
|
||||
|
||||
@@ -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
|
||||
@@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx,
|
||||
const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud);
|
||||
|
||||
/* Platforms a tool exists on, for def.platforms (NULL-terminated).
|
||||
* CST_PLAT_DESKTOP covers linux / win / macos; an app-provided tool can
|
||||
* declare its own, e.g. a static {"android", NULL} in the Qt frontend. */
|
||||
extern const char *const CST_PLAT_DESKTOP[];
|
||||
/* Host platform: "linux" | "win" | "macos" | "android" | "termux" | "other".
|
||||
* CST_PLATFORM overrides it (tests, cross checks). */
|
||||
const char *cst_platform_name(void);
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *desc;
|
||||
@@ -81,10 +89,34 @@ typedef struct {
|
||||
const char *owner; /* "plugin:<name>", "mcp:<server>", ... */
|
||||
/* Non-READ tools that may still join a parallel batch for this call. */
|
||||
int (*parallel)(const ctl_tool_call_t *call, void *ud);
|
||||
/* --- capability metadata (discovery + availability) ------------------- */
|
||||
/* Fine-grained policy name for permissions.json and consent UIs
|
||||
* ("read","write","delete","shell","net","notify","device","ui","meta").
|
||||
* NULL falls back to the class name of perm. */
|
||||
const char *perm_name;
|
||||
/* Platforms the tool exists on (NULL = everywhere). A tool that is not
|
||||
* for this host never reaches the prompt or tools[]. */
|
||||
const char *const *platforms;
|
||||
/* Probe: 0 when the tool can run here, -1 with a reason in why (NULL =
|
||||
* always). Keep it cheap (no forks); the answer is cached until
|
||||
* cst_tool_meta_reload(). */
|
||||
int (*available)(char *why, size_t why_sz);
|
||||
} cst_tool_def_t;
|
||||
|
||||
/* 0 when t exists on this platform. */
|
||||
int cst_tool_platform_ok(const cst_tool_def_t *t);
|
||||
/* 0 when the tool can run here, -1 with a reason in why. Cached. */
|
||||
int cst_tool_available(const cst_tool_def_t *t, char *why, size_t why_sz);
|
||||
/* Drop cached probes (a platform service appeared or went away). */
|
||||
void cst_tool_meta_reload(void);
|
||||
/* Policy name of t: perm_name, or the class default of perm. */
|
||||
const char *cst_tool_perm_name(const cst_tool_def_t *t);
|
||||
|
||||
/* Add a tool at runtime (strings are copied, ud is not). Safe while turns
|
||||
* run; returns 0, or -1 when the name is taken or the table is full. */
|
||||
* run; returns 0, or -1 when the name is taken or the table is full. An
|
||||
* app-provided tool may take a name whose built-in cannot run on this host
|
||||
* (the Android clipboard replacing the xclip one) and then shadows it for
|
||||
* lookups: one capability, one name. */
|
||||
int cst_tool_register(const cst_tool_def_t *def);
|
||||
/* Drop every dynamic tool of owner (call only while no turn runs). */
|
||||
int cst_tool_unregister_owner(const char *owner);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 8
|
||||
#define CST_VERSION_PATCH 5
|
||||
#define CST_VERSION_MINOR 9
|
||||
#define CST_VERSION_PATCH 6
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
|
||||
@@ -38,6 +38,10 @@ void cst_web_md_free(cst_web_md_t *s);
|
||||
|
||||
/* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */
|
||||
char *cst_web_md_html(const char *md, size_t len);
|
||||
/* One-shot: markdown -> a whole HTML document (doctype, charset, title, a
|
||||
* small stylesheet; no script and no external request), for a file handed to
|
||||
* the user. title is optional; NULL or empty means "Document". */
|
||||
char *cst_web_md_document(const char *md, size_t len, const char *title);
|
||||
/* The sanitizer alone (tests, and any HTML that did not come from here). */
|
||||
char *cst_web_md_sanitize(const char *html, size_t len);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static).
|
||||
# scripts/bootstrap_windows.sh [repo-dir]
|
||||
# With Nix the toolchain comes from shell-win.nix; otherwise x86_64-w64-mingw32-gcc
|
||||
# (UCRT flavour, with winpthreads), cmake, ninja, git and perl must be installed.
|
||||
# With Nix the toolchain comes from shell-win.nix; otherwise $MINGW_HOST-gcc
|
||||
# (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
|
||||
set -euo pipefail
|
||||
|
||||
@@ -21,15 +22,17 @@ if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
|
||||
fi
|
||||
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
|
||||
exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")"
|
||||
fi
|
||||
|
||||
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
|
||||
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then
|
||||
echo "x86_64-w64-mingw32-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2
|
||||
export MINGW_HOST="$H"
|
||||
need git; need cmake; need ninja; need perl; need "$H-gcc"
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -40,7 +43,8 @@ fi
|
||||
echo "==> cmake (Windows, TUI)"
|
||||
cmake -S . -B build-win-tui -G Ninja \
|
||||
-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.
|
||||
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"
|
||||
mkdir -p "$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-progs --without-tests --without-debug --without-shared \
|
||||
--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
|
||||
# CI helper: MinGW-w64 UCRT toolchain via llvm-mingw (no Nix / no /nix).
|
||||
# Downloads a pinned release, puts it on PATH, then runs bootstrap_windows.sh.
|
||||
# CI helper: Windows x86_64 release with Debian's mingw-w64 UCRT gcc.
|
||||
# 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
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VER="${CST_LLVM_MINGW_VER:-20241030}"
|
||||
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 MINGW_HOST="${MINGW_HOST:-x86_64-w64-mingw32ucrt}"
|
||||
export BUILD_CC="${BUILD_CC:-cc}"
|
||||
export CST_REQUIRE_NSIS=1
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; }
|
||||
need x86_64-w64-mingw32-gcc
|
||||
need x86_64-w64-mingw32-windres
|
||||
need cmake; need ninja; need perl; need zip; need git; need make
|
||||
need "$MINGW_HOST-gcc"
|
||||
need "$MINGW_HOST-windres"
|
||||
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
|
||||
echo "toolchain is not UCRT" >&2
|
||||
if ! printf '#include <stdio.h>\n' | "$MINGW_HOST-gcc" -dM -E - | grep -qw _UCRT; then
|
||||
echo "$MINGW_HOST-gcc is not UCRT" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$MINGW_HOST-gcc" --version | head -1
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Gradle init script for the Android APK build, installed as
|
||||
// ~/.gradle/init.d/cst-android.gradle by scripts/build_apk_android.sh.
|
||||
//
|
||||
// Qt's generated gradle project only sets "lintOptions { abortOnError false }",
|
||||
// so the release build still runs lintVitalAnalyzeRelease -- and that task
|
||||
// resolves its own tool jars (manifest-merger, lint-typedef-remover, ...)
|
||||
// straight from dl.google.com, bypassing every repository the project (or an
|
||||
// init script) configures. On a slow or filtered route that fails the build
|
||||
// after all the real work is done, and nothing here wants lint output anyway.
|
||||
gradle.afterProject { project ->
|
||||
def android = project.extensions.findByName('android')
|
||||
if (android != null) {
|
||||
// AGP keeps both spellings around: lintOptions (deprecated) and lint.
|
||||
try { android.lintOptions.checkReleaseBuilds = false } catch (Exception ignored) { }
|
||||
try { android.lint.checkReleaseBuilds = false } catch (Exception ignored) { }
|
||||
|
||||
// NOTE: no signing config here on purpose. androiddeployqt copies the
|
||||
// *unsigned* gradle output and signs it itself (--sign); a signed
|
||||
// gradle APK makes it fail with "Could not copy unsigned APK". The
|
||||
// keystore is wired up from app/CMakeLists.txt through
|
||||
// QT_ANDROID_SIGN_APK + the QT_ANDROID_KEYSTORE_* environment.
|
||||
}
|
||||
// Belt and braces: switch the tasks themselves off.
|
||||
project.tasks.matching { it.name.startsWith('lintVital') }.configureEach { it.enabled = false }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static musl build of cst-agent for one architecture, packed as
|
||||
# dist/cst-agent-VERSION-linux-ARCH.tar.gz (+ .sha256).
|
||||
# scripts/package_linux.sh x86_64|x86|aarch64
|
||||
# CST_LINUX_CHECK=1 also runs `make check` (only where the binaries can run).
|
||||
# Builds in the source tree, so the tree is cleaned first.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ARCH="${1:?usage: $0 x86_64|x86|aarch64}"
|
||||
PREFIX="$ROOT/third_party/prefix-linux-$ARCH"
|
||||
cd "$ROOT"
|
||||
|
||||
read -r LINUX_HOST bindir < <(bash scripts/ci_linux_toolchain.sh "$ARCH")
|
||||
export PATH="$bindir:$PATH" LINUX_HOST
|
||||
T="$LINUX_HOST-"
|
||||
|
||||
if [[ ! -f "$PREFIX/lib/libcurl.a" || ! -f "$PREFIX/lib/libncursesw.a" ]]; then
|
||||
bash scripts/build_third_party_linux.sh "$ARCH"
|
||||
fi
|
||||
|
||||
# Only the static prefix: no host .pc file may leak into the link.
|
||||
export PKG_CONFIG_LIBDIR="$PREFIX/lib/pkgconfig"
|
||||
unset PKG_CONFIG_PATH CC CFLAGS LDFLAGS
|
||||
mk=(make CC="${T}gcc" AR="${T}ar" STATIC=1
|
||||
CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror")
|
||||
|
||||
make clean >/dev/null
|
||||
make -C vendor/sashidown clean >/dev/null 2>&1 || true
|
||||
"${mk[@]}" -j"$(nproc)" cst-agent
|
||||
if "${T}readelf" -l cst-agent | grep -q 'Requesting program interpreter'; then
|
||||
echo "cst-agent is dynamically linked" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${CST_LINUX_CHECK:-0}" == 1 ]]; then
|
||||
"${mk[@]}" check
|
||||
fi
|
||||
|
||||
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)"
|
||||
name="cst-agent-$ver-linux-$ARCH"
|
||||
rel="dist/$name"
|
||||
rm -rf "$rel"
|
||||
mkdir -p "$rel"
|
||||
cp -f cst-agent "$rel/cst-agent"
|
||||
"${T}strip" "$rel/cst-agent"
|
||||
cp -f README.md "$rel/README.md"
|
||||
echo "$ver" >"$rel/VERSION"
|
||||
tgz="dist/$name.tar.gz"
|
||||
tar -C dist -czf "$tgz" "$name"
|
||||
(cd dist && sha256sum "$name.tar.gz" >"$name.tar.gz.sha256")
|
||||
ls -la "$rel"
|
||||
echo "$ROOT/$tgz"
|
||||
@@ -36,3 +36,18 @@ rm -f "$zip"
|
||||
(cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256")
|
||||
ls -la "$REL"
|
||||
echo "$zip"
|
||||
|
||||
# NSIS installer from the same folder (CST_REQUIRE_NSIS=1: fail without makensis).
|
||||
setup="$ROOT/dist/cst-agent-$ver-windows-x86_64-setup.exe"
|
||||
if command -v makensis >/dev/null; then
|
||||
rm -f "$setup"
|
||||
makensis -V2 -WX -INPUTCHARSET UTF8 -DVERSION="$ver" -DSRCDIR="$REL" \
|
||||
-DOUTFILE="$setup" "$ROOT/scripts/windows/cst-agent.nsi"
|
||||
(cd "$ROOT/dist" && sha256sum "$(basename "$setup")" >"$(basename "$setup").sha256")
|
||||
echo "$setup"
|
||||
elif [[ "${CST_REQUIRE_NSIS:-0}" == 1 ]]; then
|
||||
echo "makensis not found" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "makensis not found; skipping the installer" >&2
|
||||
fi
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish dist/cst-agent-VERSION-windows-x86_64.zip (+ .sha256) to the Gitea
|
||||
# release vVERSION, creating the release (and its tag at HEAD) when missing.
|
||||
# GITEA_TOKEN=... scripts/release_windows.sh [--build]
|
||||
# --build runs scripts/bootstrap_windows.sh first. The token is only read from
|
||||
# the environment and handed to curl on stdin: it never appears in argv, on
|
||||
# disk or in the output. Assets of the same name are replaced.
|
||||
# Upload release files to the Gitea release vVERSION, creating the release
|
||||
# (and its tag at HEAD) when missing.
|
||||
# GITEA_TOKEN=... scripts/release.sh FILE...
|
||||
# Each FILE's FILE.sha256 is uploaded too when it exists; assets of the same
|
||||
# name are replaced. Several CI jobs may call this at once: the first creates
|
||||
# the release, the others find it. The token is only read from the
|
||||
# environment and handed to curl on stdin: it never appears in argv, on disk
|
||||
# or in the output.
|
||||
#
|
||||
# CI: set GITEA_TOKEN (or GITHUB_TOKEN), CST_GITEA, CST_REPO. The remote-branch
|
||||
# check is skipped under GITEA_ACTIONS / GITHUB_ACTIONS / CI.
|
||||
@@ -19,16 +21,20 @@ TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
echo "set GITEA_TOKEN (a Gitea token with write:repository)" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ $# -gt 0 ]] || { echo "usage: $0 FILE..." >&2; exit 1; }
|
||||
command -v python3 >/dev/null || { echo "missing python3" >&2; exit 1; }
|
||||
|
||||
files=()
|
||||
for f in "$@"; do
|
||||
[[ -f "$f" ]] || { echo "missing $f" >&2; exit 1; }
|
||||
f="$(cd "$(dirname "$f")" && pwd)/$(basename "$f")"
|
||||
files+=("$f")
|
||||
[[ "$f" != *.sha256 && -f "$f.sha256" ]] && files+=("$f.sha256")
|
||||
done
|
||||
cd "$ROOT"
|
||||
[[ "${1:-}" == --build ]] && scripts/bootstrap_windows.sh "$ROOT"
|
||||
|
||||
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)"
|
||||
tag="v$ver"
|
||||
zip="dist/cst-agent-$ver-windows-x86_64.zip"
|
||||
for f in "$zip" "$zip.sha256"; do
|
||||
[[ -f "$f" ]] || { echo "missing $f (scripts/package_windows.sh, or --build)" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# If this job was triggered by a version tag, it must match cst_version.h.
|
||||
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
|
||||
@@ -51,6 +57,12 @@ json() { # CODE [ARGS...]: d = the JSON on stdin, ARGS in sys.argv[1:]
|
||||
python3 -c "import json,sys; d=json.load(sys.stdin); $code" "$@"
|
||||
}
|
||||
|
||||
BODY="Linux (static, musl, any distro): cst-agent-$ver-linux-{x86_64,x86,aarch64}.tar.gz -- extract and run ./cst-agent, or copy it onto PATH.
|
||||
|
||||
Windows x86_64: cst-agent-$ver-windows-x86_64-setup.exe installs per user (no admin rights) and adds PATH; cst-agent-$ver-windows-x86_64.zip is the portable build (install_windows.ps1 inside).
|
||||
|
||||
Every file has a .sha256 next to it."
|
||||
|
||||
if rel="$(api GET "/releases/tags/$tag" 2>/dev/null)"; then
|
||||
echo "==> release $tag exists"
|
||||
else
|
||||
@@ -62,12 +74,22 @@ else
|
||||
fi
|
||||
fi
|
||||
echo "==> creating release $tag at $head"
|
||||
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": "CST-Agent " + sys.argv[1], "body": "Windows x86_64: unzip, or install with install_windows.ps1 from the zip.\\n\\nArtifacts: `cst-agent-VERSION-windows-x86_64.zip` + `.sha256`."}))' "$tag" "$head")"
|
||||
rel="$(api POST /releases -H 'Content-Type: application/json' -d "$body")"
|
||||
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": "CST-Agent " + sys.argv[1], "body": sys.argv[3]}))' "$tag" "$head" "$BODY")"
|
||||
if ! rel="$(api POST /releases -H 'Content-Type: application/json' -d "$body" 2>/dev/null)"; then
|
||||
# Another job created it first (409).
|
||||
rel=""
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
sleep 5
|
||||
rel="$(api GET "/releases/tags/$tag" 2>/dev/null)" && break
|
||||
rel=""
|
||||
done
|
||||
[[ -n "$rel" ]] || { echo "could not create or find release $tag" >&2; exit 1; }
|
||||
echo "==> release $tag created by another job"
|
||||
fi
|
||||
fi
|
||||
id="$(json 'print(d["id"])' <<<"$rel")"
|
||||
|
||||
for f in "$zip" "$zip.sha256"; do
|
||||
for f in "${files[@]}"; do
|
||||
name="$(basename "$f")"
|
||||
old="$(json "print(' '.join(str(a['id']) for a in d.get('assets') or [] if a['name'] == sys.argv[1]))" "$name" <<<"$rel")"
|
||||
for aid in $old; do
|
||||
@@ -0,0 +1,151 @@
|
||||
; CST-Agent per-user installer (no admin rights).
|
||||
; makensis -INPUTCHARSET UTF8 -DVERSION=0.8.5 -DSRCDIR=dist/windows-x86_64 -DOUTFILE=setup.exe cst-agent.nsi
|
||||
; Installs the same files as the zip into %LOCALAPPDATA%\Programs\cst-agent,
|
||||
; adds that folder to the user PATH and registers an uninstaller under HKCU.
|
||||
Unicode true
|
||||
SetCompressor /SOLID lzma
|
||||
RequestExecutionLevel user
|
||||
|
||||
!ifndef VERSION
|
||||
!error "pass -DVERSION=x.y.z"
|
||||
!endif
|
||||
!ifndef SRCDIR
|
||||
!error "pass -DSRCDIR=<dist/windows-x86_64>"
|
||||
!endif
|
||||
!ifndef OUTFILE
|
||||
!define OUTFILE "cst-agent-${VERSION}-windows-x86_64-setup.exe"
|
||||
!endif
|
||||
|
||||
!define APP "CST-Agent"
|
||||
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\cst-agent"
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "x64.nsh"
|
||||
|
||||
Name "${APP} ${VERSION}"
|
||||
OutFile "${OUTFILE}"
|
||||
InstallDir "$LOCALAPPDATA\Programs\cst-agent"
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation"
|
||||
BrandingText "${APP} ${VERSION}"
|
||||
|
||||
VIProductVersion "${VERSION}.0"
|
||||
VIAddVersionKey /LANG=1033 "ProductName" "${APP}"
|
||||
VIAddVersionKey /LANG=1033 "ProductVersion" "${VERSION}"
|
||||
VIAddVersionKey /LANG=1033 "FileVersion" "${VERSION}"
|
||||
VIAddVersionKey /LANG=1033 "FileDescription" "${APP} installer"
|
||||
VIAddVersionKey /LANG=1033 "LegalCopyright" "WHC"
|
||||
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_FINISHPAGE_RUN
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "$(RunWebUI)"
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchWebUI
|
||||
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||
|
||||
LangString NeedX64 ${LANG_ENGLISH} "${APP} needs 64-bit Windows."
|
||||
LangString NeedX64 ${LANG_SIMPCHINESE} "${APP} 需要 64 位 Windows。"
|
||||
LangString NeedBuild ${LANG_ENGLISH} "${APP} needs Windows 10 1903 (build 18362) or newer; this is build $0."
|
||||
LangString NeedBuild ${LANG_SIMPCHINESE} "${APP} 需要 Windows 10 1903(内部版本 18362)或更新版本,当前为 $0。"
|
||||
LangString RunWebUI ${LANG_ENGLISH} "Open the ${APP} WebUI"
|
||||
LangString RunWebUI ${LANG_SIMPCHINESE} "打开 ${APP} WebUI"
|
||||
LangString PathFail ${LANG_ENGLISH} "Could not add $INSTDIR to the user PATH; add it by hand."
|
||||
LangString PathFail ${LANG_SIMPCHINESE} "未能把 $INSTDIR 加入用户 PATH,请手动添加。"
|
||||
|
||||
; PowerShell edits the user PATH: NSIS strings are capped at 1024 chars, and
|
||||
; .NET broadcasts the change so new terminals see it. The folder is passed in
|
||||
; the environment, so quotes in it cannot break the command.
|
||||
!macro EditUserPath ACTION
|
||||
System::Call 'Kernel32::SetEnvironmentVariable(t "CST_INSTDIR", t "$INSTDIR")'
|
||||
nsExec::ExecToLog `powershell -NoProfile -ExecutionPolicy Bypass -Command "$$d = $$env:CST_INSTDIR.TrimEnd('\'); $$p = [Environment]::GetEnvironmentVariable('Path', 'User'); $$parts = @([string]$$p -split ';' | Where-Object { $$_ -and $$_.TrimEnd('\') -ine $$d }); if ('${ACTION}' -eq 'add') { $$parts += $$d }; [Environment]::SetEnvironmentVariable('Path', ($$parts -join ';'), 'User')"`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
; A running exe cannot be overwritten but can be renamed out of the way.
|
||||
!macro MoveAside NAME
|
||||
Delete "$INSTDIR\${NAME}.old"
|
||||
ClearErrors
|
||||
Delete "$INSTDIR\${NAME}"
|
||||
${If} ${Errors}
|
||||
Rename "$INSTDIR\${NAME}" "$INSTDIR\${NAME}.old"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
Function .onInit
|
||||
${IfNot} ${RunningX64}
|
||||
MessageBox MB_ICONSTOP "$(NeedX64)"
|
||||
Abort
|
||||
${EndIf}
|
||||
SetRegView 64
|
||||
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\Windows NT\CurrentVersion" "CurrentBuildNumber"
|
||||
${If} $0 < 18362
|
||||
MessageBox MB_ICONSTOP "$(NeedBuild)"
|
||||
Abort
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function LaunchWebUI
|
||||
SetOutPath "$PROFILE"
|
||||
Exec '"$INSTDIR\cst-agent.exe" --web --web-open'
|
||||
FunctionEnd
|
||||
|
||||
Section "Install"
|
||||
SetShellVarContext current
|
||||
SetOutPath "$INSTDIR"
|
||||
!insertmacro MoveAside "cst-agent.exe"
|
||||
!insertmacro MoveAside "busybox.exe"
|
||||
File "${SRCDIR}/cst-agent.exe"
|
||||
File "${SRCDIR}/busybox.exe"
|
||||
File "${SRCDIR}/LICENSE.busybox"
|
||||
File "${SRCDIR}/README.md"
|
||||
File "${SRCDIR}/cst-agent-update.cmd"
|
||||
File "${SRCDIR}/install_windows.ps1"
|
||||
File "${SRCDIR}/VERSION"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
!insertmacro EditUserPath "add"
|
||||
${If} $0 != 0
|
||||
DetailPrint "$(PathFail)"
|
||||
${EndIf}
|
||||
|
||||
; The WebUI's workspace is the shortcut's start folder.
|
||||
SetOutPath "$PROFILE"
|
||||
CreateShortCut "$SMPROGRAMS\${APP} WebUI.lnk" "$INSTDIR\cst-agent.exe" "--web --web-open" "$INSTDIR\cst-agent.exe" 0
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${APP}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "WHC"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\cst-agent.exe"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "URLInfoAbout" "https://git.whcself.xyz/WHC/CST-agent"
|
||||
WriteRegDWORD HKCU "${UNINST_KEY}" "NoModify" 1
|
||||
WriteRegDWORD HKCU "${UNINST_KEY}" "NoRepair" 1
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
SetShellVarContext current
|
||||
!insertmacro EditUserPath "remove"
|
||||
Delete "$SMPROGRAMS\${APP} WebUI.lnk"
|
||||
Delete "$INSTDIR\cst-agent.exe"
|
||||
Delete "$INSTDIR\cst-agent.exe.old"
|
||||
Delete "$INSTDIR\busybox.exe"
|
||||
Delete "$INSTDIR\busybox.exe.old"
|
||||
Delete "$INSTDIR\LICENSE.busybox"
|
||||
Delete "$INSTDIR\README.md"
|
||||
Delete "$INSTDIR\cst-agent-update.cmd"
|
||||
Delete "$INSTDIR\install_windows.ps1"
|
||||
Delete "$INSTDIR\VERSION"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
DeleteRegKey HKCU "${UNINST_KEY}"
|
||||
SectionEnd
|
||||
@@ -15,6 +15,7 @@ win.mkShell {
|
||||
pkg-config
|
||||
zip
|
||||
curl
|
||||
nsis
|
||||
];
|
||||
buildInputs = [ win.windows.pthreads ];
|
||||
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
|
||||
|
||||
@@ -177,8 +177,62 @@ static int tool_on(const cst_agent_config_t *cfg, const char *name) {
|
||||
return cst_tool_active(cfg, cst_tool_find(name));
|
||||
}
|
||||
|
||||
/* /mode general (cst_mode_name covers "" and unknown values). */
|
||||
static int general_mode(const cst_agent_config_t *cfg) {
|
||||
return cfg && strcmp(cst_mode_name(cfg->mode), "general") == 0;
|
||||
}
|
||||
|
||||
/* Workflow for /mode general: gather, then do the work, then check it. */
|
||||
static void put_workflow_general(cst_buf_t *b, const cst_agent_config_t *cfg) {
|
||||
cst_buf_puts(b, "\nWorkflow:\n1) Gather what you need");
|
||||
const char *explore[] = {"list_dir", "glob", "grep"};
|
||||
int n = 0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (tool_on(cfg, explore[i]))
|
||||
cst_buf_printf(b, "%s%s", n++ ? ", " : " (", explore[i]);
|
||||
}
|
||||
if (n)
|
||||
cst_buf_puts(b, ")");
|
||||
if (tool_on(cfg, "read_file"))
|
||||
cst_buf_puts(b, "; read big files in slices with read_file offset/limit, "
|
||||
"and use the documents the user attached");
|
||||
cst_buf_puts(b, ".\n2) Do the work");
|
||||
n = 0;
|
||||
if (tool_on(cfg, "write_file")) {
|
||||
cst_buf_puts(b, ": write_file for new files");
|
||||
n++;
|
||||
}
|
||||
if (tool_on(cfg, "edit_file"))
|
||||
cst_buf_printf(b, "%sedit_file for small exact replacements",
|
||||
n++ ? ", " : ": ");
|
||||
if (tool_on(cfg, "apply_patch"))
|
||||
cst_buf_printf(b, "%sapply_patch for multi-hunk diffs to existing files",
|
||||
n++ ? ", " : ": ");
|
||||
if (tool_on(cfg, "run_shell"))
|
||||
cst_buf_printf(b, "%srun_shell for commands, computations, data "
|
||||
"processing and other CLIs",
|
||||
n++ ? ", " : ": ");
|
||||
cst_buf_puts(b, ".\n");
|
||||
if (tool_on(cfg, "run_shell"))
|
||||
cst_buf_puts(b, "3) Check the result before saying it works (re-read it, "
|
||||
"run it, or verify the numbers); fix and re-run until it "
|
||||
"holds.\n");
|
||||
if (tool_on(cfg, "todo_write"))
|
||||
cst_buf_puts(b, "- For work with several steps keep a todo list with "
|
||||
"todo_write and update it as you go.\n");
|
||||
if (tool_on(cfg, "ask_user"))
|
||||
cst_buf_puts(b, "- Use ask_user only when a decision is truly the user's; "
|
||||
"otherwise choose sensibly and say so.\n");
|
||||
cst_buf_puts(b, "Finish with a short plain-text summary of the result.\n"
|
||||
"Never invent other tool names.\n");
|
||||
}
|
||||
|
||||
/* Workflow section for the default tool profile; names only active tools. */
|
||||
static void put_workflow(cst_buf_t *b, const cst_agent_config_t *cfg) {
|
||||
if (general_mode(cfg)) {
|
||||
put_workflow_general(b, cfg);
|
||||
return;
|
||||
}
|
||||
cst_buf_puts(b, "\nWorkflow:\n1) Inspect only what you need");
|
||||
const char *explore[] = {"list_dir", "glob", "grep"};
|
||||
int n = 0;
|
||||
@@ -235,6 +289,25 @@ static const char DEF_INTRO[] =
|
||||
"could not verify, say so.\n"
|
||||
"- Keep going until the task is done or truly blocked.\n\n";
|
||||
|
||||
/* Same role, framed for /mode general: the workspace may hold documents,
|
||||
* data or anything else, not just code. */
|
||||
static const char DEF_INTRO_GENERAL[] =
|
||||
"You are CST-Agent, a general-purpose assistant working in the user's "
|
||||
"local workspace. All tool paths are relative to the workspace root and "
|
||||
"shell commands run there; the material you work on may be code, "
|
||||
"documents, data or anything else.\n\n"
|
||||
"Principles:\n"
|
||||
"- Reply in the user's language. Be concise: lead with the result, no "
|
||||
"filler.\n"
|
||||
"- Understand before answering: read the files, documents and sources the "
|
||||
"task refers to instead of guessing; never invent content, numbers or "
|
||||
"tool results.\n"
|
||||
"- Do what was asked, at the scope asked: leave unrelated files, data and "
|
||||
"material alone.\n"
|
||||
"- Check your work when you can (re-read it, run it, cross-check the "
|
||||
"source); if you could not verify something, say so.\n"
|
||||
"- Keep going until the task is done or truly blocked.\n\n";
|
||||
|
||||
static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
|
||||
cst_buf_puts(b, "\nTool use:\n");
|
||||
if (tool_on(cfg, "run_shell") &&
|
||||
@@ -248,11 +321,22 @@ static void put_tool_use(cst_buf_t *b, const cst_agent_config_t *cfg) {
|
||||
cst_buf_puts(b, "- Read a file before editing it and again after a failed "
|
||||
"edit. The `N|` prefixes in read_file output are not part "
|
||||
"of the file.\n");
|
||||
if (general_mode(cfg) && tool_on(cfg, "web_fetch"))
|
||||
cst_buf_puts(b, "- Use web_fetch for pages the user points you to; say so "
|
||||
"when a page could not be fetched instead of guessing its "
|
||||
"content.\n");
|
||||
cst_buf_puts(b, "- When a call fails, read the error and change approach "
|
||||
"instead of repeating the same call.\n");
|
||||
}
|
||||
|
||||
static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
|
||||
if (general_mode(cfg))
|
||||
cst_buf_puts(b, "\nSafety:\n"
|
||||
"- Do not delete or overwrite files or data you did not "
|
||||
"create unless the task requires it, and do not send the "
|
||||
"user's content anywhere the task did not ask for. Never "
|
||||
"print secrets or API keys.\n");
|
||||
else
|
||||
cst_buf_puts(b, "\nSafety:\n"
|
||||
"- Do not commit, push or rewrite git history unless asked. "
|
||||
"Do not delete or overwrite files you did not create unless "
|
||||
@@ -365,7 +449,9 @@ char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
|
||||
const char *project_notes) {
|
||||
int minimal = !cfg || cfg->tool_profile == CST_TOOLS_MINIMAL;
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, minimal ? PROMPT_BASE : DEF_INTRO);
|
||||
cst_buf_puts(&b, minimal ? PROMPT_BASE
|
||||
: general_mode(cfg) ? DEF_INTRO_GENERAL
|
||||
: DEF_INTRO);
|
||||
if (cst_protocol_is_native(p))
|
||||
cst_buf_puts(&b, minimal ? SEC_NATIVE
|
||||
: "Call tools through the native "
|
||||
@@ -1095,6 +1181,8 @@ static void exec_end(turn_t *t, pcall_t *pc, exec_t *x) {
|
||||
if (t->hooks && t->hooks->on_tool_result)
|
||||
t->hooks->on_tool_result(c, pc->output, pc->output_len, pc->exit_code,
|
||||
t->hooks->userdata);
|
||||
if (t->hooks && t->hooks->on_tool_images && pc->nattach > 0)
|
||||
t->hooks->on_tool_images(c, pc->attach, pc->nattach, t->hooks->userdata);
|
||||
if (pc->status != PC_DENIED) {
|
||||
char d[96];
|
||||
snprintf(d, sizeof(d), "%sexit=%d bytes=%zu",
|
||||
@@ -1764,6 +1852,9 @@ static void fill_request(cst_agent_t *a, cst_llm_request_t *req,
|
||||
req->session_path = cst_session_path(a->session);
|
||||
req->cancel_flag = &a->cancel;
|
||||
req->tool_out_cap = a->cfg.tool_out_cap;
|
||||
req->temperature = a->cfg.temperature;
|
||||
req->top_p = a->cfg.top_p;
|
||||
req->max_tokens = a->cfg.max_tokens;
|
||||
if (cst_protocol_is_native(proto)) {
|
||||
req->enable_tools = 1;
|
||||
req->tool_cfg = &a->cfg;
|
||||
@@ -1956,16 +2047,8 @@ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) {
|
||||
out[0] = 0;
|
||||
if (!a)
|
||||
return;
|
||||
const char *p = a->cfg.session_path;
|
||||
const char *sl = strrchr(p, '/');
|
||||
const char *base = sl ? sl + 1 : p;
|
||||
size_t n = strlen(base);
|
||||
if (n >= out_sz)
|
||||
n = out_sz - 1;
|
||||
memcpy(out, base, n);
|
||||
out[n] = 0;
|
||||
if (n > 6 && strcmp(out + n - 6, ".jsonl") == 0)
|
||||
out[n - 6] = 0;
|
||||
if (cst_agent_store_id_of_path(a->cfg.session_path, out, out_sz) != 0)
|
||||
out[0] = 0;
|
||||
}
|
||||
|
||||
const char *cst_agent_session_path(const cst_agent_t *a) {
|
||||
@@ -2084,6 +2167,20 @@ int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
|
||||
err, err_sz);
|
||||
}
|
||||
|
||||
/* The stored name of a pending attachment: the window serves the file from the
|
||||
* session's attachment directory while the message is still being written. */
|
||||
const char *cst_agent_pending_rel(const cst_agent_t *a, int i) {
|
||||
if (!a || i < 0 || i >= a->npending)
|
||||
return NULL;
|
||||
return a->pending[i].rel;
|
||||
}
|
||||
|
||||
int cst_agent_pending_is_image(const cst_agent_t *a, int i) {
|
||||
if (!a || i < 0 || i >= a->npending)
|
||||
return 0;
|
||||
return cst_attach_is_image(&a->pending[i]);
|
||||
}
|
||||
|
||||
const char *cst_agent_todo(const cst_agent_t *a) { return a ? a->todo : NULL; }
|
||||
|
||||
const char *cst_agent_notes(const cst_agent_t *a) {
|
||||
@@ -2241,11 +2338,12 @@ int cst_agent_undo_available(const cst_agent_t *a) {
|
||||
* later changes reach the model as persisted notes appended to the history,
|
||||
* which keeps the cached prefix intact. */
|
||||
|
||||
#define PROMPT_VERSION 2
|
||||
#define PROMPT_VERSION 3
|
||||
|
||||
typedef struct {
|
||||
int plan;
|
||||
char approval[96];
|
||||
char mode[16];
|
||||
char branch[128];
|
||||
char day[16];
|
||||
char notes[24]; /* hash of AGENTS.md, "" = none */
|
||||
@@ -2255,6 +2353,7 @@ static void state_now(const cst_agent_t *a, pstate_t *s) {
|
||||
memset(s, 0, sizeof(*s));
|
||||
s->plan = a->cfg.plan_mode;
|
||||
snprintf(s->approval, sizeof(s->approval), "%s", approval_desc(&a->cfg));
|
||||
snprintf(s->mode, sizeof(s->mode), "%s", cst_mode_name(a->cfg.mode));
|
||||
git_branch(a->cfg.workspace_root, s->branch, sizeof(s->branch));
|
||||
today(s->day, sizeof(s->day));
|
||||
if (a->notes && a->notes[0])
|
||||
@@ -2267,6 +2366,7 @@ static cJSON *state_json(const pstate_t *s) {
|
||||
cJSON *o = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(o, "plan", s->plan);
|
||||
cJSON_AddStringToObject(o, "approval", s->approval);
|
||||
cJSON_AddStringToObject(o, "mode", s->mode);
|
||||
cJSON_AddStringToObject(o, "branch", s->branch);
|
||||
cJSON_AddStringToObject(o, "day", s->day);
|
||||
cJSON_AddStringToObject(o, "notes", s->notes);
|
||||
@@ -2282,6 +2382,7 @@ static void state_parse(const cJSON *o, pstate_t *s) {
|
||||
if (cJSON_IsString(v)) \
|
||||
snprintf(s->field, sizeof(s->field), "%s", v->valuestring);
|
||||
STATE_STR("approval", approval)
|
||||
STATE_STR("mode", mode)
|
||||
STATE_STR("branch", branch)
|
||||
STATE_STR("day", day)
|
||||
STATE_STR("notes", notes)
|
||||
@@ -2293,6 +2394,7 @@ static char *system_meta(const cst_agent_t *a, const pstate_t *s) {
|
||||
cJSON_AddStringToObject(o, "protocol", cst_protocol_name(a->cfg.protocol));
|
||||
cJSON_AddNumberToObject(o, "pv", PROMPT_VERSION);
|
||||
cJSON_AddNumberToObject(o, "tools", a->cfg.tool_profile);
|
||||
cJSON_AddStringToObject(o, "mode", cst_mode_name(a->cfg.mode));
|
||||
cJSON_AddItemToObject(o, "state", state_json(s));
|
||||
char *out = cJSON_PrintUnformatted(o);
|
||||
cJSON_Delete(o);
|
||||
@@ -2312,10 +2414,13 @@ static int system_current(const cst_message_t *m, const cst_agent_config_t *c) {
|
||||
const cJSON *pv = cJSON_GetObjectItemCaseSensitive(meta, "pv");
|
||||
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(meta, "protocol");
|
||||
const cJSON *tp = cJSON_GetObjectItemCaseSensitive(meta, "tools");
|
||||
const cJSON *md = cJSON_GetObjectItemCaseSensitive(meta, "mode");
|
||||
int ok = cJSON_IsNumber(pv) && pv->valueint == PROMPT_VERSION &&
|
||||
cJSON_IsString(pr) &&
|
||||
strcmp(pr->valuestring, cst_protocol_name(c->protocol)) == 0 &&
|
||||
cJSON_IsNumber(tp) && tp->valueint == c->tool_profile;
|
||||
ok = ok && cJSON_IsString(md) &&
|
||||
strcmp(md->valuestring, cst_mode_name(c->mode)) == 0;
|
||||
cJSON_Delete(meta);
|
||||
return ok;
|
||||
}
|
||||
@@ -2393,6 +2498,14 @@ static void sync_notes(cst_agent_t *a) {
|
||||
"is available again.\n");
|
||||
if (strcmp(now.approval, was.approval) != 0)
|
||||
cst_buf_printf(&b, "[cst] Approval mode is now %s.\n", now.approval);
|
||||
if (strcmp(now.mode, was.mode) != 0)
|
||||
cst_buf_printf(&b,
|
||||
"[cst] Working mode is now %s%s. Earlier instructions about "
|
||||
"how to frame the work no longer apply.\n",
|
||||
now.mode,
|
||||
strcmp(now.mode, "general") == 0
|
||||
? " (general: documents, data, research)"
|
||||
: " (coding: repo, build, tests)");
|
||||
if (strcmp(now.branch, was.branch) != 0)
|
||||
cst_buf_printf(&b, "[cst] The git branch is now %s.\n",
|
||||
now.branch[0] ? now.branch : "(none)");
|
||||
@@ -2436,6 +2549,24 @@ cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) {
|
||||
a->cfg.max_steps = CST_DEFAULT_MAX_STEPS;
|
||||
if (a->cfg.tool_out_cap == 0)
|
||||
a->cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
|
||||
/* The generation parameters the user saved for this provider (Settings ->
|
||||
* Advanced in the app, "params" in settings.json). They are read here so
|
||||
* that the CLI and the TUI send what the GUI was set to; a caller that
|
||||
* named one explicitly wins. */
|
||||
{
|
||||
cst_settings_t st;
|
||||
if (cst_settings_load(&st) == 0) {
|
||||
const cst_gen_params_t *g = cst_settings_params(&st, a->cfg.provider);
|
||||
if (g) {
|
||||
if (a->cfg.temperature <= 0)
|
||||
a->cfg.temperature = g->temperature;
|
||||
if (a->cfg.top_p <= 0)
|
||||
a->cfg.top_p = g->top_p;
|
||||
if (a->cfg.max_tokens <= 0)
|
||||
a->cfg.max_tokens = g->max_tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path
|
||||
: "session.jsonl");
|
||||
if (!a->session) {
|
||||
|
||||
@@ -23,6 +23,29 @@
|
||||
#define OS_F_OK F_OK
|
||||
#endif
|
||||
|
||||
/* --- the store's shape ----------------------------------------------------
|
||||
* A session owns a directory: everything the session keeps is inside it, so
|
||||
* "delete this conversation" is one rmdir and nothing has to be found by
|
||||
* guesswork (a phone's app data directory has no user to tidy it by hand).
|
||||
*
|
||||
* <workspace>/.agent/sessions/
|
||||
* <uuid>/session.jsonl the transcript
|
||||
* <uuid>/title the window title, one line
|
||||
* <uuid>/attachments/ what the messages carry: images, delivered
|
||||
* files (<id>.png, art-<id>.md, ...)
|
||||
* <uuid>/sub-1.jsonl a sub-agent's own transcript
|
||||
* CURRENT the id the next run opens
|
||||
*
|
||||
* Sessions written before this layout are flat, with one shared attachment
|
||||
* directory, and keep working: every reader here takes either shape and
|
||||
* cst_agent_store_migrate() (--migrate-store) moves them in.
|
||||
*
|
||||
* <uuid>.jsonl
|
||||
* <uuid>.title
|
||||
* attachments/<uuid>/... (shared by every session)
|
||||
* <uuid>/sub-1.jsonl (already per session)
|
||||
*/
|
||||
|
||||
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) {
|
||||
if (!workspace || !out || out_sz < 16)
|
||||
return -1;
|
||||
@@ -53,24 +76,140 @@ int cst_agent_store_ensure(const char *workspace) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void make_id(char *out, size_t n) {
|
||||
time_t now = time(NULL);
|
||||
struct tm tm;
|
||||
os_localtime(&now, &tm);
|
||||
static unsigned seq;
|
||||
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++;
|
||||
snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900,
|
||||
tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec,
|
||||
r & 0xffff);
|
||||
/* An id is a directory name: anything that could leave the store, and the two
|
||||
* names the store itself uses, is not one. */
|
||||
static int id_ok(const char *id) {
|
||||
if (!id || !id[0] || id[0] == '.')
|
||||
return 0;
|
||||
for (const char *p = id; *p; p++) {
|
||||
char c = *p;
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
|
||||
return 0;
|
||||
}
|
||||
if (strstr(id, ".."))
|
||||
return 0;
|
||||
return strcmp(id, "CURRENT") != 0 && strcmp(id, "attachments") != 0;
|
||||
}
|
||||
|
||||
/* 16 random bytes, whatever the platform offers; the clock and the pid are
|
||||
* the fallback (a collision only costs a reopen, not data). */
|
||||
static void random_bytes(unsigned char *out, size_t n) {
|
||||
FILE *f = fopen("/dev/urandom", "rb");
|
||||
if (f) {
|
||||
size_t got = fread(out, 1, n, f);
|
||||
fclose(f);
|
||||
if (got == n)
|
||||
return;
|
||||
}
|
||||
static unsigned seq;
|
||||
unsigned long long x = (unsigned long long)time(NULL) ^
|
||||
((unsigned long long)os_getpid() << 20) ^
|
||||
((unsigned long long)++seq << 8);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
x = x * 6364136223846793005ULL + 1442695040888963407ULL;
|
||||
out[i] = (unsigned char)(x >> 33);
|
||||
}
|
||||
}
|
||||
|
||||
/* A uuid v4: unique across machines and time, which a timestamp is not (two
|
||||
* phones, one synced workspace, and the ids have to stay apart). */
|
||||
int cst_agent_store_new_id(char *id_out, size_t id_sz) {
|
||||
if (!id_out || id_sz < 24)
|
||||
if (!id_out || id_sz < 37)
|
||||
return -1;
|
||||
make_id(id_out, id_sz);
|
||||
unsigned char b[16];
|
||||
random_bytes(b, sizeof(b));
|
||||
b[6] = (unsigned char)((b[6] & 0x0f) | 0x40); /* version 4 */
|
||||
b[8] = (unsigned char)((b[8] & 0x3f) | 0x80); /* variant 1 */
|
||||
snprintf(id_out, id_sz,
|
||||
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-"
|
||||
"%02x%02x%02x%02x%02x%02x",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
|
||||
b[11], b[12], b[13], b[14], b[15]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* <sessions>/<id>, the session's own directory (not made here). */
|
||||
static int dir_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (!workspace || !id_ok(id))
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, out_sz, dir, id);
|
||||
}
|
||||
|
||||
/* <sessions>/<id>.jsonl -- the shape before per-session directories. */
|
||||
static int flat_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (!workspace || !id_ok(id))
|
||||
return -1;
|
||||
char dir[OS_PATH_MAX], leaf[192];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
}
|
||||
|
||||
/* The transcript of an id in the per-session layout. */
|
||||
static int nested_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, out_sz, d, "session.jsonl");
|
||||
}
|
||||
|
||||
int cst_agent_store_id_of_path(const char *session_path, char *out,
|
||||
size_t out_sz) {
|
||||
if (!session_path || !session_path[0] || !out || out_sz < 2)
|
||||
return -1;
|
||||
const char *base = session_path;
|
||||
for (const char *p = session_path; *p; p++)
|
||||
if (*p == '/' || *p == '\\')
|
||||
base = p + 1;
|
||||
size_t n = strlen(base);
|
||||
if (n > 6 && strcmp(base + n - 6, ".jsonl") == 0) {
|
||||
size_t stem = n - 6;
|
||||
if (stem == 7 && strncmp(base, "session", 7) == 0) {
|
||||
/* <id>/session.jsonl: the id is the directory the file sits in. */
|
||||
if (base == session_path)
|
||||
return -1; /* a bare "session.jsonl" has no session to name */
|
||||
const char *end = base - 1; /* the separator before the leaf */
|
||||
const char *start = end;
|
||||
while (start > session_path && start[-1] != '/' && start[-1] != '\\')
|
||||
start--;
|
||||
size_t dn = (size_t)(end - start);
|
||||
if (!dn || dn >= out_sz)
|
||||
return -1;
|
||||
memcpy(out, start, dn);
|
||||
out[dn] = 0;
|
||||
return 0;
|
||||
}
|
||||
n = stem;
|
||||
}
|
||||
if (!n || n >= out_sz)
|
||||
return -1;
|
||||
memcpy(out, base, n);
|
||||
out[n] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Which shape this id has. 1 = its own directory, 0 = flat, -1 = neither
|
||||
* (and not a usable id). */
|
||||
static int shape_of_id(const char *workspace, const char *id) {
|
||||
char p[OS_PATH_MAX];
|
||||
os_stat_t st;
|
||||
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 &&
|
||||
os_lstat(p, &st) == 0 && !st.is_dir)
|
||||
return 1;
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 &&
|
||||
!st.is_dir)
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
char *out, size_t out_sz) {
|
||||
if (!workspace || !id || !out)
|
||||
@@ -80,12 +219,19 @@ int cst_agent_store_path_for_id(const char *workspace, const char *id,
|
||||
snprintf(out, out_sz, "%s", id);
|
||||
return 0;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
char leaf[160];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
int shape = shape_of_id(workspace, id);
|
||||
if (shape == 0)
|
||||
return flat_for_id(workspace, id, out, out_sz);
|
||||
if (nested_for_id(workspace, id, out, out_sz) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
if (shape < 0) {
|
||||
/* A new session: its directory is made here, so whoever opens the
|
||||
* transcript next has a place to put it (and its attachments). */
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) == 0)
|
||||
os_mkdir_p(d, 0700);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_store_set_current(const char *workspace, const char *id) {
|
||||
@@ -164,24 +310,42 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
|
||||
return 0;
|
||||
typedef struct {
|
||||
char id[128];
|
||||
time_t mtime;
|
||||
double mtime;
|
||||
} ent_t;
|
||||
ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
|
||||
int n = 0, cap = max_ids * 4;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
if (n >= cap)
|
||||
break;
|
||||
if (de->is_dir) {
|
||||
/* a per-session directory: only one that holds a transcript counts */
|
||||
if (!id_ok(de->name))
|
||||
continue;
|
||||
char p[OS_PATH_MAX];
|
||||
if (nested_for_id(workspace, de->name, p, sizeof(p)) != 0)
|
||||
continue;
|
||||
os_stat_t st;
|
||||
if (os_lstat(p, &st) != 0 || st.is_dir)
|
||||
continue;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%s", de->name);
|
||||
ents[n].mtime = st.mtime;
|
||||
n++;
|
||||
continue;
|
||||
}
|
||||
size_t len = strlen(de->name);
|
||||
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
|
||||
continue;
|
||||
if (n >= cap)
|
||||
break;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6),
|
||||
de->name);
|
||||
char id[128];
|
||||
snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
|
||||
if (!id_ok(id))
|
||||
continue;
|
||||
char full[OS_PATH_MAX];
|
||||
if (os_path_join(full, sizeof(full), dir, de->name) != 0)
|
||||
continue;
|
||||
struct stat st;
|
||||
ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0;
|
||||
os_stat_t st;
|
||||
snprintf(ents[n].id, sizeof(ents[n].id), "%s", id);
|
||||
ents[n].mtime = os_lstat(full, &st) == 0 ? st.mtime : 0;
|
||||
n++;
|
||||
}
|
||||
os_dir_close(d);
|
||||
@@ -201,20 +365,28 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
|
||||
return out_n;
|
||||
}
|
||||
|
||||
static int side_path(const char *workspace, const char *id, const char *ext,
|
||||
char *out, size_t out_sz) {
|
||||
char dir[OS_PATH_MAX], leaf[192];
|
||||
if (!id || !id[0] || strchr(id, '/') || strchr(id, '\\') ||
|
||||
cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
/* The title of a session: inside its directory in the new layout, a <id>.title
|
||||
* beside the transcript in the old one. */
|
||||
static int title_for_id(const char *workspace, const char *id, char *out,
|
||||
size_t out_sz) {
|
||||
if (shape_of_id(workspace, id) == 0) {
|
||||
char dir[OS_PATH_MAX];
|
||||
char leaf[192];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
||||
return -1;
|
||||
snprintf(leaf, sizeof(leaf), "%s%s", id, ext);
|
||||
snprintf(leaf, sizeof(leaf), "%s.title", id);
|
||||
return os_path_join(out, out_sz, dir, leaf);
|
||||
}
|
||||
char d[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, d, sizeof(d)) != 0)
|
||||
return -1;
|
||||
return os_path_join(out, out_sz, d, "title");
|
||||
}
|
||||
|
||||
int cst_agent_store_title_set(const char *workspace, const char *id,
|
||||
const char *title) {
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".title", p, sizeof(p)) != 0)
|
||||
if (title_for_id(workspace, id, p, sizeof(p)) != 0)
|
||||
return -1;
|
||||
if (!title || !title[0])
|
||||
return remove(p) == 0 || errno == ENOENT ? 0 : -1;
|
||||
@@ -247,14 +419,18 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
memset(info, 0, sizeof(*info));
|
||||
snprintf(info->id, sizeof(info->id), "%s", id ? id : "");
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0)
|
||||
if (shape_of_id(workspace, id) == 0) {
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) != 0)
|
||||
return -1;
|
||||
struct stat st;
|
||||
if (stat(p, &st) != 0)
|
||||
} else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) {
|
||||
return -1;
|
||||
info->mtime = (long long)st.st_mtime;
|
||||
}
|
||||
os_stat_t st;
|
||||
if (os_lstat(p, &st) != 0)
|
||||
return -1;
|
||||
info->mtime = (long long)st.mtime;
|
||||
char tp[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) {
|
||||
if (title_for_id(workspace, id, tp, sizeof(tp)) == 0) {
|
||||
FILE *tf = fopen(tp, "rb");
|
||||
if (tf) {
|
||||
if (fgets(info->title, sizeof(info->title), tf)) {
|
||||
@@ -304,36 +480,204 @@ int cst_agent_store_info(const char *workspace, const char *id,
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_agent_store_delete(const char *workspace, const char *id) {
|
||||
char p[OS_PATH_MAX];
|
||||
if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0)
|
||||
/* Remove a tree (a session's directory: its transcript, its attachments and
|
||||
* its sub-agent files). Links are removed, never followed. */
|
||||
static int rm_tree(const char *path) {
|
||||
os_stat_t st;
|
||||
if (os_lstat(path, &st) != 0)
|
||||
return -1;
|
||||
if (remove(p) != 0)
|
||||
return -1;
|
||||
if (side_path(workspace, id, ".title", p, sizeof(p)) == 0)
|
||||
remove(p);
|
||||
/* sub-agent transcripts live in <id>/ */
|
||||
if (side_path(workspace, id, "", p, sizeof(p)) == 0) {
|
||||
os_dir_t *d = os_dir_open(p);
|
||||
if (!st.is_dir)
|
||||
return remove(path) == 0 ? 0 : -1;
|
||||
os_dir_t *d = os_dir_open(path);
|
||||
if (d) {
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
char f[OS_PATH_MAX];
|
||||
if (strncmp(de->name, "sub-", 4) == 0 &&
|
||||
os_path_join(f, sizeof(f), p, de->name) == 0)
|
||||
remove(f);
|
||||
if (os_path_join(f, sizeof(f), path, de->name) == 0)
|
||||
rm_tree(f);
|
||||
}
|
||||
os_dir_close(d);
|
||||
rmdir(p);
|
||||
}
|
||||
return rmdir(path) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
int cst_agent_store_delete(const char *workspace, const char *id) {
|
||||
if (!id_ok(id))
|
||||
return -1;
|
||||
char p[OS_PATH_MAX];
|
||||
int found = 0;
|
||||
/* both shapes, whichever this session has (or had) */
|
||||
if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
|
||||
found = 1;
|
||||
if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0)
|
||||
found = 1;
|
||||
if (!found)
|
||||
return -1;
|
||||
if (title_for_id(workspace, id, p, sizeof(p)) == 0)
|
||||
remove(p);
|
||||
/* the session's own directory: attachments, sub-agent transcripts */
|
||||
if (dir_for_id(workspace, id, p, sizeof(p)) == 0)
|
||||
rm_tree(p);
|
||||
/* and the shared attachment directory of the old layout */
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
|
||||
os_path_join(p, sizeof(p), dir, "attachments") == 0) {
|
||||
char one[OS_PATH_MAX];
|
||||
if (os_path_join(one, sizeof(one), p, id) == 0)
|
||||
rm_tree(one);
|
||||
}
|
||||
char cur[128];
|
||||
if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 &&
|
||||
strcmp(cur, id) == 0) {
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 &&
|
||||
os_path_join(p, sizeof(p), dir, "CURRENT") == 0)
|
||||
remove(p);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Move a directory: rename if the OS likes it, otherwise copy file by file
|
||||
* (a synced workspace can be odd about renames). */
|
||||
static int move_file(const char *from, const char *to) {
|
||||
if (os_rename_replace(from, to) == 0)
|
||||
return 0;
|
||||
FILE *in = fopen(from, "rb");
|
||||
if (!in)
|
||||
return -1;
|
||||
FILE *out = fopen(to, "wb");
|
||||
if (!out) {
|
||||
fclose(in);
|
||||
return -1;
|
||||
}
|
||||
char buf[16384];
|
||||
size_t n;
|
||||
int rc = 0;
|
||||
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
|
||||
if (fwrite(buf, 1, n, out) != n) {
|
||||
rc = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(in);
|
||||
if (fclose(out) != 0)
|
||||
rc = -1;
|
||||
if (rc == 0)
|
||||
remove(from);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int move_tree(const char *from, const char *to) {
|
||||
os_stat_t st;
|
||||
if (os_lstat(from, &st) != 0)
|
||||
return -1;
|
||||
if (!st.is_dir)
|
||||
return move_file(from, to);
|
||||
if (os_mkdir_p(to, 0700) != 0)
|
||||
return -1;
|
||||
os_dir_t *d = os_dir_open(from);
|
||||
if (!d)
|
||||
return -1;
|
||||
int rc = 0;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
char f[OS_PATH_MAX], t[OS_PATH_MAX];
|
||||
if (os_path_join(f, sizeof(f), from, de->name) != 0 ||
|
||||
os_path_join(t, sizeof(t), to, de->name) != 0 || move_tree(f, t) != 0)
|
||||
rc = -1;
|
||||
}
|
||||
os_dir_close(d);
|
||||
if (rc == 0)
|
||||
rmdir(from);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_agent_store_migrate(const char *workspace, int *moved_out,
|
||||
int *failed_out, char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (moved_out)
|
||||
*moved_out = 0;
|
||||
if (failed_out)
|
||||
*failed_out = 0;
|
||||
if (!workspace || !workspace[0]) {
|
||||
snprintf(err, err_sz, "no workspace");
|
||||
return -1;
|
||||
}
|
||||
if (cst_agent_store_ensure(workspace) != 0) {
|
||||
snprintf(err, err_sz, "cannot use the store under %s", workspace);
|
||||
return -1;
|
||||
}
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) {
|
||||
snprintf(err, err_sz, "no sessions directory");
|
||||
return -1;
|
||||
}
|
||||
os_dir_t *d = os_dir_open(dir);
|
||||
if (!d) {
|
||||
snprintf(err, err_sz, "cannot read %s", dir);
|
||||
return -1;
|
||||
}
|
||||
int moved = 0, failed = 0;
|
||||
const os_dirent_t *de;
|
||||
while ((de = os_dir_next(d)) != NULL) {
|
||||
if (de->is_dir)
|
||||
continue;
|
||||
size_t len = strlen(de->name);
|
||||
if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
|
||||
continue;
|
||||
char id[128];
|
||||
snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name);
|
||||
if (!id_ok(id))
|
||||
continue;
|
||||
char nested_dir[OS_PATH_MAX], nested[OS_PATH_MAX], flat[OS_PATH_MAX];
|
||||
if (dir_for_id(workspace, id, nested_dir, sizeof(nested_dir)) != 0 ||
|
||||
nested_for_id(workspace, id, nested, sizeof(nested)) != 0 ||
|
||||
flat_for_id(workspace, id, flat, sizeof(flat)) != 0) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
/* already moved (a second run, or a session that never was flat) */
|
||||
os_stat_t st;
|
||||
if (os_lstat(nested, &st) == 0)
|
||||
continue;
|
||||
if (os_mkdir_p(nested_dir, 0700) != 0 || move_file(flat, nested) != 0) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
/* the title and the attachments that belong to this session */
|
||||
char sdir[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, sdir, sizeof(sdir)) == 0) {
|
||||
char t_from[OS_PATH_MAX], t_to[OS_PATH_MAX];
|
||||
char leaf[192];
|
||||
snprintf(leaf, sizeof(leaf), "%s.title", id);
|
||||
if (os_path_join(t_from, sizeof(t_from), sdir, leaf) == 0 &&
|
||||
os_path_join(t_to, sizeof(t_to), nested_dir, "title") == 0)
|
||||
move_file(t_from, t_to); /* no title is not a failure */
|
||||
}
|
||||
char att_dir[OS_PATH_MAX], att_one[OS_PATH_MAX];
|
||||
char a_from[OS_PATH_MAX], a_to[OS_PATH_MAX];
|
||||
if (cst_agent_store_dir(workspace, att_dir, sizeof(att_dir)) == 0 &&
|
||||
os_path_join(att_one, sizeof(att_one), att_dir, "attachments") == 0 &&
|
||||
os_path_join(a_from, sizeof(a_from), att_one, id) == 0 &&
|
||||
os_path_join(a_to, sizeof(a_to), nested_dir, "attachments") == 0) {
|
||||
os_stat_t ast;
|
||||
if (os_lstat(a_from, &ast) == 0 && ast.is_dir)
|
||||
move_tree(a_from, a_to);
|
||||
}
|
||||
moved++;
|
||||
}
|
||||
os_dir_close(d);
|
||||
/* the shared directory itself is empty now (and has no other purpose) */
|
||||
char att[OS_PATH_MAX];
|
||||
if (os_path_join(att, sizeof(att), dir, "attachments") == 0)
|
||||
rmdir(att); /* fails while another session still uses it: fine */
|
||||
if (moved_out)
|
||||
*moved_out = moved;
|
||||
if (failed_out)
|
||||
*failed_out = failed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_pdf.h"
|
||||
#include "cst_util.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
@@ -102,6 +103,9 @@ static int le16(const unsigned char *p) {
|
||||
}
|
||||
|
||||
static const char *mime_of_ext(const char *mime) {
|
||||
if (strcmp(mime, "application/pdf") == 0 ||
|
||||
strcmp(mime, "text/plain") == 0)
|
||||
return "txt"; /* a document is stored as the text it was reduced to */
|
||||
if (strcmp(mime, "image/png") == 0)
|
||||
return "png";
|
||||
if (strcmp(mime, "image/jpeg") == 0)
|
||||
@@ -256,8 +260,13 @@ int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
||||
|
||||
/* --- storage ------------------------------------------------------------- */
|
||||
|
||||
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
|
||||
* workspace keeps the two together. */
|
||||
/* Where a session's payloads live, beside its JSONL so moving the workspace
|
||||
* keeps the two together:
|
||||
* <uuid>/session.jsonl -> <uuid>/attachments/ (the store's shape
|
||||
* since v0.9.5)
|
||||
* <id>.jsonl -> <dir>/attachments/<id>/ (older sessions; the
|
||||
* migration moves them)
|
||||
* The directory is made here: every store() writes into it next. */
|
||||
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
||||
if (!session_path || !session_path[0])
|
||||
return -1;
|
||||
@@ -285,7 +294,12 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
||||
char *dot = strstr(leaf, ".jsonl");
|
||||
if (dot)
|
||||
*dot = 0;
|
||||
/* A session that owns its directory keeps its payloads directly inside it;
|
||||
* only the flat layout needs the shared directory and its stem. */
|
||||
char rel[320];
|
||||
if (strcmp(leaf, "session") == 0)
|
||||
snprintf(rel, sizeof(rel), "attachments");
|
||||
else
|
||||
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
||||
char full[OS_PATH_MAX];
|
||||
if (os_path_join(full, sizeof(full), dir, rel) != 0)
|
||||
@@ -340,35 +354,91 @@ static char *read_whole(const char *path, size_t *out_len, size_t max) {
|
||||
}
|
||||
|
||||
/* FNV over the bytes, the name and the clock: short, stable, unique enough
|
||||
* inside one session. */
|
||||
static void make_id(const char *name, const char *data, size_t n, char *out,
|
||||
size_t out_sz) {
|
||||
* inside one session. The prefix says what the name is for: "img" for what a
|
||||
* model is shown, "art" for what a person is handed. */
|
||||
static void make_id(const char *prefix, const char *name, const char *data,
|
||||
size_t n, char *out, size_t out_sz) {
|
||||
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
|
||||
h = cst_fnv1a64(h, name, strlen(name));
|
||||
h = cst_fnv1a64(h, &n, sizeof(n));
|
||||
double now = os_now_ms();
|
||||
h = cst_fnv1a64(h, &now, sizeof(now));
|
||||
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
|
||||
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
|
||||
snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
|
||||
(unsigned)(h & 0xFFFFFFFFu));
|
||||
}
|
||||
|
||||
static cst_attachment_t *store(const char *session_path, const char *name,
|
||||
const char *data, size_t n, const char *origin,
|
||||
char *err, size_t err_sz) {
|
||||
if (!name || !name[0])
|
||||
name = "image";
|
||||
char mime[32];
|
||||
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
|
||||
snprintf(err, err_sz,
|
||||
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
|
||||
"checked by content, not by extension)");
|
||||
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
|
||||
static char *memdup_z(const char *data, size_t n) {
|
||||
char *copy = malloc(n + 1);
|
||||
if (!copy)
|
||||
return NULL;
|
||||
}
|
||||
memcpy(copy, data, n);
|
||||
copy[n] = 0;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* What a picked file is, plus the payload to store for it: an image stays as
|
||||
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
|
||||
* stored file holds text), anything else must be UTF-8 text. The caller frees
|
||||
* the result; *out_len is its length. */
|
||||
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
|
||||
int *pages, size_t *out_len, char *err, size_t err_sz) {
|
||||
*pages = 0;
|
||||
*out_len = n;
|
||||
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
|
||||
if (n > CST_ATTACH_MAX_BYTES) {
|
||||
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
||||
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
||||
return NULL;
|
||||
}
|
||||
char *copy = memdup_z(data, n);
|
||||
if (!copy)
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return copy;
|
||||
}
|
||||
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
|
||||
int pg = 0;
|
||||
char perr[256];
|
||||
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
|
||||
if (!text) {
|
||||
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
|
||||
return NULL;
|
||||
}
|
||||
snprintf(mime, mime_sz, "application/pdf");
|
||||
*pages = pg;
|
||||
*out_len = strlen(text);
|
||||
return text;
|
||||
}
|
||||
if (n > CST_ATTACH_TEXT_MAX) {
|
||||
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
|
||||
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
|
||||
return NULL;
|
||||
}
|
||||
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
|
||||
snprintf(err, err_sz,
|
||||
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
|
||||
"text; the content decides, not the extension");
|
||||
return NULL;
|
||||
}
|
||||
snprintf(mime, mime_sz, "text/plain");
|
||||
char *copy = memdup_z(data, n);
|
||||
if (!copy)
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return copy;
|
||||
}
|
||||
|
||||
/* Write one payload whose kind is already known. id_prefix names it ("img" /
|
||||
* "art"), for_user marks it as the person's rather than the model's, and
|
||||
* ext_override is the stored file's extension when the caller knows better
|
||||
* than the mime does. */
|
||||
static cst_attachment_t *store(const char *session_path, const char *name,
|
||||
const char *data, size_t n, const char *mime,
|
||||
int pages, const char *origin, int for_user,
|
||||
const char *id_prefix, const char *ext_override,
|
||||
char *err, size_t err_sz) {
|
||||
if (!name || !name[0])
|
||||
name = "image";
|
||||
char dir[OS_PATH_MAX];
|
||||
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
|
||||
snprintf(err, err_sz, "cannot create the attachment directory");
|
||||
@@ -379,13 +449,17 @@ static cst_attachment_t *store(const char *session_path, const char *name,
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return NULL;
|
||||
}
|
||||
make_id(name, data, n, a->id, sizeof(a->id));
|
||||
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
|
||||
make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
|
||||
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
|
||||
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
|
||||
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
|
||||
snprintf(a->mime, sizeof(a->mime), "%s", mime);
|
||||
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
|
||||
snprintf(a->detail, sizeof(a->detail), "auto");
|
||||
a->for_user = for_user ? 1 : 0;
|
||||
a->bytes = (long long)n;
|
||||
a->pages = pages;
|
||||
if (cst_attach_is_image(a))
|
||||
cst_attach_dimensions(data, n, &a->width, &a->height);
|
||||
|
||||
char full[OS_PATH_MAX];
|
||||
@@ -419,14 +493,27 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!session_path || !session_path[0]) {
|
||||
snprintf(err, err_sz, "no session to store the image in");
|
||||
snprintf(err, err_sz, "no session to store the file in");
|
||||
return NULL;
|
||||
}
|
||||
if (!data || !n) {
|
||||
snprintf(err, err_sz, "image is empty");
|
||||
snprintf(err, err_sz, "the file is empty");
|
||||
return NULL;
|
||||
}
|
||||
return store(session_path, name, data, n, "user", err, err_sz);
|
||||
char mime[32];
|
||||
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
|
||||
snprintf(err, err_sz,
|
||||
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
|
||||
"checked by content, not by extension)");
|
||||
return NULL;
|
||||
}
|
||||
if (n > CST_ATTACH_MAX_BYTES) {
|
||||
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
||||
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
||||
return NULL;
|
||||
}
|
||||
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
|
||||
err, err_sz);
|
||||
}
|
||||
|
||||
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||
@@ -449,11 +536,155 @@ cst_attachment_t *cst_attach_import_file(const char *session_path,
|
||||
for (const char *p = src_path; *p; p++)
|
||||
if (*p == '/' || *p == '\\')
|
||||
leaf = p + 1;
|
||||
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
|
||||
char mime[32];
|
||||
int pages = 0;
|
||||
size_t pn = 0;
|
||||
char *payload =
|
||||
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
|
||||
free(data);
|
||||
if (!payload)
|
||||
return NULL;
|
||||
cst_attachment_t *a =
|
||||
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
|
||||
NULL, err, err_sz);
|
||||
free(payload);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* The mime of a delivered file: its extension decides (the content is not
|
||||
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
|
||||
* the OS still opens by name. */
|
||||
static const char *mime_from_name(const char *name) {
|
||||
static const struct {
|
||||
const char *ext, *mime;
|
||||
} T[] = {
|
||||
{"md", "text/markdown"}, {"markdown", "text/markdown"},
|
||||
{"txt", "text/plain"}, {"log", "text/plain"},
|
||||
{"json", "application/json"}, {"csv", "text/csv"},
|
||||
{"html", "text/html"}, {"htm", "text/html"},
|
||||
{"xml", "application/xml"}, {"yaml", "application/yaml"},
|
||||
{"yml", "application/yaml"}, {"toml", "application/toml"},
|
||||
{"pdf", "application/pdf"}, {"zip", "application/zip"},
|
||||
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
|
||||
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
|
||||
{"py", "text/x-python"}, {"c", "text/x-c"},
|
||||
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
|
||||
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
|
||||
{"js", "text/javascript"}, {"ts", "text/typescript"},
|
||||
{"go", "text/x-go"}, {"rs", "text/x-rust"},
|
||||
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
|
||||
{"css", "text/css"}, {"sql", "text/x-sql"},
|
||||
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
|
||||
{"png", "image/png"}, {"jpg", "image/jpeg"},
|
||||
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
|
||||
{"webp", "image/webp"}, {"bmp", "image/bmp"},
|
||||
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
|
||||
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
|
||||
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
|
||||
{"mp4", "video/mp4"}, {"webm", "video/webm"},
|
||||
{"apk", "application/vnd.android.package-archive"},
|
||||
{"docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
{"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
||||
{"pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml."
|
||||
"presentation"},
|
||||
};
|
||||
const char *dot = name ? strrchr(name, '.') : NULL;
|
||||
if (!dot || !dot[1])
|
||||
return "application/octet-stream";
|
||||
const char *e = dot + 1;
|
||||
size_t el = strlen(e);
|
||||
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
|
||||
if (strlen(T[i].ext) != el)
|
||||
continue;
|
||||
size_t k = 0;
|
||||
for (; k < el; k++) {
|
||||
char c = e[k];
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = (char)(c - 'A' + 'a');
|
||||
if (c != T[i].ext[k])
|
||||
break;
|
||||
}
|
||||
if (k == el)
|
||||
return T[i].mime;
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
|
||||
* most 8 characters -- a stored name has to stay portable), else whatever the
|
||||
* mime maps to. */
|
||||
static void ext_for_name(const char *name, const char *mime, char *out,
|
||||
size_t out_sz) {
|
||||
size_t k = 0;
|
||||
const char *dot = name ? strrchr(name, '.') : NULL;
|
||||
if (dot && dot[1] && out_sz > 1) {
|
||||
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
|
||||
char c = *p;
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = (char)(c - 'A' + 'a');
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
|
||||
break;
|
||||
out[k++] = c;
|
||||
}
|
||||
}
|
||||
out[k] = 0;
|
||||
if (!k)
|
||||
snprintf(out, out_sz, "%s", mime_of_ext(mime));
|
||||
}
|
||||
|
||||
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
|
||||
const char *src_path,
|
||||
const char *title, char *err,
|
||||
size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!session_path || !session_path[0]) {
|
||||
snprintf(err, err_sz, "no session to store the file in");
|
||||
return NULL;
|
||||
}
|
||||
if (!src_path || !src_path[0]) {
|
||||
snprintf(err, err_sz, "no file to deliver");
|
||||
return NULL;
|
||||
}
|
||||
size_t n = 0;
|
||||
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
|
||||
if (!data) {
|
||||
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
||||
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
|
||||
return NULL;
|
||||
}
|
||||
const char *leaf = src_path;
|
||||
for (const char *p = src_path; *p; p++)
|
||||
if (*p == '/' || *p == '\\')
|
||||
leaf = p + 1;
|
||||
const char *name = (title && title[0]) ? title : leaf;
|
||||
char mime[64], ext[16];
|
||||
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
|
||||
ext_for_name(name, mime, ext, sizeof(ext));
|
||||
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
|
||||
"art", ext, err, err_sz);
|
||||
free(data);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* Images are opaque and travel as image parts; everything else is text the
|
||||
* store holds and the request inlines. */
|
||||
int cst_attach_is_image(const cst_attachment_t *a) {
|
||||
return a && strncmp(a->mime, "image/", 6) == 0;
|
||||
}
|
||||
|
||||
int cst_attach_is_text(const cst_attachment_t *a) {
|
||||
/* A delivered file is the person's: it is never inlined into a request. */
|
||||
return a && !a->for_user && !cst_attach_is_image(a);
|
||||
}
|
||||
|
||||
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||
size_t *out_len, char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
@@ -470,11 +701,12 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
|
||||
if (!data) {
|
||||
snprintf(err, err_sz,
|
||||
"%.200s is missing; the image was deleted from the session "
|
||||
"store",
|
||||
"%.200s is missing; it was deleted from the session store",
|
||||
a->name);
|
||||
return NULL;
|
||||
}
|
||||
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
|
||||
return data;
|
||||
char mime[32];
|
||||
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
|
||||
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
|
||||
@@ -486,6 +718,8 @@ char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
||||
}
|
||||
|
||||
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
|
||||
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
|
||||
return NULL;
|
||||
char err[256];
|
||||
size_t n = 0;
|
||||
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
|
||||
@@ -509,17 +743,29 @@ void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
||||
snprintf(out, out_sz, "(none)");
|
||||
return;
|
||||
}
|
||||
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
|
||||
const char *kind = a->mime;
|
||||
char dim[32] = "";
|
||||
if (cst_attach_is_image(a)) {
|
||||
if (strncmp(a->mime, "image/", 6) == 0)
|
||||
kind = a->mime + 6;
|
||||
if (a->width > 0 && a->height > 0)
|
||||
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
|
||||
} else if (strcmp(a->mime, "application/pdf") == 0) {
|
||||
kind = "pdf";
|
||||
if (a->pages > 0)
|
||||
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
|
||||
a->pages == 1 ? "" : "s");
|
||||
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
|
||||
kind = "text";
|
||||
}
|
||||
out[0] = 0;
|
||||
if (a->bytes >= 1024 * 1024)
|
||||
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
||||
(double)a->bytes / (1024 * 1024));
|
||||
else if (a->bytes >= 1024)
|
||||
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
|
||||
else
|
||||
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
|
||||
(double)a->bytes / 1024);
|
||||
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
|
||||
}
|
||||
|
||||
/* --- lifetime ------------------------------------------------------------ */
|
||||
@@ -580,10 +826,14 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
|
||||
cJSON_AddNumberToObject(o, "width", a->width);
|
||||
cJSON_AddNumberToObject(o, "height", a->height);
|
||||
}
|
||||
if (a->pages > 0)
|
||||
cJSON_AddNumberToObject(o, "pages", a->pages);
|
||||
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
|
||||
cJSON_AddStringToObject(o, "detail", a->detail);
|
||||
if (a->origin[0])
|
||||
cJSON_AddStringToObject(o, "origin", a->origin);
|
||||
if (a->for_user)
|
||||
cJSON_AddNumberToObject(o, "for_user", 1);
|
||||
cJSON_AddItemToArray(arr, o);
|
||||
}
|
||||
char *s = cJSON_PrintUnformatted(arr);
|
||||
@@ -628,12 +878,16 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out)
|
||||
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "height");
|
||||
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
|
||||
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
|
||||
snprintf(a->detail, sizeof(a->detail), "%s",
|
||||
cJSON_IsString(v) ? v->valuestring : "auto");
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
|
||||
snprintf(a->origin, sizeof(a->origin), "%s",
|
||||
cJSON_IsString(v) ? v->valuestring : "");
|
||||
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
|
||||
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
|
||||
k++;
|
||||
}
|
||||
if (k == 0) {
|
||||
|
||||
@@ -18,6 +18,51 @@ static void set_err(char *err, size_t err_sz, const char *msg) {
|
||||
snprintf(err, err_sz, "%s", msg);
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
/* PATH lookup without shelling out: cheap enough for a capability probe. */
|
||||
static int on_path(const char *name) {
|
||||
const char *path = getenv("PATH");
|
||||
if (!path)
|
||||
return 0;
|
||||
for (const char *p = path;;) {
|
||||
const char *colon = strchr(p, ':');
|
||||
size_t len = colon ? (size_t)(colon - p) : strlen(p);
|
||||
char cand[4096];
|
||||
if (len + strlen(name) + 2 < sizeof(cand)) {
|
||||
if (len == 0)
|
||||
snprintf(cand, sizeof(cand), "./%s", name);
|
||||
else
|
||||
snprintf(cand, sizeof(cand), "%.*s/%s", (int)len, p, name);
|
||||
if (access(cand, X_OK) == 0)
|
||||
return 1;
|
||||
}
|
||||
if (!colon)
|
||||
break;
|
||||
p = colon + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int cst_clipboard_available(char *why, size_t why_sz) {
|
||||
if (why && why_sz)
|
||||
why[0] = 0;
|
||||
#ifdef _WIN32
|
||||
return 0; /* the Win32 clipboard API is always there */
|
||||
#elif defined(__APPLE__)
|
||||
return 0; /* pbpaste / pbcopy ship with the OS */
|
||||
#else
|
||||
static const char *const helpers[] = {"wl-paste", "wl-copy", "xclip", NULL};
|
||||
for (int i = 0; helpers[i]; i++)
|
||||
if (on_path(helpers[i]))
|
||||
return 0;
|
||||
if (why && why_sz)
|
||||
snprintf(why, why_sz,
|
||||
"no clipboard helper on PATH (install wl-clipboard or xclip)");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
static void put_le32(unsigned char *p, unsigned long v) {
|
||||
p[0] = (unsigned char)(v & 0xff);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xff);
|
||||
@@ -390,6 +435,175 @@ int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
|
||||
|
||||
#endif
|
||||
|
||||
/* --- text clipboard ------------------------------------------------------ */
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
|
||||
*data = NULL;
|
||||
*n = 0;
|
||||
if (!IsClipboardFormatAvailable(CF_UNICODETEXT) || !OpenClipboard(NULL)) {
|
||||
set_err(err, err_sz, "no text on the clipboard");
|
||||
return -1;
|
||||
}
|
||||
HANDLE h = GetClipboardData(CF_UNICODETEXT);
|
||||
const wchar_t *w = h ? GlobalLock(h) : NULL;
|
||||
if (!w) {
|
||||
if (h)
|
||||
GlobalUnlock(h);
|
||||
CloseClipboard();
|
||||
set_err(err, err_sz, "no text on the clipboard");
|
||||
return -1;
|
||||
}
|
||||
char *utf8 = os_wide_to_utf8(w);
|
||||
size_t len = utf8 ? strlen(utf8) : 0;
|
||||
GlobalUnlock(h);
|
||||
CloseClipboard();
|
||||
if (!utf8 || !len) {
|
||||
free(utf8);
|
||||
set_err(err, err_sz, "no text on the clipboard");
|
||||
return -1;
|
||||
}
|
||||
if (len > CST_CLIPBOARD_TEXT_MAX) {
|
||||
free(utf8);
|
||||
set_err(err, err_sz, "clipboard text is too large");
|
||||
return -1;
|
||||
}
|
||||
*data = utf8;
|
||||
*n = len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_clipboard_set_text(const char *text, size_t n, char *err,
|
||||
size_t err_sz) {
|
||||
if (!text || os_clipboard_set(text, n) != 0) {
|
||||
set_err(err, err_sz, "could not put text on the clipboard");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
/* stdout of a clipboard reader, capped and NUL-terminated. */
|
||||
static int clip_read(const char *cmd, const char *tool, char **data, size_t *n,
|
||||
char *err, size_t err_sz) {
|
||||
os_run_result_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
if (os_run_shell_c(NULL, cmd, CST_CLIPBOARD_TEXT_MAX + 1, &r) != 0) {
|
||||
os_run_result_free(&r);
|
||||
set_err(err, err_sz, "could not read the clipboard");
|
||||
return -1;
|
||||
}
|
||||
if (r.exit_code != 0 || r.out_len == 0) {
|
||||
int missing = r.exit_code == 127;
|
||||
os_run_result_free(&r);
|
||||
if (missing) {
|
||||
char msg[96];
|
||||
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
|
||||
set_err(err, err_sz, msg);
|
||||
} else {
|
||||
set_err(err, err_sz, "no text on the clipboard");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (r.out_len > CST_CLIPBOARD_TEXT_MAX) {
|
||||
os_run_result_free(&r);
|
||||
set_err(err, err_sz, "clipboard text is too large");
|
||||
return -1;
|
||||
}
|
||||
char *copy = malloc(r.out_len + 1);
|
||||
if (!copy) {
|
||||
os_run_result_free(&r);
|
||||
set_err(err, err_sz, "out of memory");
|
||||
return -1;
|
||||
}
|
||||
memcpy(copy, r.out, r.out_len);
|
||||
copy[r.out_len] = 0;
|
||||
os_run_result_free(&r);
|
||||
*data = copy;
|
||||
*n = r.out_len;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_clipboard_text(char **data, size_t *n, char *err, size_t err_sz) {
|
||||
*data = NULL;
|
||||
*n = 0;
|
||||
#ifdef __APPLE__
|
||||
return clip_read("pbpaste 2>/dev/null", "pbpaste", data, n, err, err_sz);
|
||||
#else
|
||||
const char *wl = getenv("WAYLAND_DISPLAY");
|
||||
if (wl && *wl)
|
||||
return clip_read("wl-paste --type text/plain --no-newline 2>/dev/null",
|
||||
"wl-clipboard (wl-paste)", data, n, err, err_sz);
|
||||
const char *x = getenv("DISPLAY");
|
||||
if (x && *x)
|
||||
return clip_read("xclip -selection clipboard -o 2>/dev/null", "xclip",
|
||||
data, n, err, err_sz);
|
||||
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
int cst_clipboard_set_text(const char *text, size_t n, char *err,
|
||||
size_t err_sz) {
|
||||
if (!text)
|
||||
return -1;
|
||||
if (n > CST_CLIPBOARD_TEXT_MAX) {
|
||||
set_err(err, err_sz, "text is too large for the clipboard");
|
||||
return -1;
|
||||
}
|
||||
const char *fmt = NULL, *tool = NULL;
|
||||
#ifdef __APPLE__
|
||||
fmt = "pbcopy < '%s'";
|
||||
tool = "pbcopy";
|
||||
#else
|
||||
if (getenv("WAYLAND_DISPLAY")) {
|
||||
fmt = "wl-copy < '%s'";
|
||||
tool = "wl-clipboard (wl-copy)";
|
||||
} else if (getenv("DISPLAY")) {
|
||||
fmt = "xclip -selection clipboard -i < '%s'";
|
||||
tool = "xclip";
|
||||
}
|
||||
#endif
|
||||
if (!fmt) {
|
||||
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
|
||||
return -1;
|
||||
}
|
||||
char tmp[OS_PATH_MAX];
|
||||
int fd = os_mkstemp(tmp, sizeof(tmp), "cst-clip");
|
||||
if (fd < 0) {
|
||||
set_err(err, err_sz, "could not create a temporary file");
|
||||
return -1;
|
||||
}
|
||||
FILE *fp = fdopen(fd, "wb");
|
||||
int ok = fp && fwrite(text, 1, n, fp) == n && fclose(fp) == 0;
|
||||
if (!ok) {
|
||||
if (fp)
|
||||
fclose(fp);
|
||||
remove(tmp);
|
||||
set_err(err, err_sz, "could not write the text");
|
||||
return -1;
|
||||
}
|
||||
char cmd[OS_PATH_MAX + 64];
|
||||
snprintf(cmd, sizeof(cmd), fmt, tmp);
|
||||
os_run_result_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
int rc = os_run_shell_c(NULL, cmd, 4096, &r);
|
||||
int code = r.exit_code;
|
||||
os_run_result_free(&r);
|
||||
remove(tmp);
|
||||
if (rc != 0 || code != 0) {
|
||||
char msg[96];
|
||||
snprintf(msg, sizeof(msg), "writing the clipboard needs %s", tool);
|
||||
set_err(err, err_sz, msg);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
static int hexval(int c) {
|
||||
if (c >= '0' && c <= '9')
|
||||
return c - '0';
|
||||
|
||||
@@ -22,43 +22,75 @@
|
||||
#define ERRBODY_MAX 4096
|
||||
#define LLM_MAX_RETRIES 3
|
||||
|
||||
/* Point libcurl at a CA bundle on Windows.
|
||||
static int ca_file_ok(const char *path) {
|
||||
if (!path || !path[0])
|
||||
return 0;
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f)
|
||||
return 0;
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Point libcurl at a CA bundle.
|
||||
*
|
||||
* An OpenSSL-based libcurl keeps the path it was built with -- MSYS2's is
|
||||
* "/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the
|
||||
* binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE.
|
||||
* Every HTTPS request then dies with CURLE_SSL_CACERT_BADFILE (77).
|
||||
* libcurl keeps the bundle path it was built with. On Windows an OpenSSL
|
||||
* build carries MSYS2's "/mingw64/etc/ssl/certs/ca-bundle.crt"; the static
|
||||
* Linux release is built on one distro and runs on all of them, whose bundles
|
||||
* live in different places. A path that does not exist fails every HTTPS
|
||||
* request with CURLE_SSL_CACERT_BADFILE (77).
|
||||
*
|
||||
* Order: $CST_CA_BUNDLE, then ca-bundle.crt beside the executable. If neither
|
||||
* exists nothing is set, so a libcurl linked against Schannel (the Windows
|
||||
* certificate store) behaves exactly as before. */
|
||||
static void llm_apply_ca_bundle(CURL *curl) {
|
||||
#if defined(_WIN32)
|
||||
* Order: $CST_CA_BUNDLE; on Windows ca-bundle.crt beside the executable (if
|
||||
* absent nothing is set, so Schannel uses the certificate store); elsewhere
|
||||
* $SSL_CERT_FILE, libcurl's own default if that file exists, then the usual
|
||||
* distro locations. Decided once per process. */
|
||||
void cst_curl_apply_ca(void *curl) {
|
||||
static char ca[OS_PATH_MAX];
|
||||
static int checked;
|
||||
if (!checked) {
|
||||
checked = 1;
|
||||
static atomic_int state; /* 0 unchecked, 1 checking, 2 done */
|
||||
int expect = 0;
|
||||
if (atomic_compare_exchange_strong(&state, &expect, 1)) {
|
||||
const char *env = getenv("CST_CA_BUNDLE");
|
||||
if (env && env[0]) {
|
||||
if (ca_file_ok(env)) {
|
||||
snprintf(ca, sizeof(ca), "%s", env);
|
||||
} else {
|
||||
char dir[OS_PATH_MAX];
|
||||
if (os_exe_dir(dir, sizeof(dir)) == 0)
|
||||
os_path_join(ca, sizeof(ca), dir, "ca-bundle.crt");
|
||||
}
|
||||
if (ca[0]) {
|
||||
FILE *f = fopen(ca, "rb");
|
||||
if (f)
|
||||
fclose(f);
|
||||
#if defined(_WIN32)
|
||||
char dir[OS_PATH_MAX], p[OS_PATH_MAX];
|
||||
if (os_exe_dir(dir, sizeof(dir)) == 0 &&
|
||||
os_path_join(p, sizeof(p), dir, "ca-bundle.crt") == 0 &&
|
||||
ca_file_ok(p))
|
||||
snprintf(ca, sizeof(ca), "%s", p);
|
||||
#else
|
||||
static const char *const known[] = {
|
||||
"/etc/ssl/certs/ca-certificates.crt", /* Debian, Ubuntu, Arch, Alpine */
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", /* Fedora, RHEL */
|
||||
"/etc/ssl/cert.pem", /* macOS, BSD, Alpine */
|
||||
"/etc/ssl/ca-bundle.pem", /* openSUSE */
|
||||
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
||||
};
|
||||
const char *pick = NULL;
|
||||
const char *sslenv = getenv("SSL_CERT_FILE");
|
||||
const curl_version_info_data *vi = curl_version_info(CURLVERSION_NOW);
|
||||
if (ca_file_ok(sslenv))
|
||||
pick = sslenv;
|
||||
else if (vi && ca_file_ok(vi->cainfo))
|
||||
pick = NULL; /* libcurl's built-in default already works */
|
||||
else
|
||||
ca[0] = '\0';
|
||||
for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++)
|
||||
if (ca_file_ok(known[i])) {
|
||||
pick = known[i];
|
||||
break;
|
||||
}
|
||||
if (pick)
|
||||
snprintf(ca, sizeof(ca), "%s", pick);
|
||||
#endif
|
||||
}
|
||||
atomic_store(&state, 2);
|
||||
} else {
|
||||
while (atomic_load(&state) != 2)
|
||||
;
|
||||
}
|
||||
if (ca[0])
|
||||
curl_easy_setopt(curl, CURLOPT_CAINFO, ca);
|
||||
#else
|
||||
(void)curl;
|
||||
#endif
|
||||
curl_easy_setopt((CURL *)curl, CURLOPT_CAINFO, ca);
|
||||
}
|
||||
|
||||
#include "cst_attach.h"
|
||||
@@ -223,37 +255,99 @@ static cJSON *image_part(const cst_llm_request_t *req,
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Append every attachment of m as image parts (or a note saying why not). */
|
||||
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
|
||||
const cst_message_t *m, size_t *budget) {
|
||||
/* One stored document as a text part: its text, framed so the model knows
|
||||
* where it came from. Documents are plain text on the wire, so a text-only
|
||||
* model and CST_NO_IMAGES both still get them. */
|
||||
static cJSON *document_part(const cst_llm_request_t *req,
|
||||
const cst_attachment_t *a, size_t *budget,
|
||||
char *why, size_t why_sz) {
|
||||
why[0] = 0;
|
||||
if (!req->session_path || !req->session_path[0]) {
|
||||
snprintf(why, why_sz, "there is no session store to read it from");
|
||||
return NULL;
|
||||
}
|
||||
char err[256];
|
||||
size_t n = 0;
|
||||
char *text = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
|
||||
if (!text) {
|
||||
snprintf(why, why_sz, "%s", err);
|
||||
return NULL;
|
||||
}
|
||||
int cut = 0;
|
||||
if (n > *budget) {
|
||||
n = *budget;
|
||||
cut = 1;
|
||||
}
|
||||
*budget -= n;
|
||||
char desc[128];
|
||||
cst_attach_describe(a, desc, sizeof(desc));
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_printf(&b, "[document \"%.120s\" (%s)%s]\n", a->name, desc,
|
||||
cut ? " -- only the start is included" : "");
|
||||
cst_buf_append(&b, text, n);
|
||||
cst_buf_puts(&b, cut ? "\n[-- document truncated --]"
|
||||
: "\n[-- end of document --]");
|
||||
free(text);
|
||||
cJSON *p = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p, "type", "text");
|
||||
cJSON_AddStringToObject(p, "text", b.buf ? b.buf : "");
|
||||
cst_buf_free(&b);
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Append every attachment of m: images as image parts (or a note saying why
|
||||
* not), documents inline as text. Returns how many parts were added: a
|
||||
* delivered file (for_user) adds none. */
|
||||
static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
||||
const cst_message_t *m, size_t *budget,
|
||||
size_t *doc_budget, const char *img_off) {
|
||||
int added = 0;
|
||||
int n = m->nattach;
|
||||
if (n > CST_ATTACH_MAX_COUNT) {
|
||||
char note[160];
|
||||
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
|
||||
n);
|
||||
snprintf(note, sizeof(note),
|
||||
"[only the first %d of %d attachments are sent]",
|
||||
CST_ATTACH_MAX_COUNT, n);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
added++;
|
||||
n = CST_ATTACH_MAX_COUNT;
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cst_attachment_t *a = &m->attach[i];
|
||||
/* A delivered file belongs to the person, not to the model: no part, and
|
||||
* not even a "not sent" note -- it was never meant to be in the request. */
|
||||
if (a->for_user)
|
||||
continue;
|
||||
int is_img = cst_attach_is_image(a);
|
||||
char why[256];
|
||||
cJSON *p = image_part(req, a, budget, why, sizeof(why));
|
||||
cJSON *p = NULL;
|
||||
if (is_img && img_off)
|
||||
snprintf(why, sizeof(why), "%s", img_off);
|
||||
else if (is_img)
|
||||
p = image_part(req, a, budget, why, sizeof(why));
|
||||
else
|
||||
p = document_part(req, a, doc_budget, why, sizeof(why));
|
||||
if (p) {
|
||||
cJSON_AddItemToArray(parts, p);
|
||||
added++;
|
||||
continue;
|
||||
}
|
||||
char note[512];
|
||||
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
|
||||
a->name, why);
|
||||
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
|
||||
is_img ? "image" : "document", a->name, why);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/* Images a tool result produced are deferred: an assistant tool_call must be
|
||||
* answered by tool messages only, so they ride in one extra user message
|
||||
* after the run of tool messages ends. *pending collects it. */
|
||||
static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
|
||||
cJSON **pending, size_t *budget) {
|
||||
static void defer_attachments(const cst_llm_request_t *req,
|
||||
const cst_message_t *m, cJSON **pending,
|
||||
size_t *budget, size_t *doc_budget,
|
||||
const char *img_off) {
|
||||
cJSON *p = *pending;
|
||||
if (!p) {
|
||||
p = cJSON_CreateObject();
|
||||
@@ -261,15 +355,24 @@ static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
|
||||
cJSON_AddArrayToObject(p, "content");
|
||||
*pending = p;
|
||||
}
|
||||
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
|
||||
budget);
|
||||
cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
|
||||
parts_add_attachments(req, content, m, budget, doc_budget, img_off);
|
||||
if (cJSON_GetArraySize(content) == 0) {
|
||||
/* Nothing to carry (a tool result that only delivered a file): an empty
|
||||
* user message is noise, and some providers refuse it. */
|
||||
cJSON_Delete(p);
|
||||
*pending = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* m's text plus one line per image it would have carried (malloc'd). */
|
||||
/* m's text plus one line per image it would have carried (malloc'd); a
|
||||
* document is not affected (it is text and is sent as such). */
|
||||
static char *image_notes(const cst_message_t *m, const char *why) {
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, m->content ? m->content : "");
|
||||
for (int i = 0; i < m->nattach; i++) {
|
||||
if (!cst_attach_is_image(&m->attach[i]))
|
||||
continue;
|
||||
char d[128];
|
||||
cst_attach_describe(&m->attach[i], d, sizeof(d));
|
||||
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
|
||||
@@ -357,11 +460,21 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
|
||||
cJSON_AddTrueToObject(so, "include_usage");
|
||||
}
|
||||
/* The generation parameters the user set (Settings -> Advanced). A field
|
||||
* that was not set is left out: an absent field is the provider's own
|
||||
* default, which "temperature": 0 would not be. */
|
||||
if (req->temperature > 0)
|
||||
cJSON_AddNumberToObject(root, "temperature", req->temperature);
|
||||
if (req->top_p > 0)
|
||||
cJSON_AddNumberToObject(root, "top_p", req->top_p);
|
||||
if (req->max_tokens > 0)
|
||||
cJSON_AddNumberToObject(root, "max_tokens", req->max_tokens);
|
||||
cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
|
||||
size_t tcap = wire_tool_cap(req);
|
||||
cJSON *prev_user = NULL; /* merge consecutive user messages */
|
||||
cJSON *pending = NULL; /* images from tool results, waiting for a break */
|
||||
size_t img_budget = CST_ATTACH_MAX_TOTAL;
|
||||
size_t doc_budget = CST_ATTACH_MAX_TOTAL;
|
||||
const char *noimg = getenv("CST_NO_IMAGES");
|
||||
const char *img_off =
|
||||
noimg && noimg[0] && noimg[0] != '0'
|
||||
@@ -375,15 +488,26 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
cst_role_t role = m->role;
|
||||
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
|
||||
role = CST_ROLE_USER;
|
||||
int has_img = m->attach && m->nattach > 0;
|
||||
int n_att = m->attach ? m->nattach : 0;
|
||||
int has_img = 0, has_doc = 0;
|
||||
for (int k = 0; k < n_att; k++) {
|
||||
if (cst_attach_is_image(&m->attach[k]))
|
||||
has_img = 1;
|
||||
else
|
||||
has_doc = 1;
|
||||
}
|
||||
char *noted = NULL;
|
||||
if (has_img && img_off) {
|
||||
has_img = 0;
|
||||
if (!has_doc) {
|
||||
/* plain text: text-only APIs may refuse content arrays */
|
||||
noted = image_notes(m, img_off);
|
||||
if (noted)
|
||||
mm.content = noted;
|
||||
has_img = 0;
|
||||
}
|
||||
}
|
||||
/* a document needs a content array even when no image can go out */
|
||||
int att_parts = has_img || has_doc;
|
||||
|
||||
/* Every tool_call must be answered by tool messages, so tool-produced
|
||||
* images only leave the queue when the run of tool results is over. */
|
||||
@@ -399,7 +523,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
cJSON *pc = prev_user
|
||||
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
|
||||
: NULL;
|
||||
if (!has_img && !cJSON_IsArray(pc)) {
|
||||
if (!att_parts && !cJSON_IsArray(pc)) {
|
||||
cJSON *c = clean_string(m->content, 0);
|
||||
if (prev_user) {
|
||||
cst_buf_t b = {0};
|
||||
@@ -431,8 +555,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
}
|
||||
cJSON *parts = content_parts(o);
|
||||
parts_add_text(parts, clean_string(m->content, 0));
|
||||
if (has_img)
|
||||
parts_add_images(req, parts, m, &img_budget);
|
||||
if (att_parts)
|
||||
parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
|
||||
free(noted);
|
||||
continue;
|
||||
}
|
||||
@@ -466,8 +590,8 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
cJSON_AddTrueToObject(o, "prefix");
|
||||
}
|
||||
cJSON_AddItemToArray(msgs, o);
|
||||
if (role == CST_ROLE_TOOL && has_img)
|
||||
defer_images(req, m, &pending, &img_budget);
|
||||
if (role == CST_ROLE_TOOL && n_att)
|
||||
defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
|
||||
free(noted);
|
||||
}
|
||||
if (pending)
|
||||
@@ -988,7 +1112,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err,
|
||||
ret = -1;
|
||||
break;
|
||||
}
|
||||
llm_apply_ca_bundle(curl);
|
||||
cst_curl_apply_ca(curl);
|
||||
sse_reset(&s);
|
||||
s.curl = curl;
|
||||
if (rec_dir && rec_dir[0]) {
|
||||
@@ -1233,7 +1357,7 @@ int cst_llm_list_models_meta(const char *api_base, const char *api_key,
|
||||
snprintf(err, err_sz, "curl init failed");
|
||||
return -1;
|
||||
}
|
||||
llm_apply_ca_bundle(curl);
|
||||
cst_curl_apply_ca(curl);
|
||||
char url[1024];
|
||||
size_t bl = strlen(api_base);
|
||||
while (bl && api_base[bl - 1] == '/')
|
||||
|
||||
@@ -26,11 +26,15 @@ static void usage(const char *argv0) {
|
||||
" (last used)\n"
|
||||
" -m, --model NAME model (last used for the provider)\n"
|
||||
" -e, --effort LEVEL low | medium | high | max (last used)\n"
|
||||
" -M, --mode NAME coding (default, code framed) | general\n"
|
||||
" (documents, data, research; last used)\n"
|
||||
" -w, --workspace DIR project directory (default: current)\n"
|
||||
" -c, --continue reopen the last session (default)\n"
|
||||
" -N, --new start a new session\n"
|
||||
" -R, --resume ID open a session by id or unique prefix\n"
|
||||
" -L, --list-sessions list sessions and exit\n"
|
||||
" --migrate-store move sessions written by an older version\n"
|
||||
" into one directory per session, and exit\n"
|
||||
" --list-models [P] what is known about each provider's models\n"
|
||||
" (built-in, <config>/models.json, GET /models)\n"
|
||||
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
|
||||
@@ -59,13 +63,13 @@ static void usage(const char *argv0) {
|
||||
"API keys: the TUI asks for a missing key and saves it per provider\n"
|
||||
" in the config dir (credentials, mode 0600); /key changes it.\n"
|
||||
" Fallbacks: <provider env> (DEEPSEEK_API_KEY, ...), CST_API_KEY.\n"
|
||||
"Settings: provider, models and effort are remembered in\n"
|
||||
"Settings: provider, models, effort and mode are remembered in\n"
|
||||
" settings.json next to the credentials.\n"
|
||||
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_PROTOCOL CST_TOOLS\n"
|
||||
" CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n"
|
||||
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n"
|
||||
" CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n"
|
||||
" CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
|
||||
"Env: CST_PROVIDER CST_MODEL CST_EFFORT CST_MODE CST_PROTOCOL\n"
|
||||
" CST_TOOLS CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP\n"
|
||||
" CST_CTX_WINDOW CST_CTX_TOKENS CST_AUTO_COMPACT=0\n"
|
||||
" CST_SHELL_TIMEOUT CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE\n"
|
||||
" CST_MODELS CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
|
||||
" CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
|
||||
"TUI: type /help inside for commands and keys\n",
|
||||
argv0);
|
||||
@@ -173,6 +177,25 @@ static int list_sessions(const char *ws) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Move a store written by an older version into one directory per session
|
||||
* (--migrate-store). Idempotent: what is already a directory is left alone. */
|
||||
static int migrate_store(const char *ws) {
|
||||
int moved = 0, failed = 0;
|
||||
char err[256] = "";
|
||||
if (cst_agent_store_migrate(ws, &moved, &failed, err, sizeof(err)) != 0) {
|
||||
fprintf(stderr, "cannot migrate %s/.agent/sessions: %s\n", ws,
|
||||
err[0] ? err : "unknown error");
|
||||
return 1;
|
||||
}
|
||||
printf("migrated %d session(s) into %s/.agent/sessions/<id>/\n", moved, ws);
|
||||
if (failed)
|
||||
printf("%d session(s) could not be moved; their files are untouched\n",
|
||||
failed);
|
||||
if (moved == 0)
|
||||
printf("nothing to move (already one directory per session)\n");
|
||||
return failed ? 1 : 0;
|
||||
}
|
||||
|
||||
/* Exact id or unique prefix → id. */
|
||||
static int resolve_session_id(const char *ws, const char *want, char *out,
|
||||
size_t out_sz) {
|
||||
@@ -336,7 +359,7 @@ int main(int argc, char **argv) {
|
||||
int force_ask_key = 0;
|
||||
int session_explicit = 0;
|
||||
const char *prompt = NULL;
|
||||
int new_session = 0, do_list = 0;
|
||||
int new_session = 0, do_list = 0, do_migrate = 0;
|
||||
const char *resume_id = NULL;
|
||||
/* WebUI (--web): a second front end on the same engine. */
|
||||
int web_mode = 0;
|
||||
@@ -370,10 +393,12 @@ int main(int argc, char **argv) {
|
||||
{"continue", no_argument, 0, 'c'},
|
||||
{"resume", required_argument, 0, 'R'},
|
||||
{"list-sessions", no_argument, 0, 'L'},
|
||||
{"migrate-store", no_argument, 0, 0x107},
|
||||
{"yolo", no_argument, 0, 'y'},
|
||||
{"approval", required_argument, 0, 'A'},
|
||||
{"confirm", required_argument, 0, 'C'},
|
||||
{"effort", required_argument, 0, 'e'},
|
||||
{"mode", required_argument, 0, 'M'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{"version", no_argument, 0, 'V'},
|
||||
{"list-models", optional_argument, 0, 0x100},
|
||||
@@ -385,8 +410,8 @@ int main(int argc, char **argv) {
|
||||
{"web-open", no_argument, 0, 0x106},
|
||||
{0, 0, 0, 0}};
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
|
||||
-1) {
|
||||
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:",
|
||||
opts, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'w':
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
|
||||
@@ -478,6 +503,14 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
snprintf(cfg.effort, sizeof(cfg.effort), "%s", optarg);
|
||||
break;
|
||||
case 'M':
|
||||
if (!cst_mode_valid(optarg)) {
|
||||
fprintf(stderr, "bad --mode %s (coding | general)\n", optarg);
|
||||
curl_global_cleanup();
|
||||
return 1;
|
||||
}
|
||||
snprintf(cfg.mode, sizeof(cfg.mode), "%s", optarg);
|
||||
break;
|
||||
case 'h':
|
||||
usage(argv[0]);
|
||||
curl_global_cleanup();
|
||||
@@ -517,6 +550,9 @@ int main(int argc, char **argv) {
|
||||
web.open_browser = 1;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x107:
|
||||
do_migrate = 1;
|
||||
break;
|
||||
case 'V':
|
||||
printf("cst-agent %s\n", CST_VERSION);
|
||||
curl_global_cleanup();
|
||||
@@ -528,6 +564,12 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
}
|
||||
|
||||
if (do_migrate) {
|
||||
int rc = migrate_store(cfg.workspace_root);
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (do_list) {
|
||||
int rc = list_sessions(cfg.workspace_root);
|
||||
curl_global_cleanup();
|
||||
@@ -557,6 +599,12 @@ int main(int argc, char **argv) {
|
||||
snprintf(cfg.effort, sizeof(cfg.effort), "%s",
|
||||
cst_effort_valid(e) ? e : CST_DEFAULT_EFFORT);
|
||||
}
|
||||
if (!cfg.mode[0]) {
|
||||
const char *m = getenv("CST_MODE");
|
||||
if (!cst_mode_valid(m))
|
||||
m = saved.mode;
|
||||
snprintf(cfg.mode, sizeof(cfg.mode), "%s", cst_mode_name(m));
|
||||
}
|
||||
if (!cfg.api_base[0]) {
|
||||
const char *env_base = getenv("CST_API_BASE");
|
||||
if (!env_base || !env_base[0])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "cst_metrics.h"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -31,18 +32,11 @@ cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path)
|
||||
os_mkdir(agent, 0755);
|
||||
os_path_join(dir, sizeof(dir), agent, "metrics");
|
||||
os_mkdir(dir, 0755);
|
||||
const char *base = session_path ? session_path : "session.jsonl";
|
||||
const char *s1 = strrchr(base, '/');
|
||||
const char *s2 = strrchr(base, '\\');
|
||||
if (s2 && (!s1 || s2 > s1))
|
||||
s1 = s2;
|
||||
if (s1)
|
||||
base = s1 + 1;
|
||||
/* One file per session: the id of the transcript's path (<id>.jsonl, or
|
||||
* <id>/session.jsonl in the store's own layout). */
|
||||
char leaf[256];
|
||||
snprintf(leaf, sizeof(leaf), "%s", base);
|
||||
size_t n = strlen(leaf);
|
||||
if (n > 6 && strcmp(leaf + n - 6, ".jsonl") == 0)
|
||||
leaf[n - 6] = 0;
|
||||
if (cst_agent_store_id_of_path(session_path, leaf, sizeof(leaf)) != 0)
|
||||
snprintf(leaf, sizeof(leaf), "session");
|
||||
char file[300];
|
||||
snprintf(file, sizeof(file), "%s.jsonl", leaf);
|
||||
os_path_join(m->path, sizeof(m->path), dir, file);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#include "cst_models.h"
|
||||
#include "cst_util.h"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -326,6 +328,162 @@ int cst_models_user_path(char *out, size_t out_sz) {
|
||||
return w > 0 && (size_t)w < out_sz ? 0 : -1;
|
||||
}
|
||||
|
||||
/* --- the user file: providers the presets do not know -------------------- */
|
||||
|
||||
static int valid_provider_name(const char *name) {
|
||||
if (!name || !name[0] || strlen(name) >= 32)
|
||||
return 0;
|
||||
for (const char *q = name; *q; q++)
|
||||
if (!isalnum((unsigned char)*q) && *q != '.' && *q != '_' && *q != '-')
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The user file as a JSON object: an empty one when there is no file, NULL
|
||||
* when there is one that is not an object (never overwrite somebody else's
|
||||
* file, their edit is not worth losing). */
|
||||
static cJSON *read_user_doc(char *path, size_t path_sz) {
|
||||
if (cst_models_user_path(path, path_sz) != 0)
|
||||
return NULL;
|
||||
char *text = read_file(path);
|
||||
if (!text)
|
||||
return cJSON_CreateObject();
|
||||
cJSON *doc = cJSON_Parse(text + cst_bom_len(text, strlen(text)));
|
||||
free(text);
|
||||
if (!cJSON_IsObject(doc)) {
|
||||
cJSON_Delete(doc);
|
||||
return NULL;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
static int write_user_doc(cJSON *doc, const char *path, char *err,
|
||||
size_t err_sz) {
|
||||
char *text = cJSON_Print(doc);
|
||||
if (!text) {
|
||||
snprintf(err, err_sz, "out of memory");
|
||||
return -1;
|
||||
}
|
||||
char tmp[1200];
|
||||
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) {
|
||||
cJSON_free(text);
|
||||
snprintf(err, err_sz, "path too long");
|
||||
return -1;
|
||||
}
|
||||
int rc = -1;
|
||||
FILE *f = fopen(tmp, "wb");
|
||||
if (!f) {
|
||||
snprintf(err, err_sz, "%s: cannot write (%s)", tmp, strerror(errno));
|
||||
} else {
|
||||
const int ok = fputs(text, f) >= 0 && fputc('\n', f) != EOF;
|
||||
const int closed = fclose(f);
|
||||
if (ok && closed == 0 &&
|
||||
#ifdef _WIN32
|
||||
(remove(path), 1) &&
|
||||
#endif
|
||||
rename(tmp, path) == 0)
|
||||
rc = 0;
|
||||
else
|
||||
snprintf(err, err_sz, "%s: cannot replace (%s)", path, strerror(errno));
|
||||
if (rc != 0)
|
||||
remove(tmp);
|
||||
}
|
||||
cJSON_free(text);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_models_user_set_provider(const char *name, const char *api_base,
|
||||
const char *key_env, const char *default_model,
|
||||
const char *const *models, int nmodels,
|
||||
char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!valid_provider_name(name)) {
|
||||
snprintf(err, err_sz,
|
||||
"a provider name is 1-31 of letters, digits, '.', '_' or '-'");
|
||||
return -1;
|
||||
}
|
||||
if (!api_base || !api_base[0]) {
|
||||
snprintf(err, err_sz, "a base URL is needed");
|
||||
return -1;
|
||||
}
|
||||
char path[1100];
|
||||
pthread_mutex_lock(&g_mu);
|
||||
cJSON *doc = read_user_doc(path, sizeof(path));
|
||||
if (!doc) {
|
||||
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return -1;
|
||||
}
|
||||
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
||||
if (!cJSON_IsObject(providers))
|
||||
providers = cJSON_AddObjectToObject(doc, "providers");
|
||||
cJSON *entry = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(entry, "api_base", api_base);
|
||||
if (key_env && key_env[0])
|
||||
cJSON_AddStringToObject(entry, "key_env", key_env);
|
||||
if (default_model && default_model[0])
|
||||
cJSON_AddStringToObject(entry, "default_model", default_model);
|
||||
if (models && nmodels > 0) {
|
||||
cJSON *arr = cJSON_AddArrayToObject(entry, "models");
|
||||
for (int i = 0; i < nmodels; i++)
|
||||
if (models[i] && models[i][0])
|
||||
cJSON_AddItemToArray(arr, cJSON_CreateString(models[i]));
|
||||
}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
||||
cJSON_AddItemToObject(providers, name, entry);
|
||||
const int rc = write_user_doc(doc, path, err, err_sz);
|
||||
cJSON_Delete(doc);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (rc == 0)
|
||||
cst_models_reload(); /* the provider is there from now on */
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz) {
|
||||
char dummy[8];
|
||||
if (!err || !err_sz) {
|
||||
err = dummy;
|
||||
err_sz = sizeof(dummy);
|
||||
}
|
||||
err[0] = 0;
|
||||
if (!valid_provider_name(name)) {
|
||||
snprintf(err, err_sz, "not a provider name");
|
||||
return -1;
|
||||
}
|
||||
char path[1100];
|
||||
if (cst_models_user_path(path, sizeof(path)) != 0) {
|
||||
snprintf(err, err_sz, "no config directory");
|
||||
return -1;
|
||||
}
|
||||
FILE *probe = fopen(path, "rb");
|
||||
if (!probe)
|
||||
return 0; /* no user file: nothing of ours to drop */
|
||||
fclose(probe);
|
||||
pthread_mutex_lock(&g_mu);
|
||||
cJSON *doc = read_user_doc(path, sizeof(path));
|
||||
if (!doc) {
|
||||
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
return -1;
|
||||
}
|
||||
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
||||
const int found = cJSON_IsObject(providers) &&
|
||||
cJSON_GetObjectItemCaseSensitive(providers, name) != NULL;
|
||||
if (found)
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
||||
const int rc = found ? write_user_doc(doc, path, err, err_sz) : 0;
|
||||
cJSON_Delete(doc);
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
if (rc == 0 && found)
|
||||
cst_models_reload();
|
||||
return rc == 0 ? found : -1;
|
||||
}
|
||||
|
||||
static void load_default_locked(void) {
|
||||
reset_locked();
|
||||
char e[128];
|
||||
|
||||
@@ -37,11 +37,6 @@
|
||||
|
||||
#define NET_SEND_TIMEOUT_MS 20000
|
||||
|
||||
static void set_err(char *err, size_t err_sz, const char *what) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "%s", what);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static void set_err_win(char *err, size_t err_sz, const char *what) {
|
||||
if (err && err_sz)
|
||||
@@ -59,6 +54,10 @@ static int net_last_conn_reset(void) {
|
||||
return e == WSAECONNRESET || e == WSAECONNABORTED || e == WSAENOTSOCK;
|
||||
}
|
||||
#else
|
||||
static void set_err(char *err, size_t err_sz, const char *what) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "%s", what);
|
||||
}
|
||||
#define SET_ERR(e, n, w) set_err(e, n, w)
|
||||
#define NET_FD(s) ((int)(s))
|
||||
static void net_close_raw(os_socket_t s) { close(s); }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "os_internal.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -503,19 +504,74 @@ int os_resolve_shell(char *out, size_t out_sz) {
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Per thread: parallel tool runs and sub-agents each watch their own flag. */
|
||||
static _Thread_local volatile int *g_run_cancel;
|
||||
static _Thread_local int g_run_timeout_s;
|
||||
/* Per thread: parallel tool runs and sub-agents each watch their own flag.
|
||||
* Thread keys rather than _Thread_local, which tcc does not implement; each
|
||||
* value fits in the key's pointer slot, so nothing is allocated. */
|
||||
#ifdef _WIN32
|
||||
static INIT_ONCE g_run_once = INIT_ONCE_STATIC_INIT;
|
||||
static DWORD g_key_cancel = TLS_OUT_OF_INDEXES, g_key_timeout = TLS_OUT_OF_INDEXES;
|
||||
static BOOL CALLBACK run_keys_init(PINIT_ONCE once, PVOID param, PVOID *ctx) {
|
||||
(void)once;
|
||||
(void)param;
|
||||
(void)ctx;
|
||||
g_key_cancel = TlsAlloc();
|
||||
g_key_timeout = TlsAlloc();
|
||||
return TRUE;
|
||||
}
|
||||
static void *run_key_get(DWORD *key) {
|
||||
InitOnceExecuteOnce(&g_run_once, run_keys_init, NULL, NULL);
|
||||
if (*key == TLS_OUT_OF_INDEXES)
|
||||
return NULL;
|
||||
DWORD err = GetLastError(); /* TlsGetValue clears it */
|
||||
void *v = TlsGetValue(*key);
|
||||
SetLastError(err);
|
||||
return v;
|
||||
}
|
||||
static void run_key_set(DWORD *key, void *v) {
|
||||
InitOnceExecuteOnce(&g_run_once, run_keys_init, NULL, NULL);
|
||||
if (*key != TLS_OUT_OF_INDEXES)
|
||||
TlsSetValue(*key, v);
|
||||
}
|
||||
#else
|
||||
static pthread_once_t g_run_once = PTHREAD_ONCE_INIT;
|
||||
static pthread_key_t g_key_cancel, g_key_timeout;
|
||||
static int g_run_keys_ok;
|
||||
static void run_keys_init(void) {
|
||||
g_run_keys_ok = pthread_key_create(&g_key_cancel, NULL) == 0 &&
|
||||
pthread_key_create(&g_key_timeout, NULL) == 0;
|
||||
}
|
||||
static void *run_key_get(pthread_key_t *key) {
|
||||
pthread_once(&g_run_once, run_keys_init);
|
||||
return g_run_keys_ok ? pthread_getspecific(*key) : NULL;
|
||||
}
|
||||
static void run_key_set(pthread_key_t *key, void *v) {
|
||||
pthread_once(&g_run_once, run_keys_init);
|
||||
if (g_run_keys_ok)
|
||||
pthread_setspecific(*key, v);
|
||||
}
|
||||
#endif
|
||||
|
||||
void os_set_cancel_flag(volatile int *flag) { g_run_cancel = flag; }
|
||||
static volatile int *run_cancel(void) {
|
||||
return (volatile int *)run_key_get(&g_key_cancel);
|
||||
}
|
||||
static int run_timeout(void) {
|
||||
return (int)(intptr_t)run_key_get(&g_key_timeout);
|
||||
}
|
||||
|
||||
void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; }
|
||||
void os_set_cancel_flag(volatile int *flag) {
|
||||
run_key_set(&g_key_cancel, (void *)(uintptr_t)flag);
|
||||
}
|
||||
|
||||
int os_get_run_timeout(void) { return g_run_timeout_s; }
|
||||
void os_set_run_timeout(int seconds) {
|
||||
run_key_set(&g_key_timeout, (void *)(intptr_t)seconds);
|
||||
}
|
||||
|
||||
int os_get_run_timeout(void) { return run_timeout(); }
|
||||
|
||||
int os_run_timeout_effective(void) {
|
||||
if (g_run_timeout_s > 0)
|
||||
return g_run_timeout_s;
|
||||
int t = run_timeout();
|
||||
if (t > 0)
|
||||
return t;
|
||||
const char *e = getenv("CST_SHELL_TIMEOUT");
|
||||
if (e && atoi(e) > 0)
|
||||
return atoi(e);
|
||||
@@ -693,8 +749,8 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
|
||||
}
|
||||
/* Checked on every pass, so a chatty command still stops promptly. */
|
||||
if (!reaped && !killed &&
|
||||
((g_run_cancel && *g_run_cancel) || (deadline > 0 && now > deadline))) {
|
||||
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
|
||||
(os_cancel_requested() || (deadline > 0 && now > deadline))) {
|
||||
killed = os_cancel_requested() ? 2 : 1;
|
||||
killed_at = now;
|
||||
kill(-pid, SIGTERM);
|
||||
}
|
||||
@@ -900,9 +956,12 @@ void os_run_parallel(os_task_fn fn, void *const args[], int n) {
|
||||
free(tasks);
|
||||
}
|
||||
|
||||
int os_cancel_requested(void) { return g_run_cancel && *g_run_cancel; }
|
||||
int os_cancel_requested(void) {
|
||||
volatile int *c = run_cancel();
|
||||
return c && *c;
|
||||
}
|
||||
|
||||
volatile int *os_get_cancel_flag(void) { return g_run_cancel; }
|
||||
volatile int *os_get_cancel_flag(void) { return run_cancel(); }
|
||||
|
||||
#ifndef _WIN32
|
||||
int os_proc_spawn(const char *cwd, const char *const argv[],
|
||||
|
||||