Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235652604d | ||
|
|
60047ca5ce | ||
|
|
b7034baec4 | ||
|
|
db8cdd7dd1 | ||
|
|
81aeee7d1e | ||
|
|
a99f78134a | ||
|
|
8e5bdd0c9d | ||
|
|
f8030ec201 | ||
|
|
a34c74e453 | ||
|
|
9d7ad9433e | ||
|
|
4c69ef39ab | ||
|
|
da04913071 | ||
|
|
7b2e7a3dbd | ||
|
|
4e990c8e35 | ||
|
|
dae51e06c7 | ||
|
|
042d7f6999 | ||
|
|
2ffe74b37c | ||
|
|
580d533656 | ||
|
|
08008a5a4c | ||
|
|
7299f39245 | ||
|
|
d4f65b17cf | ||
|
|
60cbce3c5c | ||
|
|
84f9585588 | ||
|
|
c2320f946b | ||
|
|
82dee65942 | ||
|
|
3fd039cb51 | ||
|
|
8694afca94 | ||
|
|
cbd71b83c7 | ||
|
|
be4025e276 | ||
|
|
69842edf16 | ||
|
|
f5d340bd2a | ||
|
|
da812cc7e8 | ||
|
|
5ba215b36c | ||
|
|
e0b0eddf3a | ||
|
|
50abcf1a91 | ||
|
|
9bf7707912 | ||
|
|
7e1dbd9afa | ||
|
|
2d1052fd5f | ||
|
|
e238f5f37d | ||
|
|
adf3e9f024 | ||
|
|
6e81513e36 | ||
|
|
c7b32c3fd6 | ||
|
|
32b4afd772 | ||
|
|
895b47274c | ||
|
|
0226f417ff | ||
|
|
1772c26d43 | ||
|
|
630e8f4295 | ||
|
|
94e8746091 | ||
|
|
546a4c7eeb | ||
|
|
9b2c7acfae | ||
|
|
420f9b242c | ||
|
|
e5ea869681 | ||
|
|
d6ae1a67c4 | ||
|
|
41c7e04940 | ||
|
|
77de812207 | ||
|
|
9ec21b7fc0 | ||
|
|
daa92a6cfb | ||
|
|
1476f1774a | ||
|
|
2a43ae6622 | ||
|
|
9862c29f58 | ||
|
|
929f7125b8 |
@@ -0,0 +1,2 @@
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
@@ -1,75 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
cc: [gcc, clang]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential clang cmake pkg-config python3 \
|
||||
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: Unit tests
|
||||
run: make CC=${{ matrix.cc }} check
|
||||
- name: CMake build + ctest
|
||||
run: |
|
||||
cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON
|
||||
cmake --build build -j"$(nproc)"
|
||||
ctest --test-dir build --output-on-failure
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./cst-agent --help
|
||||
./cst-agent --workspace "$PWD" --list-sessions
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
|
||||
sanitizers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
SUDO=$(command -v sudo || true)
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config libcurl4-openssl-dev libncurses-dev
|
||||
- name: ASan + UBSan
|
||||
run: make asan
|
||||
|
||||
musl:
|
||||
runs-on: ubuntu-latest
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: apk add --no-cache build-base pkgconf curl-dev ncurses-dev git nodejs bash python3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build + unit tests
|
||||
run: |
|
||||
make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)"
|
||||
make check
|
||||
|
||||
# Gitea instances rarely have macOS runners; set the repository variable
|
||||
# HAVE_MACOS_RUNNER=true once one is registered.
|
||||
macos:
|
||||
if: ${{ vars.HAVE_MACOS_RUNNER == 'true' }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: brew install ncurses curl pkg-config
|
||||
- name: Build + unit tests
|
||||
run: |
|
||||
make -j"$(sysctl -n hw.ncpu)"
|
||||
make check
|
||||
@@ -21,10 +21,38 @@ third_party/busybox/busybox
|
||||
third_party/busybox/busybox.exe
|
||||
third_party/curl/
|
||||
third_party/PDCurses/
|
||||
third_party/ncurses-*/
|
||||
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-*/
|
||||
# the Windows app package (scripts/package_windows_app.sh): MSYS2's Qt and the
|
||||
# host tools it is built with, both fetched/unpacked by the script
|
||||
third_party/msys-qt6/
|
||||
third_party/prefix-qt6-host
|
||||
third_party/prefix-android-*/
|
||||
third_party/build-android-*/
|
||||
third_party/src/
|
||||
vendor/sashidown/*.o
|
||||
vendor/sashidown/src/*.o
|
||||
vendor/sashidown/libsashidown.a
|
||||
vendor/sashidown/libsashidown-*.a
|
||||
vendor/sashidown/libsashidown-mingw.a
|
||||
.gen/
|
||||
.build-tmp/
|
||||
|
||||
# Android APK builds (scripts/build_apk_android.sh) and what gradle leaves
|
||||
build-apk-android/
|
||||
*.apk
|
||||
*.aab
|
||||
app/android/.gradle/
|
||||
app/android/build/
|
||||
app/android/local.properties
|
||||
|
||||
# the engine as a static library (app/ links it; built by `make`)
|
||||
libcstcore.a
|
||||
|
||||
# nix-build
|
||||
result
|
||||
|
||||
@@ -5,9 +5,37 @@ set(CMAKE_C_STANDARD 11)
|
||||
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)
|
||||
|
||||
if(WIN32)
|
||||
enable_language(RC)
|
||||
# Cross builds link libgcc/winpthreads/curl statically; MSYS2's curl is a DLL.
|
||||
if(CMAKE_CROSSCOMPILING)
|
||||
set(_CST_STATIC_DEFAULT ON)
|
||||
else()
|
||||
set(_CST_STATIC_DEFAULT OFF)
|
||||
endif()
|
||||
option(CST_WIN_STATIC "Link Windows executables with -static" ${_CST_STATIC_DEFAULT})
|
||||
endif()
|
||||
|
||||
# Windows: UTF-8 manifest (activeCodePage, longPathAware) and version resource
|
||||
# on every executable, so tests run with the same process code page as the app.
|
||||
function(cst_win_exe t)
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
target_sources(${t} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/res/windows/cst-agent.rc")
|
||||
target_include_directories(${t} PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/res/windows")
|
||||
target_compile_definitions(${t} PRIVATE _WIN32_WINNT=0x0A00)
|
||||
if(CST_WIN_STATIC)
|
||||
target_link_options(${t} PRIVATE -static)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown")
|
||||
if(CST_NATIVE_ARCH)
|
||||
set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1)
|
||||
@@ -15,6 +43,9 @@ else()
|
||||
set(_SD_MAKE_EXTRA "")
|
||||
endif()
|
||||
|
||||
# sashidown has a plain Makefile; CMAKE_MAKE_PROGRAM may be ninja.
|
||||
find_program(CST_MAKE NAMES make gmake mingw32-make REQUIRED)
|
||||
|
||||
# Separate archive for MinGW so Linux host builds keep libsashidown.a
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a")
|
||||
@@ -26,19 +57,42 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
set(_MINGW_RANLIB "${CMAKE_RANLIB}")
|
||||
add_custom_command(
|
||||
OUTPUT "${SASHIDOWN_A}"
|
||||
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
|
||||
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
|
||||
CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=${_MINGW_RANLIB}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
|
||||
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
|
||||
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(
|
||||
OUTPUT "${SASHIDOWN_A}"
|
||||
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
|
||||
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
|
||||
COMMENT "Building libsashidown.a"
|
||||
VERBATIM)
|
||||
endif()
|
||||
@@ -54,6 +108,7 @@ set(CST_CORE_SOURCES
|
||||
src/util.c
|
||||
src/ctl_parse.c
|
||||
src/os_compat.c
|
||||
src/os_win.c
|
||||
src/sandbox.c
|
||||
src/sandbox_exec.c
|
||||
src/keymap.c
|
||||
@@ -62,6 +117,9 @@ set(CST_CORE_SOURCES
|
||||
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
|
||||
@@ -69,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
|
||||
@@ -78,11 +137,103 @@ set(CST_CORE_SOURCES
|
||||
src/agent_store.c
|
||||
src/json_baseline.c
|
||||
src/creds.c
|
||||
src/provider_switch.c
|
||||
src/mm_tool.c
|
||||
src/models.c
|
||||
src/model_registry.c
|
||||
src/metrics.c
|
||||
src/net.c
|
||||
src/http_srv.c
|
||||
src/web_md.c
|
||||
)
|
||||
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
|
||||
|
||||
# Built-in model data as a C byte list (included by model_registry.c).
|
||||
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
|
||||
|
||||
# The WebUI shell (web/*) as C byte arrays, the same trick as the model data.
|
||||
file(GLOB CST_WEB_ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/web/*")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CST_WEB_ASSETS})
|
||||
set(_cst_web_inc "/* Generated by CMake from web/ - do not edit. */\n")
|
||||
set(_cst_web_table "")
|
||||
foreach(_f ${CST_WEB_ASSETS})
|
||||
if(IS_DIRECTORY "${_f}")
|
||||
continue()
|
||||
endif()
|
||||
get_filename_component(_name "${_f}" NAME)
|
||||
string(REGEX REPLACE "[^A-Za-z0-9]" "_" _var "wui_asset_${_name}")
|
||||
file(READ "${_f}" _hex HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _hex "${_hex}")
|
||||
set(_ctype "application/octet-stream")
|
||||
if(_name MATCHES "\\.html$")
|
||||
set(_ctype "text/html; charset=utf-8")
|
||||
elseif(_name MATCHES "\\.js$")
|
||||
set(_ctype "text/javascript; charset=utf-8")
|
||||
elseif(_name MATCHES "\\.css$")
|
||||
set(_ctype "text/css; charset=utf-8")
|
||||
elseif(_name MATCHES "\\.svg$")
|
||||
set(_ctype "image/svg+xml")
|
||||
endif()
|
||||
string(APPEND _cst_web_inc "static const unsigned char ${_var}[] = {${_hex}0x00};\n")
|
||||
string(APPEND _cst_web_table " {\"${_name}\", \"${_ctype}\", ${_var}, sizeof(${_var}) - 1},\n")
|
||||
endforeach()
|
||||
string(APPEND _cst_web_inc "static const wui_asset_t wui_assets[] = {\n${_cst_web_table}};\n")
|
||||
string(APPEND _cst_web_inc "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));\n")
|
||||
file(WRITE "${CST_GEN_DIR}/web_assets.inc" "${_cst_web_inc}")
|
||||
if(NOT CST_BUILD_WEBUI)
|
||||
list(APPEND CST_CORE_SOURCES src/webui_stub.c)
|
||||
else()
|
||||
list(APPEND CST_CORE_SOURCES src/webui.c)
|
||||
endif()
|
||||
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
|
||||
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
|
||||
include_directories("${CST_GEN_DIR}")
|
||||
|
||||
if(NOT CST_BUILD_WEBUI)
|
||||
add_compile_definitions(CST_NO_WEBUI=1)
|
||||
endif()
|
||||
if(WIN32)
|
||||
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
||||
include_directories(vendor/tre-regex)
|
||||
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()
|
||||
|
||||
# --- 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)
|
||||
@@ -92,6 +243,7 @@ endif()
|
||||
|
||||
add_executable(cst-agent ${CST_SOURCES})
|
||||
add_dependencies(cst-agent sashidown)
|
||||
cst_win_exe(cst-agent)
|
||||
|
||||
target_include_directories(cst-agent PRIVATE
|
||||
include
|
||||
@@ -104,18 +256,29 @@ 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)
|
||||
|
||||
if(CST_BUILD_TUI)
|
||||
set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
|
||||
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
|
||||
if(CST_BUILD_TUI AND WIN32 AND CST_WIN_CURSES STREQUAL "ncurses")
|
||||
# ncursesw built by scripts/build_third_party_mingw.sh ncurses (or MSYS2's
|
||||
# mingw-w64-ucrt-x86_64-ncurses); headers live in include/ncursesw.
|
||||
find_path(NCURSESW_INCLUDE_DIR ncurses.h PATH_SUFFIXES ncursesw include/ncursesw)
|
||||
find_library(NCURSESW_LIBRARY NAMES ncursesw)
|
||||
if(NOT NCURSESW_INCLUDE_DIR OR NOT NCURSESW_LIBRARY)
|
||||
message(FATAL_ERROR "CST_WIN_CURSES=ncurses needs ncursesw "
|
||||
"(scripts/build_third_party_mingw.sh ncurses), or -DCST_WIN_CURSES=pdcurses")
|
||||
endif()
|
||||
# ncurses.h includes <ncursesw/ncurses_dll.h>, so the parent is needed too
|
||||
get_filename_component(NCURSESW_PARENT_DIR ${NCURSESW_INCLUDE_DIR} DIRECTORY)
|
||||
target_include_directories(cst-agent PRIVATE ${NCURSESW_INCLUDE_DIR} ${NCURSESW_PARENT_DIR})
|
||||
target_link_libraries(cst-agent PRIVATE ${NCURSESW_LIBRARY})
|
||||
target_compile_definitions(cst-agent PRIVATE CST_USE_NCURSES=1 NCURSES_STATIC=1 NCURSES_WIDECHAR=1)
|
||||
message(STATUS "TUI: ncursesw (${NCURSESW_LIBRARY})")
|
||||
elseif(CST_BUILD_TUI)
|
||||
if(WIN32)
|
||||
# Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
|
||||
# PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
|
||||
# PATH_SUFFIXES also covers the layout MSYS2's mingw-w64-pdcurses uses,
|
||||
# which puts the headers under include/pdcurses/ rather than include/.
|
||||
find_path(PDCURSES_INCLUDE_DIR curses.h
|
||||
@@ -156,17 +319,27 @@ endif()
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32)
|
||||
# curl Schannel + networking; winmm is needed by PDCurses (PlaySoundW)
|
||||
target_link_libraries(cst-agent PRIVATE
|
||||
ws2_32 crypt32 secur32 bcrypt advapi32 winmm)
|
||||
ws2_32 crypt32 secur32 bcrypt advapi32 user32 gdi32 winmm)
|
||||
endif()
|
||||
|
||||
# --- tests ---
|
||||
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)
|
||||
add_executable(${t} tests/${t}.c src/textsel.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_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")
|
||||
@@ -177,33 +350,54 @@ foreach(t ${_CST_SMALL_TESTS})
|
||||
elseif(t STREQUAL test_json_baseline)
|
||||
add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c)
|
||||
elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real)
|
||||
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c vendor/cjson/cJSON.c)
|
||||
# cst_test_util.h static helpers reference os_*; gcc -O0 keeps unused ones.
|
||||
# On Windows os_win.c also needs util.c (cst_buf_*, cst_utf8_valid).
|
||||
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c
|
||||
vendor/cjson/cJSON.c src/os_compat.c src/os_win.c src/util.c)
|
||||
if(WIN32)
|
||||
target_link_libraries(${t} PRIVATE advapi32 user32)
|
||||
endif()
|
||||
else()
|
||||
add_executable(${t} tests/test_mm_tool.c src/mm_tool.c)
|
||||
endif()
|
||||
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}")
|
||||
endforeach()
|
||||
|
||||
# Tests that link the whole core (no TUI).
|
||||
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
|
||||
test_shell test_diff test_tools_fs test_registry
|
||||
test_undo test_config test_models test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent)
|
||||
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||
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_tool_meta test_embed)
|
||||
if(CST_BUILD_WEBUI)
|
||||
# test_webui drives the real server; the stub has nothing to test.
|
||||
list(APPEND _CST_CORE_TESTS test_webui)
|
||||
endif()
|
||||
foreach(t ${_CST_CORE_TESTS})
|
||||
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
||||
# The core now contains src/web_md.c, which needs sashidown's header and
|
||||
# archive exactly like the Makefile build does.
|
||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
|
||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||
target_link_libraries(${t} PRIVATE CURL::libcurl Threads::Threads)
|
||||
target_link_libraries(${t} PRIVATE
|
||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
|
||||
add_dependencies(${t} sashidown)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(${t} PRIVATE m)
|
||||
else()
|
||||
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32)
|
||||
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
||||
endif()
|
||||
cst_win_exe(${t})
|
||||
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
endforeach()
|
||||
|
||||
|
||||
@@ -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),)
|
||||
@@ -43,23 +53,50 @@ ifeq ($(strip $(CURL_LIBS)),)
|
||||
CURL_LIBS := -lcurl
|
||||
endif
|
||||
|
||||
# WebUI (HTTP + SSE front end): src/net.c src/http_srv.c src/web_md.c
|
||||
# src/webui.c. CST_WEBUI=0 swaps in the stub (--web then prints a hint).
|
||||
CST_WEBUI ?= 1
|
||||
|
||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
||||
|
||||
CPPFLAGS += -Iinclude -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) \
|
||||
$(wildcard $(WEB_DIR)/*.css) $(wildcard $(WEB_DIR)/*.svg)
|
||||
ifeq ($(CST_WEBUI),0)
|
||||
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui_stub.c
|
||||
# The stub body is guarded by this, so the switch has to define it too.
|
||||
CPPFLAGS += -DCST_NO_WEBUI=1
|
||||
else
|
||||
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui.c
|
||||
endif
|
||||
|
||||
CORE_SRC = \
|
||||
$(CJSON_DIR)/cJSON.c \
|
||||
src/util.c \
|
||||
src/ctl_parse.c \
|
||||
src/os_compat.c \
|
||||
src/os_win.c \
|
||||
src/sandbox.c \
|
||||
src/sandbox_exec.c \
|
||||
src/keymap.c \
|
||||
src/skills.c \
|
||||
src/extensions.c src/plugins.c src/mcp.c src/subagent.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 \
|
||||
@@ -67,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 \
|
||||
@@ -75,14 +113,20 @@ CORE_SRC = \
|
||||
src/agent_loop.c \
|
||||
src/agent_store.c \
|
||||
src/json_baseline.c \
|
||||
src/creds.c \
|
||||
src/creds.c src/provider_switch.c \
|
||||
src/mm_tool.c \
|
||||
src/models.c \
|
||||
src/metrics.c
|
||||
src/models.c src/model_registry.c \
|
||||
src/metrics.c \
|
||||
$(WEB_SRC)
|
||||
|
||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||
OBJ = $(SRC:.c=.o)
|
||||
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h
|
||||
# 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
|
||||
|
||||
TESTS = \
|
||||
tests/test_ctl_frontier \
|
||||
@@ -92,16 +136,25 @@ TESTS = \
|
||||
tests/test_replay_real \
|
||||
tests/test_mm_tool \
|
||||
tests/test_llm_body \
|
||||
tests/test_prefix_stable \
|
||||
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 \
|
||||
tests/test_model_registry \
|
||||
tests/test_sandbox \
|
||||
tests/test_keymap \
|
||||
tests/test_textsel \
|
||||
@@ -110,13 +163,29 @@ TESTS = \
|
||||
tests/test_skills \
|
||||
tests/test_plugins \
|
||||
tests/test_mcp \
|
||||
tests/test_subagent
|
||||
tests/test_subagent \
|
||||
tests/test_lowil \
|
||||
tests/test_win_utf8 \
|
||||
tests/test_os_dir \
|
||||
tests/test_tmpdir
|
||||
# test_webui drives the real server; there is nothing to test with the stub.
|
||||
ifneq ($(CST_WEBUI),0)
|
||||
TESTS += tests/test_webui
|
||||
endif
|
||||
|
||||
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
|
||||
TEST_SYS_LIBS = -lm $(PTHREAD_LIBS) $(LINK_STATIC)
|
||||
TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
|
||||
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
|
||||
# keeps them, so the small tests link the OS layer too.
|
||||
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
|
||||
|
||||
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
|
||||
|
||||
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
|
||||
@@ -139,32 +208,45 @@ FORCE:
|
||||
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
|
||||
|
||||
# Built-in model data as a C byte list (included by model_registry.c).
|
||||
.gen/models_builtin.inc: src/models_builtin.json
|
||||
@mkdir -p .gen
|
||||
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
|
||||
|
||||
# The WebUI shell is embedded the same way, one array per file.
|
||||
.gen/web_assets.inc: $(WEB_ASSETS) scripts/embed_web.sh
|
||||
@mkdir -p .gen
|
||||
@sh scripts/embed_web.sh $@ $(WEB_DIR)
|
||||
|
||||
%.o: %.c $(HDRS)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
||||
|
||||
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 include/cst_textsel.h
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.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
|
||||
@@ -172,10 +254,14 @@ tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
||||
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
||||
|
||||
$(TESTS): sashidown-lib
|
||||
|
||||
check: $(TESTS)
|
||||
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
||||
|
||||
# AddressSanitizer + UBSan build of the agent and all tests.
|
||||
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
|
||||
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
|
||||
asan:
|
||||
$(MAKE) clean
|
||||
$(MAKE) sashidown-lib
|
||||
@@ -185,7 +271,8 @@ demo: cst-agent
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
|
||||
clean:
|
||||
rm -f $(OBJ) cst-agent $(TESTS)
|
||||
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,20 @@ 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
|
||||
irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
|
||||
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
|
||||
```
|
||||
|
||||
也可以下载 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` 编译。
|
||||
|
||||
@@ -39,8 +53,10 @@ cd ~/my-project && cst-agent
|
||||
|---|---|
|
||||
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
|
||||
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
||||
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
|
||||
| Windows 运行 | 拷贝 dist 后 [`scripts/run_windows.bat`](scripts/run_windows.bat) |
|
||||
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
|
||||
| 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/` |
|
||||
|
||||
### 迷你验收工程(论文案例)
|
||||
|
||||
@@ -76,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)
|
||||
```
|
||||
|
||||
@@ -97,14 +114,26 @@ cmake --build build-clang
|
||||
# Headless only(无 ncurses)
|
||||
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
|
||||
|
||||
# Windows MinGW 交叉(需安装 mingw-w64)
|
||||
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
-DCST_BUILD_TUI=OFF
|
||||
cmake --build build-win
|
||||
# TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=...
|
||||
# Windows:用一键脚本(见下方“Windows 发行包”),会先编译静态依赖
|
||||
./scripts/bootstrap_windows.sh
|
||||
```
|
||||
|
||||
依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**。
|
||||
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
||||
### 桌面 GUI(Qt/QML,可选)
|
||||
|
||||
把引擎放进同一个进程的窗口前端(无端口、无第二个进程):`app/`,细节见 [app/README.md](app/README.md)。
|
||||
|
||||
```bash
|
||||
# NixOS / nix:装进用户 profile(PATH + 菜单项 + 图标),卸载 nix profile remove cst-agent
|
||||
nix profile add --file .
|
||||
|
||||
# 其他发行版(需要 Qt 6 的 Quick/Qml/QuickControls2 与 libcurl)
|
||||
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build-app -j && cmake --install build-app --component app --prefix ~/.local
|
||||
```
|
||||
|
||||
菜单里启动时工作区=启动目录(即 `$HOME`);在项目目录里 `cst-agent-app` 启动则工作区就是那个目录。
|
||||
|
||||
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
||||
@@ -113,11 +142,28 @@ 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 |
|
||||
| Windows | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** |
|
||||
| Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
|
||||
|
||||
### WebUI(`--web`)
|
||||
|
||||
同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。
|
||||
它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。
|
||||
协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。
|
||||
|
||||
```bash
|
||||
cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/
|
||||
cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL
|
||||
cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新
|
||||
cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问
|
||||
sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key
|
||||
```
|
||||
|
||||
不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`);
|
||||
`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。
|
||||
|
||||
### Termux
|
||||
|
||||
@@ -137,20 +183,40 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
|
||||
```
|
||||
|
||||
环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。
|
||||
临时目录:依次试 `$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)。交叉构建:
|
||||
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
|
||||
|
||||
```bash
|
||||
./scripts/build_third_party_mingw.sh all # curl+PDCurses+busybox-w32
|
||||
# 源码:https://github.com/rmyorston/busybox-w32.git
|
||||
# https://github.com/mirror/busybox.git (Unix)
|
||||
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
|
||||
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
||||
```
|
||||
|
||||
产物示例:`dist/windows-x86_64/`(含 `cst-agent.exe` + `busybox.exe` + `libwinpthread-1.dll`)。
|
||||
产物:`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` 等。
|
||||
|
||||
@@ -162,7 +228,10 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
|---|---|---|---|
|
||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||
|
||||
| 用途 | 来源(优先级从高到低) |
|
||||
|---|---|
|
||||
@@ -173,7 +242,11 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
||||
|
||||
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
|
||||
推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。
|
||||
推理强度会按 provider 适配:DeepSeek/OpenAI 使用 `reasoning_effort`,Kimi K3 使用同名字段,Kimi K2 使用 `thinking` 并保留 `reasoning_content`。Kimi 默认开启 1 小时隐式 prompt cache,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭提示。
|
||||
|
||||
Kimi 与 DeepSeek 还提供官方联网工具:`web_search` 调用 provider 的 Search API,`web_fetch` 调用 provider 的 Fetch API;其它 provider 继续使用通用 HTTP 抓取。
|
||||
|
||||
`kimi` 是按量计费的 Kimi 开放平台,`kimi-code` 是会员订阅的 Kimi Code API;两者的 Base URL 和 API Key 不通用。
|
||||
|
||||
详见 [docs/env_openai.md](docs/env_openai.md)。
|
||||
|
||||
@@ -181,18 +254,20 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
|---|---|
|
||||
| `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) |
|
||||
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
|
||||
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
|
||||
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) |
|
||||
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
|
||||
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
|
||||
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
|
||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 |
|
||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 |
|
||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
|
||||
| `CST_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
|
||||
@@ -219,16 +294,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`,不会被误提交。
|
||||
|
||||
### 内置工具
|
||||
|
||||
@@ -244,10 +321,20 @@ cst-agent --headless --prompt 'List files'
|
||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
||||
| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 |
|
||||
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
||||
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
||||
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
||||
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
|
||||
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
|
||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
||||
| `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 |
|
||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 |
|
||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
||||
|
||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
||||
@@ -255,6 +342,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`):
|
||||
@@ -268,15 +375,19 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
||||
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
|
||||
- **沙箱**(C 实现,`src/sandbox_exec.c`):
|
||||
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
|
||||
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。
|
||||
读、执行和联网不受限。
|
||||
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
|
||||
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
|
||||
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。
|
||||
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
|
||||
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
|
||||
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
|
||||
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
|
||||
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。
|
||||
- Windows、更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
|
||||
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` 只用指定的那种。
|
||||
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
|
||||
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
|
||||
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
|
||||
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
|
||||
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
|
||||
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
|
||||
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
|
||||
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
|
||||
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
|
||||
@@ -289,7 +400,7 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
||||
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
||||
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
|
||||
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
|
||||
- **自动压缩**:上一轮 prompt 用到模型上下文 80% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
||||
- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
||||
|
||||
### 扩展:Skills、插件、MCP、子 agent
|
||||
|
||||
@@ -369,6 +480,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]` * | 权限规则、每轮步数 |
|
||||
@@ -378,6 +490,8 @@ 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 默认展开/折叠;快捷键列表 |
|
||||
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
|
||||
|
||||
# The Qt front end (app/) embeds the C engine in-process: it links libcstcore
|
||||
# and never opens a port. Two targets:
|
||||
#
|
||||
# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the
|
||||
# C side is still guarded on machines without Qt Quick
|
||||
# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules
|
||||
# are there
|
||||
#
|
||||
# Standalone build (builds the engine from ../ too):
|
||||
# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j
|
||||
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
|
||||
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
|
||||
#
|
||||
# Desktop install (the window, its launcher entry and the icon; the CLI has
|
||||
# its own installer) -- default.nix installs exactly this component:
|
||||
# cmake --install build --component app --prefix ~/.local
|
||||
#
|
||||
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
|
||||
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
||||
|
||||
option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON)
|
||||
option(CST_APP_SELFTEST "build the C embedding smoke test" ON)
|
||||
|
||||
# --- the engine ----------------------------------------------------------
|
||||
# Used as a subproject (../CMakeLists.txt already defined cstcore, engine
|
||||
# compiled once for CLI and app) or built here from the sources next door.
|
||||
if(NOT TARGET cstcore)
|
||||
set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
|
||||
include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson")
|
||||
file(GLOB CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/*.c"
|
||||
"${CST_APP_ROOT}/src/tools/*.c"
|
||||
"${CST_APP_ROOT}/vendor/cjson/cJSON.c")
|
||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/main.c"
|
||||
"${CST_APP_ROOT}/src/tui.c"
|
||||
"${CST_APP_ROOT}/src/tui_stub.c"
|
||||
"${CST_APP_ROOT}/src/textsel.c")
|
||||
# The HTTP front end is not part of an in-process app: no server, no port.
|
||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||
"${CST_APP_ROOT}/src/net.c"
|
||||
"${CST_APP_ROOT}/src/http_srv.c"
|
||||
"${CST_APP_ROOT}/src/webui.c")
|
||||
# web_md.c stays in: the window renders markdown with it (AgentCore::
|
||||
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
|
||||
# is left out.
|
||||
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
||||
include_directories("${CST_APP_ROOT}/.gen")
|
||||
endif()
|
||||
add_library(cstcore STATIC ${CST_APP_CORE_SRC})
|
||||
target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1)
|
||||
find_package(CURL REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m)
|
||||
message(STATUS "app: building the engine from ${CST_APP_ROOT}")
|
||||
else()
|
||||
message(STATUS "app: linking the cstcore target of the parent build")
|
||||
endif()
|
||||
|
||||
# --- the C smoke test ----------------------------------------------------
|
||||
if(CST_APP_SELFTEST)
|
||||
add_executable(cst-app-selftest tools/selftest.c)
|
||||
target_link_libraries(cst-app-selftest PRIVATE cstcore)
|
||||
enable_testing()
|
||||
add_test(NAME cst-app-selftest COMMAND cst-app-selftest)
|
||||
endif()
|
||||
|
||||
# --- the GUI -------------------------------------------------------------
|
||||
if(CST_APP_GUI)
|
||||
# Android's platform file talks to the platform through QtAndroidPrivate
|
||||
# (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate.
|
||||
if(ANDROID)
|
||||
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test
|
||||
CorePrivate QUIET)
|
||||
else()
|
||||
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
|
||||
endif()
|
||||
if(NOT Qt6_FOUND)
|
||||
message(WARNING
|
||||
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
|
||||
"(the C selftest still builds; install e.g. qt6.qtdeclarative / "
|
||||
"qt6.qtquickcontrols2 and reconfigure)")
|
||||
else()
|
||||
qt_standard_project_setup()
|
||||
# Qt 6.5+ policies: keep the module resource at :/qt/qml/<URI>/ (what
|
||||
# main.cpp loads) and let CMake generate the qmldir of qml/ for us.
|
||||
qt_policy(SET QTP0001 NEW)
|
||||
qt_policy(SET QTP0004 NEW)
|
||||
# The front end's platform tools: one file per platform, the same split
|
||||
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
||||
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
|
||||
# the rest use the Linux file.
|
||||
if(ANDROID)
|
||||
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
||||
elseif(WIN32)
|
||||
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
|
||||
else()
|
||||
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
||||
endif()
|
||||
qt_add_executable(cst-agent-app
|
||||
src/main.cpp
|
||||
src/core/AgentCore.cpp
|
||||
src/core/ToolProvider.cpp
|
||||
src/core/PdfTools.cpp
|
||||
src/ui/UiTest.cpp
|
||||
src/ui/ThemeIcons.cpp
|
||||
src/ui/SessionImages.cpp
|
||||
src/ui/BackKey.cpp
|
||||
${CST_APP_PLATFORM_SRC})
|
||||
target_include_directories(cst-agent-app PRIVATE
|
||||
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
||||
target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}")
|
||||
if(CST_APP_QML_IMPORT)
|
||||
target_compile_definitions(cst-agent-app PRIVATE
|
||||
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
|
||||
endif()
|
||||
target_link_libraries(cst-agent-app PRIVATE
|
||||
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
|
||||
if(ANDROID)
|
||||
# app/src/platform/android/AndroidTools.cpp talks to the platform
|
||||
# through QtAndroidPrivate (permissions) -- a private header, hence the
|
||||
# private module for its include path.
|
||||
target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate)
|
||||
endif()
|
||||
if(WIN32)
|
||||
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
# A desktop install. The icon goes where the theme layout says, so the
|
||||
# entry's Icon= name finds it, and the entry's file name is the app_id Qt
|
||||
# sets (QGuiApplication::applicationName) -- rename one and the launcher
|
||||
# loses the icon. All of it in the "app" component: the CLI keeps coming
|
||||
# from scripts/bootstrap_linux.sh, and two `cst-agent` in PATH is how a
|
||||
# version nobody meant to run gets run.
|
||||
if(NOT ANDROID)
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS cst-agent-app
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app)
|
||||
install(FILES linux/cst-agent-app.desktop
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app)
|
||||
install(FILES linux/cst-agent-app.svg
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps
|
||||
COMPONENT app)
|
||||
endif()
|
||||
|
||||
# --- Android (APK) -----------------------------------------------------
|
||||
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
|
||||
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
|
||||
# nothing listens on a port). The manifest, icons and strings live in
|
||||
# app/android/, which androiddeployqt copies next to its own gradle
|
||||
# project. minSdk 24 is the engine's own API_LEVEL (scripts/
|
||||
# build_third_party_android.sh builds curl/mbedTLS for android-24).
|
||||
if(ANDROID)
|
||||
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../include/cst_version.h"
|
||||
_cst_ver_lines REGEX "#define CST_VERSION_(MAJOR|MINOR|PATCH) ")
|
||||
foreach(_line ${_cst_ver_lines})
|
||||
if(_line MATCHES "CST_VERSION_MAJOR ([0-9]+)")
|
||||
set(_cst_vmaj "${CMAKE_MATCH_1}")
|
||||
elseif(_line MATCHES "CST_VERSION_MINOR ([0-9]+)")
|
||||
set(_cst_vmin "${CMAKE_MATCH_1}")
|
||||
elseif(_line MATCHES "CST_VERSION_PATCH ([0-9]+)")
|
||||
set(_cst_vpat "${CMAKE_MATCH_1}")
|
||||
endif()
|
||||
endforeach()
|
||||
set_target_properties(cst-agent-app PROPERTIES
|
||||
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
|
||||
QT_ANDROID_MIN_SDK_VERSION 24
|
||||
QT_ANDROID_TARGET_SDK_VERSION 35
|
||||
QT_ANDROID_VERSION_NAME "${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}"
|
||||
QT_ANDROID_VERSION_CODE 1
|
||||
QT_ANDROID_ABIS "${ANDROID_ABI}")
|
||||
message(STATUS "app: Android package "
|
||||
"${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}, ABI ${ANDROID_ABI}, "
|
||||
"minSdk 24, targetSdk 35")
|
||||
|
||||
# Signing: androiddeployqt signs the APK itself and refuses a signed
|
||||
# gradle output ("Could not copy unsigned APK"), so the keystore goes
|
||||
# here, not into gradle. The path, alias and passwords arrive through
|
||||
# the QT_ANDROID_KEYSTORE_* environment variables;
|
||||
# scripts/build_apk_android.sh points them at the standard Android debug
|
||||
# key it creates on first use, and CST_ANDROID_KEYSTORE overrides it.
|
||||
if(DEFINED ENV{QT_ANDROID_KEYSTORE_PATH} AND NOT "$ENV{QT_ANDROID_KEYSTORE_PATH}" STREQUAL "")
|
||||
set(QT_ANDROID_SIGN_APK TRUE)
|
||||
message(STATUS "app: signing the APK with "
|
||||
"$ENV{QT_ANDROID_KEYSTORE_PATH} ($ENV{QT_ANDROID_KEYSTORE_ALIAS})")
|
||||
else()
|
||||
message(STATUS "app: QT_ANDROID_KEYSTORE_PATH unset -- the APK stays "
|
||||
"unsigned and 'adb install' will refuse it (INSTALL_PARSE_FAILED_NO_CERTIFICATES)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Theme is read by every component, so it is a QML singleton (one instance,
|
||||
# no object needed in the tree).
|
||||
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
|
||||
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
|
||||
# Sources live in qml/, but a module whose files sit in a subdirectory of
|
||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||
# requires absolute URLs" on every start).
|
||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
|
||||
ThemeIcon Theme ImageStrip)
|
||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||
endforeach()
|
||||
qt_add_qml_module(cst-agent-app
|
||||
URI CstAgent
|
||||
VERSION 1.0
|
||||
QML_FILES
|
||||
qml/Main.qml
|
||||
qml/ChatView.qml
|
||||
qml/Sidebar.qml
|
||||
qml/SessionRow.qml
|
||||
qml/MessageCard.qml
|
||||
qml/ToolCard.qml
|
||||
qml/ThinkBlock.qml
|
||||
qml/NoteLine.qml
|
||||
qml/TodoPanel.qml
|
||||
qml/Composer.qml
|
||||
qml/ConsentDialog.qml
|
||||
qml/AskDialog.qml
|
||||
qml/SettingsDialog.qml
|
||||
qml/ThemeIcon.qml
|
||||
qml/ImageStrip.qml
|
||||
qml/Theme.qml)
|
||||
|
||||
# The icons the window asks for, compiled in (app/icons, Breeze's scalable
|
||||
# names copied at 24@3x). The desktop finds them in the platform's icon
|
||||
# theme and only falls back to these (ThemeIcons.cpp); a machine without
|
||||
# any theme -- Android -- has nothing else, and before this the QML glyph
|
||||
# fallback was all that showed, which the phone's fonts cannot render.
|
||||
set(CST_APP_ICONS
|
||||
go-down mail-attachment application-menu settings-configure edit-copy
|
||||
edit-delete arrow-down arrow-right list-add task-complete box
|
||||
document-edit text-x-generic folder-open edit-find code-context
|
||||
internet-services format-list-unordered help-hint)
|
||||
set(_cst_icon_files "")
|
||||
foreach(_icon ${CST_APP_ICONS})
|
||||
list(APPEND _cst_icon_files "${CMAKE_CURRENT_SOURCE_DIR}/icons/${_icon}.svg")
|
||||
endforeach()
|
||||
qt_add_resources(cst-agent-app "cst_icons"
|
||||
PREFIX "/cst-icons"
|
||||
BASE "${CMAKE_CURRENT_SOURCE_DIR}/icons"
|
||||
FILES ${_cst_icon_files})
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,467 @@
|
||||
# app/ — the Qt front end
|
||||
|
||||
CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP
|
||||
server, no port, no token, no second process — the app creates a `cst_agent_t`
|
||||
directly and drives it through `cst_agent.h`.
|
||||
|
||||
```
|
||||
app/ the GUI
|
||||
src/main.cpp entry point, --workspace / --selftest
|
||||
src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent
|
||||
src/core/ToolProvider.h how the front end hands tools to the engine
|
||||
src/platform/<os>/ platform tools the app implements itself
|
||||
qml/Main.qml the window (chat, tool runs, consent dialog)
|
||||
tools/selftest.c C smoke test of the embedding path (no Qt)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
# engine + GUI + C smoke test
|
||||
cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build-app -j
|
||||
QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
|
||||
|
||||
# the app alone
|
||||
./build-app/cst-agent-app --workspace ~/notes
|
||||
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
|
||||
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
|
||||
./build-app/cst-agent-app --selftest # same checks as the C test
|
||||
```
|
||||
|
||||
The standalone build is the supported one: `app/CMakeLists.txt` uses the
|
||||
`cstcore` target when the parent project defines one, and otherwise builds
|
||||
the engine itself from `../src` (TUI, `main.c` and the HTTP front end
|
||||
excluded). Wiring it into the repository's `CMakeLists.txt` as
|
||||
`-DCST_APP=ON` is on the list below.
|
||||
|
||||
Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`,
|
||||
`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on
|
||||
Windows/macOS). Without them the configure step warns and builds only the C
|
||||
smoke test — that is deliberate: the embedding path stays verifiable
|
||||
everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick
|
||||
Controls and keeps the layout in Kirigami's shape (single column, cards,
|
||||
navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
|
||||
change. Point the app at a Kirigami outside the Qt prefix with
|
||||
`-DCST_APP_QML_IMPORT=/path/to/qml`.
|
||||
|
||||
## Install (Linux desktop)
|
||||
|
||||
The window installs itself: the binary, a launcher entry and the icon the
|
||||
launcher draws for it. All three are one CMake component (`app`), so the CLI
|
||||
keeps coming from `scripts/bootstrap_linux.sh` and the profile never holds two
|
||||
`cst-agent` of different vintages; the files live in `app/linux/`.
|
||||
|
||||
```sh
|
||||
# NixOS / nix users: PATH + menu entry + icon. The Qt apps hook bakes the
|
||||
# plugin and QML module paths into the wrapper, so the app runs outside a
|
||||
# nix-shell and nix-collect-garbage cannot pull Qt out from under it.
|
||||
nix-build # ./result/bin/cst-agent-app, run it in place
|
||||
nix profile add --file . # or: ./result/bin/cst-agent-app
|
||||
nix profile remove cst-agent # uninstall
|
||||
|
||||
# anywhere with Qt 6 (Quick/Qml/QuickControls2) and libcurl:
|
||||
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build-app -j
|
||||
cmake --install build-app --component app --prefix ~/.local
|
||||
```
|
||||
|
||||
Launched from a menu, the workspace is the directory the app starts in
|
||||
(`$HOME` there), so sessions land in `~/.agent/sessions`; pass
|
||||
`--workspace <dir>` to pin another one.
|
||||
|
||||
## Why in one process
|
||||
|
||||
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
||||
`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so
|
||||
there is no protocol to version, no socket to authenticate and no second
|
||||
process to babysit — and the CLI, TUI and WebUI keep working off the same
|
||||
source.
|
||||
|
||||
- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread;
|
||||
the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas,
|
||||
tool events, tool results, todo, turn end).
|
||||
- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the
|
||||
GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL /
|
||||
ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in
|
||||
`.agent/permissions.json`, so a desktop decision is visible to the TUI.
|
||||
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
|
||||
result, not an exception.
|
||||
|
||||
## Layout and input
|
||||
|
||||
One shell, three sizes (everything is styled from `qml/Theme.qml`):
|
||||
|
||||
- **Desktop**: the sidebar is docked and its divider can be dragged (double
|
||||
click restores the auto width). Hover states, tooltips, selectable text and
|
||||
a wheel-scrolled transcript.
|
||||
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
|
||||
folds away, the header grows a `☰` button, and the settings slide in over
|
||||
the transcript with a scrim; the sidebar itself scrolls, because a phone
|
||||
window cannot show it all.
|
||||
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
|
||||
not depend on hover, the composer asks the platform keyboard not to
|
||||
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
|
||||
|
||||
`--uitest` checks this without a human: it loads the window, clicks with
|
||||
synthesized **mouse** events, taps with a synthesized **touch** device, types
|
||||
into the composer, drives the session list, then narrows the window to 460 px
|
||||
to assert the dock → drawer switch and opens/closes the drawer. One line per
|
||||
check, non-zero exit on failure:
|
||||
|
||||
```sh
|
||||
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
|
||||
# ok wide window: sidebar docked
|
||||
# ok mouse click on "general" reaches the engine
|
||||
# ok touch tap on "coding" reaches the engine
|
||||
# ...
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
A GUI session is a directory in the workspace's store
|
||||
(`.agent/sessions/<id>/session.jsonl`, with its `title`, its `attachments/` and
|
||||
its sub-agent transcripts beside it), the same one the CLI, the TUI and the
|
||||
WebUI use — `AgentCore::open()` resolves the workspace's current session before
|
||||
the engine starts, so nothing is written next to the process's working
|
||||
directory. A store an older version wrote (flat `<id>.jsonl` files) still
|
||||
reads; `cst-agent --migrate-store` moves it in.
|
||||
|
||||
- The sidebar lists the store (newest first): title or first prompt, message
|
||||
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
|
||||
deletes one (the engine first moves off a session it has open, so it never
|
||||
sits on a file that is gone), and a click switches.
|
||||
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
|
||||
messages and returns the same rows the live signals build — messages,
|
||||
thoughts, notes, and one card per tool call, with its result paired by name
|
||||
(a call with no result keeps state `unfinished`, so no spinner runs
|
||||
forever). A reopened session therefore renders like the turn that wrote it.
|
||||
- Session controls are disabled while a turn runs: switching would move the
|
||||
engine out from under it.
|
||||
|
||||
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
|
||||
session through the store API (a tool call with its result), the checks click
|
||||
its row, assert the five replayed rows and the paired result, start two new
|
||||
sessions (the view empties), delete one by its `×` (the open one stays), and
|
||||
switch back.
|
||||
|
||||
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
|
||||
creates and deletes sessions. On a machine without a screen,
|
||||
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
||||
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
||||
|
||||
Every line the run prints also goes to `uitest.log` in the workspace, and the
|
||||
verdict is the last line of it. That is how a run *on a phone* is read back:
|
||||
stdout reaches nobody there, an app started from adb cannot be handed
|
||||
environment variables, and logcat does not carry this build's Qt messages --
|
||||
so `--uitest --workspace /sdcard/cst-uitest` plus
|
||||
`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip.
|
||||
|
||||
## Provider, model, key
|
||||
|
||||
The sidebar shows where the engine points (`provider · model`); the gear in the
|
||||
header opens the dialog that changes it.
|
||||
|
||||
- The provider list is the engine's own preset table (`cst_provider_count()`),
|
||||
with each entry's base URL and where its key would come from: "key saved"
|
||||
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from
|
||||
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side —
|
||||
the dialog only ever describes it. The window starts the engine without a
|
||||
key (`AgentCore::open()` is passed an empty one), so `open()` resolves it
|
||||
for the provider it starts on: saved key, then the provider's env variable,
|
||||
then `CST_API_KEY` — the same order the CLI uses in `main.c`. Without that
|
||||
the key sat in the file but never in the turn, and every start asked for it
|
||||
again.
|
||||
- "Use this" calls `AgentCore::useProvider()`, which runs
|
||||
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config:
|
||||
a pasted key is verified against the provider's `/models` before it is saved
|
||||
(offline it is kept unverified and the header says so), the model is
|
||||
remembered in `settings.json`, and the **session is not touched**. Model
|
||||
candidates come from the provider entry, the fetched cache
|
||||
(`cst_ps_cached`, no network) and the remembered pick; the label next to the
|
||||
field is `cst_model_info` ("200K · text only").
|
||||
- The dialog also reports the session's token use (`cst_agent_usage`).
|
||||
|
||||
`--uitest` checks it end to end: the presets are readable, an unknown provider
|
||||
is refused, the gear opens the panel, the provider rows exist, a model typed
|
||||
into the field reaches the engine when "Use this" is clicked, the note says so,
|
||||
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
|
||||
directory, so settings and keys of the real user are never rewritten.
|
||||
|
||||
## Custom providers, model parameters
|
||||
|
||||
The dialog's **Advanced options** covers the two things the presets cannot:
|
||||
a provider of your own, and what the model should do.
|
||||
|
||||
- A custom provider is an entry the engine keeps in
|
||||
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
|
||||
`include/cst_models.h`) — the same file a hand-written override would go
|
||||
into, so the GUI and a text editor are interchangeable:
|
||||
|
||||
```json
|
||||
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
|
||||
"key_env": "MYLLM_API_KEY",
|
||||
"default_model": "m1",
|
||||
"models": ["m1", "m2"] } } }
|
||||
```
|
||||
|
||||
`AgentCore::addProvider()` / `removeProvider()` write it
|
||||
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
|
||||
reload the registry, so the provider is in the list, in `models[]` on the
|
||||
wire and in `/provider` of the CLI at once. A name a preset uses overrides
|
||||
that preset (a self-hosted endpoint, a proxy); the other content of the file
|
||||
(model rules) is kept, and a file that is not a JSON object is never
|
||||
overwritten. The key is still a key: it is verified against `GET /models` of
|
||||
the new base URL before it is saved, and stored per provider name as before.
|
||||
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
|
||||
remembered per provider in `settings.json` (`"params": {...}`,
|
||||
`cst_settings_remember_params()`), and applied to the engine's config by
|
||||
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
|
||||
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
|
||||
An **empty field is not sent at all** — that is not the same as sending 0,
|
||||
which is a real temperature; `cst_llm_build_body()` only adds a field that
|
||||
was set (`tests/test_llm_body.c:test_gen_params`).
|
||||
- A long model list (a router lists hundreds) is filtered and capped at 40
|
||||
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
|
||||
put and nothing can spill out of the frame.
|
||||
|
||||
## Attachments
|
||||
|
||||
The next prompt can carry files, and they arrive three ways:
|
||||
|
||||
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
|
||||
- **Dropping files on the window** — a `DropArea` covers it; while something is
|
||||
dragged over, the border turns accent and says "drop to attach".
|
||||
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
|
||||
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
|
||||
intercepts the keystroke when there is an image.
|
||||
|
||||
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
|
||||
`cst_agent_attach_bytes()`: the engine validates the file, copies it into the
|
||||
session's own directory (`.agent/sessions/<id>/attachments/`), and the next
|
||||
`run_turn` carries it — the
|
||||
window only shows what the engine's `pending` list says, one chip per file in
|
||||
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
|
||||
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
|
||||
with the turn. When the model cannot be shown images
|
||||
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
|
||||
them as text notes instead.
|
||||
|
||||
`--uitest` writes a file into its scratch workspace and checks the whole path:
|
||||
`attachFile` succeeds, the engine reports one pending attachment, a missing file
|
||||
is refused with a reason, the composer shows one chip, "clear all" empties the
|
||||
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
|
||||
`attachClipboard()` behave.
|
||||
|
||||
## Images
|
||||
|
||||
What a session keeps as a picture is shown, not only stored: a file
|
||||
`view_image` returned, an image an MCP server sent, a screenshot the user
|
||||
attached or pasted. Each one appears as a thumbnail in the transcript -- a
|
||||
prompt's pictures under the message, a tool's under its card -- with the name,
|
||||
size and origin in the tooltip. The height is capped so a 4K screenshot cannot
|
||||
push the conversation off the screen, and a file the store no longer has says
|
||||
which image it was instead of leaving a hole.
|
||||
|
||||
The view never touches the filesystem on its own authority: the thumbnails
|
||||
come from `image://cstattach/<stored name>` (`app/src/ui/SessionImages.cpp`),
|
||||
where the name is the one the engine wrote next to the session and is checked
|
||||
with the engine's own `cst_attach_rel_ok()`, so only the session's attachment
|
||||
directory can be read. The picture is decoded at the size of its thumbnail
|
||||
rather than at its full resolution, and the provider's directory follows the
|
||||
session: `AgentCore::attachmentDir()` is re-read on every switch.
|
||||
|
||||
A document (PDF, UTF-8 text) still arrives as its name in the message text --
|
||||
those are sent to the model as text, not as a picture.
|
||||
|
||||
## Files the agent delivers
|
||||
|
||||
The opposite direction: the engine's `deliver_file` tool (registered in
|
||||
`src/tools/registry.c`, run in `src/tools/fs_tools.c`) copies a finished file
|
||||
out of the workspace into the session's own store, and the transcript shows it
|
||||
as a **file card** -- an extension badge, the file's name, its type and size,
|
||||
and two buttons.
|
||||
|
||||
- The copy is marked `for_user` (`cst_attachment_t.for_user`,
|
||||
`cst_attach_import_artifact()`): any bytes up to `CST_ARTIFACT_MAX_BYTES`
|
||||
(64 MB), the name's extension decides the mime, and **no request ever
|
||||
contains it** -- `src/llm_openai.c` skips those entries when it builds the
|
||||
body, which is what keeps a delivered report from being pasted back into the
|
||||
context. `tests/test_artifacts.c` holds that down at the wire level.
|
||||
- A card never invents a path. The row carries the stored file's name, and
|
||||
`AgentCore::openArtifact()` / `shareArtifact()` accept it only when one of
|
||||
the session's own messages carries that name (`artifactPathOf()`, plus the
|
||||
engine's `cst_attach_rel_ok()`), so a forged name is refused with a reason.
|
||||
- **Open** hands the file to the system: Android builds a `content://` URI
|
||||
through the app's FileProvider, checks `resolveActivity()`, starts
|
||||
`ACTION_VIEW` and grants the reader permission; a desktop calls
|
||||
`QDesktopServices::openUrl`. **Share** uses the phone's share sheet; a
|
||||
desktop has none, so the path goes to the clipboard.
|
||||
- `CST_APP_NO_OPEN=1` makes both do nothing but report the line they would
|
||||
write. `--uitest` sets it, so a test run never opens anything for real: it
|
||||
replays a session with a delivered `report.md`, checks the card and its two
|
||||
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
||||
and checks a name the session does not own is refused.
|
||||
|
||||
### Making one: `md_to_pdf`
|
||||
|
||||
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
|
||||
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
|
||||
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
|
||||
margins in the workspace, and goes through the same
|
||||
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
|
||||
file card the person can open, print or share, and its bytes never enter a
|
||||
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
|
||||
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
|
||||
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
|
||||
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
|
||||
phone has no print subsystem. The tool runs on the turn's worker thread, and
|
||||
`--uitest` runs it through the registry and checks the file starts with
|
||||
`%PDF-`.
|
||||
|
||||
The card row on the phone is the same one a picture uses: the store keeps the
|
||||
PDF next to the session, the image provider refuses it (it is not an image),
|
||||
and the file card carries it.
|
||||
|
||||
## Transcript, tool calls, streaming text
|
||||
|
||||
- A tool call is a card of its own, and the call **id** is what pairs it with
|
||||
its result: the same tool may run twice in a turn, and pairing by name (what
|
||||
the window did before) hung the first card on "running" forever while the
|
||||
second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry
|
||||
the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the
|
||||
session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an
|
||||
old text-protocol session still falls back to the name.
|
||||
- Nothing may keep spinning after a turn: `closeAllRows()` settles every open
|
||||
row when the turn ends, and a call with no result becomes "no result"
|
||||
(`unfinished`) instead of a spinner that runs forever.
|
||||
- The answer is rendered **while it streams**: a 120 ms timer re-renders the
|
||||
open answer with the engine's own markdown renderer (`cst_web_md_html`, via
|
||||
`AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted
|
||||
instead of turning into markdown when the turn is over. A replayed session
|
||||
renders once, when its rows are built.
|
||||
- Every signal handler is a plain function on the window
|
||||
(`engineToolEvent`, `engineToolResult`, `engineTurnFinished`,
|
||||
`engineAssistant`, ...) so `--uitest` can drive the same path: it opens two
|
||||
calls of one tool, delivers one result, and asserts that the result landed on
|
||||
its own card and that the turn end settled the other one. `rowJson(i)` exists
|
||||
for that (a row as JSON — an object would not survive
|
||||
`QMetaObject::invokeMethod`).
|
||||
|
||||
## Icons, colors
|
||||
|
||||
- Icons come from the desktop's **icon theme** (Breeze on KDE), through
|
||||
`image://csttheme/<name>?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`).
|
||||
Qt Quick has no icon provider of its own without Kirigami, so the window used
|
||||
unicode glyphs before (a clip emoji, a gear, a hamburger): those render in
|
||||
whichever font covers them — colour emoji in a grey toolbar — and are a box
|
||||
on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place
|
||||
that asks for an icon, and every use still names the old character as
|
||||
`glyph:`: a host with no icon theme degrades to what it looked like before
|
||||
rather than going blank.
|
||||
- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons`
|
||||
and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them
|
||||
from XDG once a theme name is set. It then tries the theme the desktop chose,
|
||||
else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising
|
||||
Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a
|
||||
desktop Qt install) — without it the icons fall back to the glyphs instead of
|
||||
disappearing.
|
||||
- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil,
|
||||
read → text page, list/glob → folder, grep → magnifier, shell → braces,
|
||||
web → globe, plan → list, ask → question mark. Toolbar icons are 22 px
|
||||
(Breeze's toolbar size), card and row icons 14–16 px.
|
||||
- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it
|
||||
carries hints, secondary lines and provider URLs. Anything written *on* the
|
||||
accent fill (a selected provider row, the open session) uses `accentText`,
|
||||
`accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is
|
||||
unreadable. The names are not `onAccent…`: QML reads every `onFoo` property
|
||||
as a signal handler and refuses the assignment.
|
||||
- The composer's frame is one `Theme.pad` around its column on **all** sides;
|
||||
with the top margin alone the clip and Send hung over the lower border.
|
||||
|
||||
## Tools the app provides
|
||||
|
||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
||||
and the owner `app:<name>`, exactly like a plugin or MCP server would:
|
||||
|
||||
```c
|
||||
cst_tool_def_t def = {0};
|
||||
def.name = "notification_send";
|
||||
def.desc = "Send a system notification.";
|
||||
def.perm = CST_TP_ASK; /* class: drives the consent dialog */
|
||||
def.perm_name = "notify"; /* policy: what the user is really granting */
|
||||
def.platforms = ANDROID_ONLY; /* NULL = everywhere */
|
||||
def.available = probe; /* 0 / -1 with a reason; cached */
|
||||
def.run_ex = my_run;
|
||||
def.ud = my_state;
|
||||
def.owner = "app:android";
|
||||
cst_tool_register(&def);
|
||||
```
|
||||
|
||||
- `platforms` + `available` decide *whether* the tool exists here: an
|
||||
Android-only tool never reaches the prompt or `tools[]` on Linux, and
|
||||
`cst_tool_run()` refuses it with the reason if a model asks anyway.
|
||||
- A front-end tool may take over the **name** of a built-in that cannot run
|
||||
on this host (the Android clipboard replacing the desktop `xclip` one);
|
||||
it may not shadow one that works.
|
||||
- `cst_tool_unregister_owner("app:android")` drops them again — do it when a
|
||||
platform service goes away, then `cst_tool_meta_reload()`.
|
||||
- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a
|
||||
launcher that knows better).
|
||||
|
||||
Note `run_ex` runs on the worker thread while the turn is going: touch the
|
||||
GUI only through queued signals.
|
||||
|
||||
## Platform notes
|
||||
|
||||
| Platform | How the engine gets built | Platform tools |
|
||||
|---|---|---|
|
||||
| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) |
|
||||
| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path |
|
||||
| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`Notifier.java`: channel + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) |
|
||||
| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) |
|
||||
|
||||
Android's long-term shape: the core lives in the APK (no Termux needed), the
|
||||
platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
|
||||
The build flag that keeps Unix helpers out of the picture is
|
||||
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
|
||||
A Java class is added by dropping it in `app/android/src/<package>/`:
|
||||
androiddeployqt copies `app/android/` with the rest of the package source and
|
||||
Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script
|
||||
change is needed (`Notifier.java` is the first one; a Java helper is the only
|
||||
way to catch a throwable where the platform throws instead of returning).
|
||||
`CstService.java` (the foreground service that holds a phone awake while a
|
||||
turn runs) went in the same way; it is started by `app::holdForeground()`
|
||||
when a turn begins and stopped by `app::releaseForeground()` when it ends,
|
||||
so nothing about it is visible when the app is idle.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
|
||||
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
|
||||
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
|
||||
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
|
||||
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
|
||||
`--uitest` for mouse, touch and small-screen checks
|
||||
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
||||
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
|
||||
- [x] attachments: picker, drop on the window, Ctrl+V image paste
|
||||
- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback
|
||||
- [x] contrast pass: faint text and selected rows are readable
|
||||
- [x] images in the transcript: tool results and attachments are drawn as
|
||||
thumbnails (`image://cstattach/<stored name>`, see the Images section)
|
||||
- [x] Android APK: the engine is linked in (no port), builds, installs under
|
||||
`xyz.whcself.cstagent` and runs on the device (see `docs/android.md`)
|
||||
- [x] the saved provider key reaches the engine on every start (`AgentCore::open()`
|
||||
resolves it: saved key, provider environment, `CST_API_KEY`)
|
||||
- [x] delivered files: `deliver_file` stores the copy next to the session and the
|
||||
transcript draws a file card with Open and Share (see the Files section)
|
||||
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
|
||||
file card's open/share through the app's FileProvider
|
||||
- [x] a turn keeps running with the app off screen (Android foreground service
|
||||
+ wake lock; nothing to do on a desktop)
|
||||
- [x] desktop install: launcher entry + icon, `nix profile add --file .`
|
||||
- [ ] a real turn driven through the window (needs a provider key)
|
||||
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
||||
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
||||
global shortcuts
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- The APK's manifest (QT_ANDROID_PACKAGE_SOURCE_DIR in app/CMakeLists.txt):
|
||||
androiddeployqt copies this file and res/ into its gradle project and
|
||||
fills in the INSERT_* placeholders below (version code and name, library
|
||||
name, app arguments). An XML comment may not contain a double hyphen, so
|
||||
the placeholder syntax is only spelled out in the attributes themselves.
|
||||
|
||||
The SDK levels are not here on purpose: they come from
|
||||
QT_ANDROID_MIN_SDK_VERSION and QT_ANDROID_TARGET_SDK_VERSION in
|
||||
app/CMakeLists.txt, and androiddeployqt writes them into the generated
|
||||
gradle.properties. The app name and icon come from res/ next to this
|
||||
file (values/strings.xml, drawable/ic_launcher.xml).
|
||||
|
||||
WHAT THIS APP IS: the engine runs in this process (no server, no port) and
|
||||
writes everything (workspace, session store, settings, credentials) under
|
||||
the app's own data directory, which main.cpp points the engine at (see
|
||||
androidRoot() there).
|
||||
|
||||
Permissions: INTERNET for the providers the user configures,
|
||||
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
|
||||
asks at runtime; the tool stays hidden until it is granted), the
|
||||
foreground-service permissions with WAKE_LOCK for the service that keeps
|
||||
a turn going while the phone is in a pocket (CstService.java), and
|
||||
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
|
||||
can read a path in shared storage that a task names rather than picks:
|
||||
Android only offers the Settings screen for it, so main.cpp opens that
|
||||
once and everything still works without it: what the system picker (SAF)
|
||||
hands over is copied into the app's own directory (AttachFile.java). No
|
||||
cleartext traffic: every provider URL is https. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="xyz.whcself.cstagent"
|
||||
android:installLocation="auto"
|
||||
android:versionCode="-- %%INSERT_VERSION_CODE%% --"
|
||||
android:versionName="-- %%INSERT_VERSION_NAME%% --">
|
||||
|
||||
<!-- %%INSERT_PERMISSIONS -->
|
||||
<!-- %%INSERT_FEATURES -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!-- "All files access": without it the engine can only read the app's own
|
||||
directory, so a task that names a file in shared storage cannot be
|
||||
answered. Android 11+ has no runtime dialog for it, only the Settings
|
||||
screen (AttachFile.java opens it once). Files the user picks work
|
||||
either way: their content:// grant is copied into the app first. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true" />
|
||||
|
||||
<application
|
||||
android:name="org.qtproject.qt.android.bindings.QtApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="false">
|
||||
|
||||
<activity
|
||||
android:name="org.qtproject.qt.android.bindings.QtActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|keyboard|keyboardHidden|navigation|density|mcc|mnc"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="unspecified"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="-- %%INSERT_APP_LIB_NAME%% --" />
|
||||
<meta-data
|
||||
android:name="android.app.arguments"
|
||||
android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
|
||||
</activity>
|
||||
|
||||
<!-- The share tool hands a file out of the workspace, and a file
|
||||
crossing an app boundary has to travel as a content:// uri. This
|
||||
is Qt's own provider (androidx.core.content.FileProvider, paths
|
||||
from res/xml/qtprovider_paths.xml): its files-path is the app's
|
||||
files directory, which is where the workspace lives. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.qtprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/qtprovider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- A turn can outlive the screen: while one is running the front end
|
||||
starts this service (AgentCore::send -> app::holdForeground), which
|
||||
makes the process a foreground one, so Android neither freezes nor
|
||||
kills it in the background, and takes a partial wake lock so the
|
||||
CPU keeps going with the phone in a pocket. dataSync is the type
|
||||
that fits "the app is finishing some work". -->
|
||||
<service
|
||||
android:name="xyz.whcself.cstagent.CstService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Launcher icon: the terminal prompt the app is about (">_"), the window's
|
||||
own colors (bg #12161a, accent #5cc0f0, faint #8d959c). A vector drawable
|
||||
works from API 24 up; a proper adaptive icon (mipmap-anydpi-v26 + a PNG
|
||||
fallback) is polish for later. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#12161a"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#5cc0f0"
|
||||
android:strokeWidth="7"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M32,38 L52,54 L32,70" />
|
||||
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#8d959c"
|
||||
android:strokeWidth="7"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M58,74 L78,74" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The name under the launcher icon. -->
|
||||
<resources>
|
||||
<string name="app_name">CST-agent</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,189 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.provider.Settings;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/* What the user picks in the system file chooser comes back as a content://
|
||||
* URI, and the engine opens files by path -- so the bytes are copied into the
|
||||
* app's own directory first. That copy needs no permission at all (the grant
|
||||
* travels with the URI), which is what makes picking work on a phone that
|
||||
* grants nothing else.
|
||||
*
|
||||
* The "all files access" below is a different thing: it is what lets the
|
||||
* engine read /sdcard by path afterwards (a model that opens a file the user
|
||||
* names rather than picks). Android 11+ has no runtime dialog for it -- only
|
||||
* the Settings screen -- so it is a request, not a demand: everything works
|
||||
* without it, one file at a time.
|
||||
*
|
||||
* Qt swallows a Java exception (it comes back as a null object), so every
|
||||
* method catches its own and answers with a reason instead.
|
||||
*/
|
||||
public final class AttachFile {
|
||||
private AttachFile() {}
|
||||
|
||||
private static final long MAX_BYTES = 256L * 1024 * 1024;
|
||||
|
||||
public static boolean isContent(String s) {
|
||||
return s != null && s.startsWith("content:");
|
||||
}
|
||||
|
||||
/* content://<uri> -> the path of a copy inside destDir; "" when the string
|
||||
* is already a path; "error: ..." when the copy failed. */
|
||||
public static String materialize(Context ctx, String pathOrUri, String destDir) {
|
||||
if (pathOrUri == null || pathOrUri.isEmpty())
|
||||
return "error: no file was picked";
|
||||
if (!isContent(pathOrUri))
|
||||
return "";
|
||||
try {
|
||||
if (ctx == null)
|
||||
return "error: no Android context";
|
||||
final Uri uri = Uri.parse(pathOrUri);
|
||||
String name = displayName(ctx, uri);
|
||||
if (name == null || name.trim().isEmpty())
|
||||
name = uri.getLastPathSegment();
|
||||
name = sanitize(name);
|
||||
File dir = new File(destDir);
|
||||
if (!dir.isDirectory() && !dir.mkdirs())
|
||||
return "error: cannot use " + destDir;
|
||||
File out = unique(dir, name);
|
||||
final ContentResolver cr = ctx.getContentResolver();
|
||||
final InputStream in = cr.openInputStream(uri);
|
||||
if (in == null)
|
||||
return "error: the picker gave no data for that file";
|
||||
long total = 0;
|
||||
try {
|
||||
final OutputStream os = new FileOutputStream(out);
|
||||
try {
|
||||
final byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
total += n;
|
||||
if (total > MAX_BYTES) {
|
||||
os.close();
|
||||
in.close();
|
||||
out.delete();
|
||||
return "error: that file is larger than 256 MB";
|
||||
}
|
||||
os.write(buf, 0, n);
|
||||
}
|
||||
} finally {
|
||||
os.close();
|
||||
}
|
||||
} finally {
|
||||
in.close();
|
||||
}
|
||||
if (total == 0) {
|
||||
out.delete();
|
||||
return "error: that file is empty";
|
||||
}
|
||||
return out.getAbsolutePath();
|
||||
} catch (Throwable t) {
|
||||
return "error: cannot read the picked file: " + t;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android 11+ keeps "all files access" in a special app-op; older systems
|
||||
* have no such gate. */
|
||||
public static boolean hasAllFilesAccess(Context ctx) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < 30)
|
||||
return true;
|
||||
return Environment.isExternalStorageManager();
|
||||
} catch (Throwable t) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* The Settings screen Android requires for it. "" when it was opened. */
|
||||
public static String requestAllFilesAccess(Context ctx) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < 30 || ctx == null)
|
||||
return "";
|
||||
if (Environment.isExternalStorageManager())
|
||||
return "";
|
||||
final Intent intent = new Intent(
|
||||
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
Uri.parse("package:" + ctx.getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
ctx.startActivity(intent);
|
||||
} catch (ActivityNotFoundException notFound) {
|
||||
final Intent all = new Intent(
|
||||
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
|
||||
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
ctx.startActivity(all);
|
||||
}
|
||||
return "";
|
||||
} catch (Throwable t) {
|
||||
return "cannot open the storage settings: " + t;
|
||||
}
|
||||
}
|
||||
|
||||
private static String displayName(Context ctx, Uri uri) {
|
||||
Cursor c = null;
|
||||
try {
|
||||
c = ctx.getContentResolver().query(uri,
|
||||
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
|
||||
null);
|
||||
if (c != null && c.moveToFirst()) {
|
||||
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (i >= 0)
|
||||
return c.getString(i);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
/* fall back to the last path segment */
|
||||
} finally {
|
||||
if (c != null)
|
||||
c.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* A plain file name: no separators, no traversal, no control bytes. */
|
||||
private static String sanitize(String name) {
|
||||
if (name == null || name.isEmpty())
|
||||
return "picked.bin";
|
||||
final StringBuilder b = new StringBuilder();
|
||||
for (int i = 0; i < name.length() && b.length() < 120; i++) {
|
||||
final char ch = name.charAt(i);
|
||||
if (ch == '/' || ch == '\\' || ch < 0x20)
|
||||
b.append('_');
|
||||
else
|
||||
b.append(ch);
|
||||
}
|
||||
String s = b.toString();
|
||||
while (s.startsWith("."))
|
||||
s = s.substring(1);
|
||||
if (s.isEmpty())
|
||||
return "picked.bin";
|
||||
return s;
|
||||
}
|
||||
|
||||
private static File unique(File dir, String name) {
|
||||
File f = new File(dir, name);
|
||||
if (!f.exists())
|
||||
return f;
|
||||
final int dot = name.lastIndexOf('.');
|
||||
final String stem = dot > 0 ? name.substring(0, dot) : name;
|
||||
final String ext = dot > 0 ? name.substring(dot) : "";
|
||||
for (int i = 2; i < 1000; i++) {
|
||||
f = new File(dir, stem + "-" + i + ext);
|
||||
if (!f.exists())
|
||||
return f;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.os.PowerManager;
|
||||
|
||||
/**
|
||||
* The service that keeps a turn running while the app is not on screen.
|
||||
*
|
||||
* A phone freezes an app it cannot see (and may kill it), so a turn that takes
|
||||
* minutes -- a build, a long web fetch, a provider that thinks -- dies in the
|
||||
* pocket. While one is going, AgentCore::send() starts this service through
|
||||
* app::holdForeground(); being a foreground service makes the process one
|
||||
* Android does not touch, and the partial wake lock keeps the CPU awake with
|
||||
* the screen off. turnFinished() stops it again (app::releaseForeground()), so
|
||||
* the permanent notification exists exactly as long as the work does.
|
||||
*
|
||||
* Started with an explicit component (AndroidTools.cpp builds the Intent), no
|
||||
* intent filter: nothing outside the app can start it.
|
||||
*/
|
||||
public class CstService extends Service {
|
||||
/** Same channel the notification_send tool uses: one place in settings. */
|
||||
private static final String CHANNEL_ID = "cst-agent";
|
||||
private static final int NOTE_ID = 2;
|
||||
private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
|
||||
/** A turn that runs longer than this is stuck; the lock must not outlive it. */
|
||||
private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
|
||||
|
||||
private PowerManager.WakeLock wake;
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||
stopEverything();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
String text = intent == null ? null : intent.getStringExtra("text");
|
||||
try {
|
||||
startForeground(NOTE_ID, buildNote(text));
|
||||
} catch (Throwable t) {
|
||||
/* Android 12+ refuses a foreground start from the background: the
|
||||
* turn then simply runs without the protection rather than not at
|
||||
* all. Nothing to show the user here -- the turn's own result is
|
||||
* what matters. */
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
acquireWake();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
releaseWake();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void stopEverything() {
|
||||
releaseWake();
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
else
|
||||
stopForeground(true);
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
private void acquireWake() {
|
||||
try {
|
||||
if (wake == null) {
|
||||
PowerManager pm =
|
||||
(PowerManager) getSystemService(Context.POWER_SERVICE);
|
||||
if (pm == null)
|
||||
return;
|
||||
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"cst-agent:turn");
|
||||
wake.setReferenceCounted(false);
|
||||
}
|
||||
wake.acquire(WAKE_LOCK_TIMEOUT_MS);
|
||||
} catch (Throwable ignored) {
|
||||
/* no wake lock: the turn still runs while the screen is on */
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseWake() {
|
||||
try {
|
||||
if (wake != null && wake.isHeld())
|
||||
wake.release();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/** "the agent is working: <first line of the prompt>", tapping opens it. */
|
||||
private Notification buildNote(String text) {
|
||||
Context ctx = getApplicationContext();
|
||||
NotificationManager manager =
|
||||
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager != null && Build.VERSION.SDK_INT >= 26) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
|
||||
channel.setDescription("A turn is running");
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
Notification.Builder builder = Build.VERSION.SDK_INT >= 26
|
||||
? new Notification.Builder(ctx, CHANNEL_ID)
|
||||
: new Notification.Builder(ctx);
|
||||
builder.setContentTitle("CST-agent is working")
|
||||
.setSmallIcon(smallIcon())
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false);
|
||||
if (text != null && !text.isEmpty())
|
||||
builder.setContentText(text);
|
||||
Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
|
||||
if (open != null) {
|
||||
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** A framework drawable first (the same order the tool uses), then the app's. */
|
||||
private int smallIcon() {
|
||||
int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
|
||||
if (icon == 0)
|
||||
icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
|
||||
if (icon == 0)
|
||||
icon = getApplicationInfo().icon;
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package xyz.whcself.cstagent;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
/**
|
||||
* The notification behind the engine's notification_send tool.
|
||||
*
|
||||
* It lives in Java because a Java exception is invisible to C++ by the time
|
||||
* Qt's JNI wrapper is done with the call: Notification.Builder.build() can
|
||||
* throw (a small icon Android refuses, a channel it does not know) and the
|
||||
* JNI call then simply returns null -- the tool could only say "failed, no
|
||||
* exception detail" on a device where the phone's own shade never saw
|
||||
* anything. Catching it here turns the failure into the line the tool shows.
|
||||
*
|
||||
* Called from app/src/platform/android/AndroidTools.cpp through QJniObject.
|
||||
* androiddeployqt copies this file with the rest of the package source
|
||||
* (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds
|
||||
* "src" to java.srcDirs, so no build script change is needed for a file
|
||||
* added here.
|
||||
*/
|
||||
public final class Notifier {
|
||||
private Notifier() {}
|
||||
|
||||
/**
|
||||
* Post one notification. An empty channelId means an API below 26, which
|
||||
* has no channels. Returns "" when it went out, the Java failure
|
||||
* otherwise (class name and message), so the tool result can name it.
|
||||
*/
|
||||
public static String post(Context ctx, String channelId, String title,
|
||||
String text, int iconId, Intent tap, int id) {
|
||||
try {
|
||||
Notification.Builder builder = channelId == null || channelId.isEmpty()
|
||||
? new Notification.Builder(ctx)
|
||||
: new Notification.Builder(ctx, channelId);
|
||||
builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId);
|
||||
if (tap != null) {
|
||||
tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pending = PendingIntent.getActivity(
|
||||
ctx, 0, tap,
|
||||
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
builder.setContentIntent(pending);
|
||||
}
|
||||
NotificationManager manager = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (manager == null)
|
||||
return "no NotificationManager";
|
||||
manager.notify(id, builder.build());
|
||||
return "";
|
||||
} catch (Throwable t) {
|
||||
String message = t.getMessage();
|
||||
return t.getClass().getSimpleName()
|
||||
+ (message == null || message.isEmpty() ? "" : ": " + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m7.707031 3l-.707031.707031 6.125 6.125 1.167969 1.167969-1.167969 1.167969-6.125 6.125.707031.707031 6.125-6.125 1.875-1.875-1.875-1.875-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 547 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" transform="translate(-.99999-.99999)" d="m4 4v16h16v-16h-16m1 1h14v14h-14v-14" fill="currentColor"/>
|
||||
<path d="m3.5056345 10.977883v-7.5052069h14.878582v15.010415h-14.878582z" class="ColorScheme-Text" fill="currentColor" fill-opacity=".50196078"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m10.04492 3c-1.77683 0-2.521484.706446-2.521484 2.509766v2.441406c0 1.11382-.034195 2.404297-1.015625 2.404297h-.507813v1.289062h.507813c.98148 0 1.015625 1.290477 1.015625 2.404297v2.441406c0 1.80332.744654 2.509766 2.521484 2.509766v-1.183594c-1.18455 0-1.201172-.478327-1.201172-2.210937v-2.01563c0-.83093-.204705-2.067378-1.203125-2.580078.99842-.51271 1.203125-1.751092 1.203125-2.582032v-2.033203c0-1.7326.016592-2.210937 1.201172-2.210937v-1.183594m1.894531 0v1.183594c1.18456 0 1.216797.478337 1.216797 2.210937v2.033203c0 .83094.204705 2.069322 1.203125 2.582032-.99842.5127-1.203125 1.749148-1.203125 2.580078v2.01563c0 1.73261-.032187 2.210937-1.216797 2.210937v1.183594c1.77684 0 2.537109-.706446 2.537109-2.509766v-2.441406c0-1.11382.068644-2.404297 1.033204-2.404297h.490234v-1.289062h-.490234c-.96457 0-1.033204-1.290477-1.033204-2.404297v-2.441406c0-1.80332-.760269-2.509766-2.537109-2.509766" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m14.996094 3l-11.992188 11.992188h-.003906v4.00781h1 2 1.00781v-.003906l11.992188-11.992188-.001953-.001953.001953-.001953-4-4-.001953.001953-.001953-.001953m-1.998047 3.412109l2.589844 2.589844-7.587891 7.587891v-1.589844h-1-1v-1-.589844l6.998047-6.998047m-7.998047 7.998047v1.589844h1 1v1 .589844l-.410156.410156h-1.589844l-1-1v-1.589844l1-1" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.1" width="24" height="24">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 3 3 L 3 17 L 7 17 L 7 19 L 17 19 L 17 10 L 13 6 L 12 6 L 9 3 L 3 3 Z M 4 4 L 8 4 L 8 6 L 7 6 L 7 16 L 4 16 L 4 4 Z M 8 7 L 12 7 L 12 11 L 16 11 L 16 18 L 8 18 L 8 7 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-NegativeText { color: #da4453; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-NegativeText" d="M8 3v2h1V4h4v1h1V3H8M4 6v1h14V6H4m2 2v11h10V8h-1v10H7V8H6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 9 3 C 5.6759952 3 3 5.6759952 3 9 C 3 12.324005 5.6759952 15 9 15 C 10.481205 15 11.830584 14.465318 12.875 13.582031 L 18.292969 19 L 19 18.292969 L 13.582031 12.875 C 14.465318 11.830584 15 10.481205 15 9 C 15 5.6759952 12.324005 3 9 3 z M 9 4 C 11.770005 4 14 6.2299952 14 9 C 14 11.770005 11.770005 14 9 14 C 6.2299952 14 4 11.770005 4 9 C 4 6.2299952 6.2299952 4 9 4 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor" d="M 3 3 L 3 4 L 3 19 L 4 19 L 19 19 L 19 18 L 19 5 L 12. 5 L 10. 3 L 10 3 L 10 3 L 4 3 L 3 3 z M 9 8 L 18 8 L 18 18 L 4 18 L 4 10 L 7 10 L 7 10 L 7 10 L 9 8 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 4 3 C 3.4477153 3 3 3.4477153 3 4 C 3 4.5522847 3.4477153 5 4 5 C 4.5522847 5 5 4.5522847 5 4 C 5 3.4477153 4.5522847 3 4 3 z M 10 3 L 10 4 L 19 4 L 19 3 L 10 3 z M 10 5 L 10 6 L 16 6 L 16 5 L 10 5 z M 4 9 C 3.4477153 9 3 9.4477153 3 10 C 3 10.552285 3.4477153 11 4 11 C 4.5522847 11 5 10.552285 5 10 C 5 9.4477153 4.5522847 9 4 9 z M 10 9 L 10 10 L 19 10 L 19 9 L 10 9 z M 10 11 L 10 12 L 12 12 L 12 11 L 10 11 z M 4 15 C 3.4477153 15 3 15.447715 3 16 C 3 16.552285 3.4477153 17 4 17 C 4.5522847 17 5 16.552285 5 16 C 5 15.447715 4.5522847 15 4 15 z M 10 15 L 10 16 L 19 16 L 19 15 L 10 15 z M 10 17 L 10 18 L 16 18 L 16 17 L 10 17 z M 17 18 L 17 19 L 19 19 L 19 18 L 17 18 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 C 15.432 19 19 15.432 19 11 C 19 6.568 15.432 3 11 3 z M 11 4 C 14.878 4 18 7.122 18 11 C 18 14.878 14.878 18 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 z M 10 6 L 10 8 L 12 8 L 12 6 L 10 6 z M 10 9 L 10 16 L 12 16 L 12 9 L 10 9 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 10 4 L 10 11 L 3 11 L 3 12 L 10 12 L 10 19 L 11 19 L 11 12 L 18 12 L 18 11 L 11 11 L 11 4 L 10 4 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 12.902344 3 L 3.734375 12.167969 C 2.167415 13.734929 2.167415 16.257279 3.734375 17.824219 C 5.301315 19.391169 7.823675 19.391169 9.390625 17.824219 L 17.851562 9.3632812 L 18.558594 8.65625 C 19.733804 7.48104 19.733794 5.5892625 18.558594 4.4140625 C 17.383384 3.2388525 15.491616 3.2388525 14.316406 4.4140625 L 13.609375 5.1210938 L 5.1484375 13.582031 C 4.3649575 14.365511 4.3649675 15.626686 5.1484375 16.410156 C 5.9319175 17.193636 7.1930825 17.193646 7.9765625 16.410156 L 16.4375 7.9492188 L 15.730469 7.2421875 L 7.9765625 14.996094 L 7.2695312 15.703125 C 6.8777912 16.094865 6.2471888 16.094865 5.8554688 15.703125 C 5.4637287 15.311405 5.4637187 14.680813 5.8554688 14.289062 L 14.316406 5.828125 L 15.023438 5.1210938 C 15.806917 4.3376037 17.068092 4.3376037 17.851562 5.1210938 C 18.635042 5.9045538 18.635042 7.1657387 17.851562 7.9492188 L 8.6835938 17.117188 C 7.5083838 18.292397 5.6166162 18.292397 4.4414062 17.117188 C 3.2661862 15.941977 3.2661862 14.05021 4.4414062 12.875 L 13.609375 3.7070312 L 12.902344 3 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11.5 3 C 10.286139 3 9.2809778 3.8559279 9.0507812 5 L 3 5 L 3 6 L 9.0507812 6 C 9.2809778 7.1440721 10.286139 8 11.5 8 C 12.713861 8 13.719022 7.1440721 13.949219 6 L 19 6 L 19 5 L 13.949219 5 C 13.719022 3.8559279 12.713861 3 11.5 3 z M 5.5 14 C 4.1149999 14 3 15.115 3 16.5 C 3 17.885 4.1149999 19 5.5 19 C 6.7138604 19 7.7190223 18.144072 7.9492188 17 L 19 17 L 19 16 L 7.9492188 16 C 7.7190223 14.855928 6.7138604 14 5.5 14 z M 5.5 15 C 6.3310001 15 7 15.669 7 16.5 C 7 17.331 6.3310001 18 5.5 18 C 4.6689999 18 4 17.331 4 16.5 C 4 15.669 4.6689999 15 5.5 15 z " class="ColorScheme-Text"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 966 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<defs id="defs3051">
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color: #fcfcfc; } </style>
|
||||
</defs>
|
||||
<g transform="translate(1,1)">
|
||||
<path class="ColorScheme-Text" d="M 19,9 12,16 8,12 9.4896719,10.543135 12,13 17.482382,7.5492102 Z M 4,3 V 5 H 3 V 6 H 4 V 7 H 3 v 1 h 1 v 6 H 3 v 1 h 1 v 1 H 3 v 1 h 1 v 2 h 3 12 v -1 -7 h -1 v 1 1 2 1 2 H 10 9 7 V 16 15 13 12 10 9 7 6 4 h 2 1 8 v 2 h 1 V 3 H 7 Z m 4,3 v 2 h 2 V 6 Z m 3,0 v 1 h 6 V 6 Z" style="fill:currentColor;fill-opacity:1;stroke:none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 669 B |
@@ -0,0 +1,14 @@
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="24" version="1.1" height="24" viewBox="0 0 24 24">
|
||||
<defs id="defs3871"/>
|
||||
<metadata id="metadata3874"/>
|
||||
<g transform="translate(1,1)">
|
||||
<g inkscape:label="Capa 1" inkscape:groupmode="layer" id="layer1" transform="matrix(1 0 0 1 -326 -534.3622)">
|
||||
<rect width="8" x="329" y="537.36218" height="1.00002" style="fill:#6c7a89" id="rect4279"/>
|
||||
<rect width="16" x="329" y="541.36218" height="1" style="fill:#6c7a89" id="rect4281"/>
|
||||
<rect width="16" x="329" y="548.36218" height="1" style="fill:#6c7a89" id="rect4283"/>
|
||||
<rect width="8" x="329" y="545.36218" height="1" style="fill:#6c7a89" id="rect4285"/>
|
||||
<rect width="12" x="329" y="552.36218" height="0.999983" style="fill:#6c7a89" id="rect4287"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 919 B |
@@ -0,0 +1,15 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=CST-agent
|
||||
GenericName=Local coding agent
|
||||
Comment=The engine in a window: workspace is the directory it is started in
|
||||
Exec=cst-agent-app
|
||||
TryExec=cst-agent-app
|
||||
Icon=cst-agent-app
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
# Qt puts the application name in the Wayland app_id / WM_CLASS, so this has to
|
||||
# stay the file's own name or the launcher draws a generic icon.
|
||||
StartupWMClass=cst-agent-app
|
||||
Categories=Development;Utility;
|
||||
Keywords=agent;ai;assistant;code;
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- The launcher icon: the prompt the CLI shows, on a tile in the window's own
|
||||
palette (app/qml/Theme.qml). Installed under hicolor/scalable, so the
|
||||
desktops that want raster sizes render it themselves. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<rect x="6" y="6" width="116" height="116" rx="26" ry="26"
|
||||
fill="#15181a" stroke="#343b41" stroke-width="5"/>
|
||||
<path d="M36 42 L56 64 L36 86" fill="none" stroke="#3daee9" stroke-width="12"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M69 88 H94" fill="none" stroke="#eff0f1" stroke-width="12"
|
||||
stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1,108 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// on_ask_user: the agent stopped and wants an answer from the human. Options
|
||||
// (when the engine sends any) become buttons; the field is always there.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string question: ""
|
||||
property string optionText: ""
|
||||
signal answered(string text)
|
||||
|
||||
readonly property var options: {
|
||||
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
|
||||
return raw.length > 1 && raw.length <= 8 ? raw : []
|
||||
}
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(620, parent ? parent.width - 60 : 620)
|
||||
padding: Theme.pad
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
function send(text) {
|
||||
const t = text.trim()
|
||||
if (!t.length)
|
||||
return
|
||||
answered(t)
|
||||
close()
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "The agent asks"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dialog.question
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.optionText.length > 0 && dialog.options.length === 0
|
||||
text: dialog.optionText
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.options.length > 0
|
||||
spacing: Theme.gap
|
||||
Repeater {
|
||||
model: dialog.options
|
||||
Button {
|
||||
text: modelData
|
||||
onClicked: dialog.send(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField {
|
||||
id: answer
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "your answer"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectByMouse: true
|
||||
onAccepted: dialog.send(text)
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Send"
|
||||
highlighted: true
|
||||
enabled: answer.text.trim().length > 0
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: dialog.send(answer.text)
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: answer.forceActiveFocus()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The transcript: cards for the messages, a card per tool call, folded
|
||||
// thinking blocks and one-line notes. Follows the tail of the conversation
|
||||
// until the user scrolls away from it.
|
||||
Item {
|
||||
id: view
|
||||
property var model: null
|
||||
property bool running: false
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
objectName: "transcript"
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.gapL
|
||||
anchors.rightMargin: Theme.gapL
|
||||
model: view.model
|
||||
clip: true
|
||||
spacing: Theme.gap
|
||||
topMargin: Theme.gapL
|
||||
bottomMargin: Theme.gapL
|
||||
cacheBuffer: 2000
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// The delegate picks the card by row kind; the components below read
|
||||
// the ListModel roles directly, which is why they are declared in the
|
||||
// delegate's scope.
|
||||
delegate: Loader {
|
||||
id: rowLoader
|
||||
width: list.width
|
||||
sourceComponent: model.kind === "tool" ? toolComp
|
||||
: model.kind === "think" ? thinkComp
|
||||
: model.kind === "note" ? noteComp : msgComp
|
||||
|
||||
Component {
|
||||
id: msgComp
|
||||
MessageCard {
|
||||
role: model.role
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
/* html comes from the streaming renderer (Main.qml) or,
|
||||
* for a replayed session, from here once. */
|
||||
html: model.html.length > 0 ? model.html
|
||||
: (model.role === "agent" || model.role === "error") &&
|
||||
model.complete && model.text.length > 0
|
||||
? agent.renderMarkdown(model.text) : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: thinkComp
|
||||
ThinkBlock {
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: toolComp
|
||||
ToolCard {
|
||||
objectName: "toolCard"
|
||||
name: model.name
|
||||
state: model.state
|
||||
detail: model.detail
|
||||
output: model.output
|
||||
exitCode: model.exitCode
|
||||
hasResult: model.complete
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: noteComp
|
||||
NoteLine {
|
||||
text: model.text
|
||||
level: model.state === "warn" || model.state === "error" ? model.state : "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMovementEnded: view.stickBottom = atYEnd
|
||||
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
}
|
||||
|
||||
// Follow the tail unless the user asked otherwise.
|
||||
property bool stickBottom: true
|
||||
|
||||
// Empty state: what this window is and what to do with it.
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 520)
|
||||
spacing: Theme.gap
|
||||
visible: view.model ? view.model.count === 0 : true
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: 22
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "The engine runs in this window — no server, no second process."
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "Ask for a change, a command to run, a file to explain.\n" +
|
||||
"Every write, delete and command asks first: Deny / Once / Always.\n" +
|
||||
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Jump back to the live end of the conversation.
|
||||
RoundButton {
|
||||
id: jump
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Theme.padL
|
||||
width: Theme.jumpButton
|
||||
height: Theme.jumpButton
|
||||
visible: !view.stickBottom && list.contentHeight > list.height
|
||||
contentItem: ThemeIcon {
|
||||
name: "go-down"
|
||||
glyph: "↓"
|
||||
size: 20
|
||||
color: Theme.text
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: width / 2
|
||||
color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: {
|
||||
view.stickBottom = true
|
||||
list.positionViewAtEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The input line: multiline, Enter sends, the button changes with the turn
|
||||
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
||||
// keyboard not to autocorrect what is usually code or a path.
|
||||
//
|
||||
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
|
||||
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
|
||||
// engine's pending list, which the next prompt carries.
|
||||
Rectangle {
|
||||
id: root
|
||||
objectName: "composer"
|
||||
property bool running: false
|
||||
property bool compact: false
|
||||
property string note: "" // a failure, or what was just attached
|
||||
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
||||
property alias text: input.text
|
||||
signal submitted(string text)
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length || running)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
}
|
||||
|
||||
/* A file: from the picker, or a URL dropped on the window. */
|
||||
function localPath(url) {
|
||||
let s = String(url)
|
||||
// A phone's picker (SAF) hands over content:// URIs: they are not
|
||||
// paths and must not be decoded -- the app copies what they point at
|
||||
// into its own directory before the engine sees it (see
|
||||
// app::materializeAttachment).
|
||||
if (s.startsWith("content://"))
|
||||
return s
|
||||
if (s.startsWith("file://"))
|
||||
s = s.substring(7)
|
||||
/* Windows keeps its drive letter after the slash. */
|
||||
if (/^\/[A-Za-z]:/.test(s))
|
||||
s = s.substring(1)
|
||||
return decodeURIComponent(s)
|
||||
}
|
||||
|
||||
function attachPath(path) {
|
||||
const r = agent.attachFile(path)
|
||||
note = r.length ? r : ""
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
||||
/* One pad of frame on every side of the column: with the top margin
|
||||
* alone the last row (the clip and Send) hung over the lower border,
|
||||
* which is what put the clip on the frame instead of inside it. */
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
fill: parent
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
// What the next prompt will carry, in the engine's own words.
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.attachments.length > 0
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.attachments
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
objectName: "attachChip" + chip.index
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 24
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: String(chip.modelData)
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: clearAtt
|
||||
objectName: "clearAttachments"
|
||||
text: "clear all"
|
||||
implicitHeight: 24
|
||||
onClicked: {
|
||||
agent.clearPending()
|
||||
root.note = ""
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: clearAtt.text
|
||||
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.note.length > 0
|
||||
text: root.note
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
|
||||
TextArea {
|
||||
id: input
|
||||
objectName: "composerInput"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||
Math.max(56, contentHeight + 12))
|
||||
placeholderText: root.running
|
||||
? "the turn is running — Esc stops it"
|
||||
: (root.compact
|
||||
? "Ask for something…"
|
||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: TextArea.Wrap
|
||||
selectByMouse: true
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||
Qt.ImhNoAutoUppercase
|
||||
background: Rectangle { color: "transparent" }
|
||||
enabled: !root.running
|
||||
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||
if (!(ev.modifiers & Qt.ShiftModifier)) {
|
||||
root.submit()
|
||||
ev.accepted = true
|
||||
}
|
||||
return
|
||||
}
|
||||
/* An image has nothing the editor could paste, so Ctrl+V
|
||||
* becomes an attachment there; text stays with the
|
||||
* editor's own paste. */
|
||||
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
||||
agent.clipboardHasImage()) {
|
||||
root.note = agent.attachClipboard()
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: attach
|
||||
objectName: "attachButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? Theme.touchTarget : 40
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
enabled: !root.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
||||
contentItem: ThemeIcon {
|
||||
name: "mail-attachment"
|
||||
glyph: "📎"
|
||||
size: 20
|
||||
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
}
|
||||
onClicked: picker.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
id: action
|
||||
objectName: "sendButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? "Stop" : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running ? agent.cancel() : root.submit()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: !action.enabled ? Theme.surfaceAlt
|
||||
: root.running ? (action.hovered ? "#e05666" : Theme.err)
|
||||
: (action.hovered ? "#5cc0f0" : Theme.accent)
|
||||
}
|
||||
contentItem: Text {
|
||||
text: action.text
|
||||
color: action.enabled ? Theme.accentInk : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a narrow window the hint would wrap into the editor's space.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !root.compact
|
||||
text: {
|
||||
if (root.running)
|
||||
return "working — Esc cancels the turn"
|
||||
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
|
||||
if (root.attachments.length > 0 && agent.seesImages() === 0)
|
||||
s += " · this model is text-only, images arrive as notes"
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: picker
|
||||
title: "Attach a file"
|
||||
fileMode: FileDialog.OpenFile
|
||||
onAccepted: root.attachPath(root.localPath(selectedFile))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's on_permission hook is blocked on this dialog: nothing runs
|
||||
// until one of the three answers goes back.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string tool: ""
|
||||
property string detail: ""
|
||||
property string preview: ""
|
||||
signal answered(int choice)
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(760, parent ? parent.width - 60 : 760)
|
||||
padding: Theme.pad
|
||||
title: ""
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: Theme.warn
|
||||
}
|
||||
Text {
|
||||
text: "Permission requested"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: dialog.tool
|
||||
color: Theme.warn
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.detail.length > 0
|
||||
text: dialog.detail
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
|
||||
visible: dialog.preview.length > 0
|
||||
color: Theme.bg
|
||||
radius: Theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentWidth: previewText.width
|
||||
contentHeight: previewText.height
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
Text {
|
||||
id: previewText
|
||||
width: Math.max(flick.width, implicitWidth)
|
||||
text: dialog.preview
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
spacing: Theme.gap
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Deny"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
|
||||
onClicked: { dialog.answered(0); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Once"
|
||||
highlighted: true
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: { dialog.answered(1); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Always"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
|
||||
onClicked: { dialog.answered(2); dialog.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The files a message or a tool call carries.
|
||||
//
|
||||
// A picture is a thumbnail from the session's own store (image://cstattach/,
|
||||
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
|
||||
// images when it is tapped. A file the agent delivered (deliver_file) is a card
|
||||
// with the name, the type, the size and two buttons: open and share. The names
|
||||
// are the ones the engine wrote next to the session -- a card never invents a
|
||||
// path.
|
||||
//
|
||||
// One line per file, written by AgentCore.cpp (filesText):
|
||||
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||||
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
|
||||
// <w>x<h>" for pictures; both shapes are read here.
|
||||
Flow {
|
||||
id: strip
|
||||
objectName: "imageStrip"
|
||||
/* A row carries this as text: a ListModel role cannot hold a list. */
|
||||
property string text: ""
|
||||
property var files: decode(text)
|
||||
/* The last refusal, so a tap that cannot work says why (the header's own
|
||||
* status line is the engine's). */
|
||||
property string errorText: ""
|
||||
spacing: 6
|
||||
|
||||
function decode(t) {
|
||||
const out = []
|
||||
const lines = String(t).split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].length)
|
||||
continue
|
||||
const f = lines[i].split("|")
|
||||
if (!f[0].length)
|
||||
continue
|
||||
const old = f.length < 7 // a picture line from an old session
|
||||
const dim = String(old ? f[3] : f[6]).split("x")
|
||||
let w = 0, h = 0
|
||||
if (dim.length === 2) {
|
||||
w = parseInt(dim[0], 10) || 0
|
||||
h = parseInt(dim[1], 10) || 0
|
||||
}
|
||||
const rel = old ? f[0] : f[1]
|
||||
out.push({ kind: old ? "img" : f[0],
|
||||
rel: rel,
|
||||
name: (old ? f[1] : f[2]) || rel,
|
||||
origin: (old ? f[2] : f[3]) || "",
|
||||
mime: old ? "image/*" : (f[4] || ""),
|
||||
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
|
||||
w: w, h: h,
|
||||
url: "image://cstattach/" + rel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function humanSize(n) {
|
||||
if (!n || n <= 0)
|
||||
return ""
|
||||
if (n >= 1024 * 1024)
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||
if (n >= 1024)
|
||||
return Math.round(n / 1024) + " KB"
|
||||
return n + " B"
|
||||
}
|
||||
|
||||
/* The two things a file card does. The engine answers with "" when it
|
||||
* worked (it puts the line in the header itself) or a reason. */
|
||||
function openFile(rel) {
|
||||
errorText = agent.openArtifact(rel)
|
||||
}
|
||||
function shareFile(rel) {
|
||||
errorText = agent.shareArtifact(rel)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: strip.files
|
||||
|
||||
delegate: Rectangle {
|
||||
id: cell
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool isFile: String(modelData.kind) !== "img"
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
/* Images keep their own shape, capped; a file card is a fixed
|
||||
* width so a long name cannot stretch the transcript. */
|
||||
readonly property real ratio: {
|
||||
const w = Number(modelData.w), h = Number(modelData.h)
|
||||
return (w > 0 && h > 0) ? w / h : 1.5
|
||||
}
|
||||
readonly property int cap: 150
|
||||
|
||||
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
|
||||
width: isFile ? 300
|
||||
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
|
||||
height: isFile ? 52
|
||||
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: hovered ? Theme.accentDim : Theme.border
|
||||
clip: true
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
// --- a picture: tap it to look at it ---------------------------
|
||||
Image {
|
||||
id: pic
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
visible: !cell.isFile && status === Image.Ready
|
||||
source: String(cell.modelData.url)
|
||||
sourceSize.width: 512
|
||||
sourceSize.height: 512
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: false
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 8
|
||||
visible: !cell.isFile && pic.status === Image.Error
|
||||
text: String(cell.modelData.name)
|
||||
elide: Text.ElideMiddle
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !cell.isFile
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
}
|
||||
|
||||
// --- a delivered file: name, type, open, share -----------------
|
||||
RowLayout {
|
||||
id: row
|
||||
visible: cell.isFile
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 6
|
||||
spacing: 6
|
||||
|
||||
/* The extension stands in for an icon: no theme lookup, and a
|
||||
* ZIP that says ZIP is more use than a generic sheet. */
|
||||
Rectangle {
|
||||
implicitWidth: 40
|
||||
implicitHeight: 30
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceHi
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
const n = String(cell.modelData.name)
|
||||
const dot = n.lastIndexOf(".")
|
||||
if (dot < 0 || dot === n.length - 1)
|
||||
return "FILE"
|
||||
return n.slice(dot + 1, dot + 5).toUpperCase()
|
||||
}
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: String(cell.modelData.name)
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const m = cell.modelData
|
||||
let s = m.mime ? String(m.mime) : ""
|
||||
const size = strip.humanSize(m.bytes)
|
||||
if (size)
|
||||
s += (s ? " · " : "") + size
|
||||
if (m.origin && m.origin !== "user" && m.origin !== "tool")
|
||||
s += (s ? " · " : "") + m.origin
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileOpen" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Open"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileShare" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.shareFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Share"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: {
|
||||
const m = cell.modelData
|
||||
let s = String(m.name)
|
||||
if (m.w > 0 && m.h > 0)
|
||||
s += " · " + m.w + "×" + m.h
|
||||
if (m.mime && m.kind !== "img")
|
||||
s += " · " + m.mime
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A tap that could not work says why, right where the card is: a phone
|
||||
* with nothing installed for the type is the common case ("no app on the
|
||||
* phone opens report.md"), and a silence there looks like a dead button. */
|
||||
Text {
|
||||
objectName: "fileError"
|
||||
visible: strip.errorText.length > 0
|
||||
width: strip.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: strip.errorText
|
||||
color: Theme.err
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
// The window: a sidebar with the engine's settings, the transcript, the
|
||||
// composer, and the two dialogs the engine can block on.
|
||||
//
|
||||
// One shell, three sizes: the sidebar is docked on a desktop (and can be
|
||||
// dragged), slides over the transcript when the window is narrow, and every
|
||||
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
|
||||
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
|
||||
// swapping in org.kde.kirigami later stays a QML change.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ApplicationWindow {
|
||||
id: win
|
||||
width: initialWidth > 0 ? initialWidth : 1180
|
||||
height: initialHeight > 0 ? initialHeight : 800
|
||||
minimumWidth: 360
|
||||
minimumHeight: 420
|
||||
visible: true
|
||||
color: Theme.bg
|
||||
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
|
||||
|
||||
property string statusText: ""
|
||||
property var sessionsView: [] // the store, for the sidebar
|
||||
property bool navOpen: false // the sliding sidebar, compact only
|
||||
property int sidebarWidth: 0 // 0 = follow the window width
|
||||
readonly property bool compact: width < Theme.compactBreakpoint
|
||||
readonly property int sidebarWide: sidebarWidth > 0
|
||||
? sidebarWidth
|
||||
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
|
||||
|
||||
// The settings panel, used docked and in the sliding panel: one definition,
|
||||
// two places.
|
||||
component ShellSidebar: Sidebar {
|
||||
workspace: agent.workspace
|
||||
provider: agent.provider
|
||||
model: agent.model
|
||||
mode: agent.mode
|
||||
approval: agent.approval
|
||||
planMode: agent.planMode
|
||||
running: agent.running
|
||||
status: win.statusText
|
||||
todo: agent.todo
|
||||
sessions: win.sessionsView
|
||||
sessionId: agent.sessionId
|
||||
onModeRequested: function (m) { agent.setMode(m) }
|
||||
onSessionRequested: function (id) { win.switchSession(id) }
|
||||
onSessionNewRequested: win.newSession()
|
||||
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
|
||||
onApprovalRequested: function (a) { agent.setApproval(a) }
|
||||
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
|
||||
}
|
||||
|
||||
// ---- the transcript --------------------------------------------------
|
||||
ListModel { id: transcript }
|
||||
|
||||
function lastIndex() { return transcript.count - 1 }
|
||||
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
|
||||
|
||||
function addRow(kind, role, state) {
|
||||
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
|
||||
name: "", callId: "", state: state, detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
}
|
||||
|
||||
// The engine streams one kind at a time: close whatever was open before.
|
||||
function closeOpenRows(exceptKind) {
|
||||
const last = lastRow()
|
||||
if (last && !last.complete && last.kind !== exceptKind)
|
||||
transcript.setProperty(lastIndex(), "complete", true)
|
||||
}
|
||||
|
||||
function appendDelta(kind, role, text) {
|
||||
closeOpenRows(kind)
|
||||
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
|
||||
addRow(kind, role, "running")
|
||||
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
|
||||
}
|
||||
|
||||
// The open card of this call, -1 when none. The id is what tells two calls
|
||||
// of the same tool apart (pairing by name left the first card running
|
||||
// forever); a call without one -- an old text-protocol session -- falls
|
||||
// back to the name.
|
||||
function findTool(callId, name) {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "tool" || r.complete)
|
||||
continue
|
||||
if (callId.length) {
|
||||
if (r.callId === callId)
|
||||
return i
|
||||
continue /* another call: its card is further back */
|
||||
}
|
||||
if (r.callId.length)
|
||||
continue
|
||||
if (r.name === name)
|
||||
return i
|
||||
return -1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function openTool(callId, name, state) {
|
||||
closeOpenRows("tool")
|
||||
addRow("tool", "tool", state)
|
||||
transcript.setProperty(lastIndex(), "name", name)
|
||||
transcript.setProperty(lastIndex(), "callId", callId)
|
||||
return lastIndex()
|
||||
}
|
||||
|
||||
function submit(text) {
|
||||
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
|
||||
name: "", callId: "", state: "", detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
agent.send(text)
|
||||
}
|
||||
|
||||
// A turn is over: nothing else is coming, so nothing may keep spinning.
|
||||
function closeAllRows() {
|
||||
for (let i = 0; i < transcript.count; i++) {
|
||||
const r = transcript.get(i)
|
||||
if (r.complete)
|
||||
continue
|
||||
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
|
||||
r.state === "running"))
|
||||
transcript.setProperty(i, "state", "unfinished")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown while it streams: the open answer is re-rendered with the
|
||||
// engine's own renderer (the timer below), so the text fills in formatted
|
||||
// instead of turning into markdown when the turn ends.
|
||||
function renderStreaming() {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "message" || r.role !== "agent" || r.complete)
|
||||
continue
|
||||
const html = agent.renderMarkdown(r.text)
|
||||
if (html !== r.html)
|
||||
transcript.setProperty(i, "html", html)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sessions --------------------------------------------------------
|
||||
function reloadSessions() { win.sessionsView = agent.sessions() }
|
||||
|
||||
// A stored session comes back as rows: the engine holds the transcript
|
||||
// (AgentCore::history()), these rows only mirror it.
|
||||
function replay() {
|
||||
transcript.clear()
|
||||
const rows = agent.history()
|
||||
for (let i = 0; i < rows.length; i++)
|
||||
transcript.append(rows[i])
|
||||
chat.stickBottom = true
|
||||
}
|
||||
|
||||
function switchSession(id) {
|
||||
if (agent.switchSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
if (agent.newSession()) {
|
||||
transcript.clear()
|
||||
win.reloadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSession(id) {
|
||||
if (agent.deleteSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
// ---- provider, model, key --------------------------------------------
|
||||
function openSettings() {
|
||||
settings.load(agent.providers(), agent.provider, agent.model)
|
||||
settings.open()
|
||||
}
|
||||
|
||||
function applySettings(provider, model, key, temperature, topP, maxTokens) {
|
||||
/* The parameters are stored first: useProvider() then picks up what
|
||||
* was remembered for whichever provider it ends on. */
|
||||
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
|
||||
const err = perr.length ? perr : agent.useProvider(provider, model, key)
|
||||
settings.note = err.length ? err
|
||||
: "using " + agent.provider + " / " + agent.model
|
||||
settings.choices = agent.modelChoices(provider)
|
||||
settings.syncPick()
|
||||
}
|
||||
|
||||
/* The engine opens the workspace's last session (cst_agent_open()), so the
|
||||
* window is created with a transcript already in it: draw it, or the pane
|
||||
* starts empty while the sidebar points at the very session it belongs to.
|
||||
* A switch draws it the same way (the sessionChanged handler below). */
|
||||
Component.onCompleted: {
|
||||
win.reloadSessions()
|
||||
win.replay()
|
||||
}
|
||||
|
||||
// ---- the shell -------------------------------------------------------
|
||||
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
|
||||
* to a style-owned contentItem, and on this phone that item sat 35 px below
|
||||
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
|
||||
* was drawn, and clicked, outside the header, over the sidebar. A
|
||||
* Rectangle puts its children where it is (checked with --uitest on the
|
||||
* device and on both styles at home). */
|
||||
header: Rectangle {
|
||||
id: appBar
|
||||
objectName: "appBar"
|
||||
implicitHeight: Theme.headerHeight
|
||||
color: Theme.surface
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.border
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.pad
|
||||
anchors.rightMargin: Theme.gap
|
||||
spacing: Theme.gap
|
||||
|
||||
// The docked sidebar has its own title: only the sliding one needs
|
||||
// a way in.
|
||||
ToolButton {
|
||||
objectName: "navButton"
|
||||
visible: win.compact
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered && !win.compact
|
||||
ToolTip.text: "Settings and plan"
|
||||
contentItem: ThemeIcon {
|
||||
name: "application-menu"
|
||||
glyph: "☰"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: win.navOpen = !win.navOpen
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !win.compact
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
text: agent.workspace.length ? agent.workspace : "(no workspace)"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
objectName: "statusText"
|
||||
visible: win.statusText.length > 0 && !win.compact
|
||||
text: win.statusText
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
Layout.maximumWidth: 260
|
||||
}
|
||||
BusyIndicator {
|
||||
running: agent.running
|
||||
visible: agent.running
|
||||
implicitWidth: 22
|
||||
implicitHeight: 22
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "settingsButton"
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Provider, model and key"
|
||||
contentItem: ThemeIcon {
|
||||
name: "settings-configure"
|
||||
glyph: "⚙"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: win.openSettings()
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "clearButton"
|
||||
enabled: transcript.count > 0 && !agent.running
|
||||
implicitWidth: win.compact ? Theme.touchTarget : 56
|
||||
implicitHeight: Theme.touchTarget
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
|
||||
contentItem: Text {
|
||||
text: "Clear"
|
||||
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: {
|
||||
transcript.clear()
|
||||
win.statusText = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
ShellSidebar {
|
||||
id: dockedSidebar
|
||||
objectName: "dockedSidebar"
|
||||
visible: !win.compact
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: win.sidebarWide
|
||||
}
|
||||
|
||||
// Drag the divider: a mouse affordance that costs nothing on touch
|
||||
// (the handle is only there while the sidebar is docked).
|
||||
Item {
|
||||
id: divider
|
||||
visible: !win.compact
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: 5
|
||||
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 1
|
||||
height: parent.height
|
||||
color: dividerMa.pressed ? Theme.accentDim
|
||||
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
|
||||
}
|
||||
MouseArea {
|
||||
id: dividerMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.SplitHCursor
|
||||
property real startX: 0
|
||||
property int startW: 0
|
||||
onPressed: function (mouse) {
|
||||
startX = mouse.x
|
||||
startW = win.sidebarWide
|
||||
}
|
||||
onPositionChanged: function (mouse) {
|
||||
if (pressed)
|
||||
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
|
||||
}
|
||||
onDoubleClicked: win.sidebarWidth = 0
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
ChatView {
|
||||
id: chat
|
||||
objectName: "chatView"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: transcript
|
||||
running: agent.running
|
||||
}
|
||||
|
||||
Composer {
|
||||
id: composer
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
|
||||
running: agent.running
|
||||
compact: win.compact
|
||||
attachments: agent.pending
|
||||
onSubmitted: function (text) { win.submit(text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- compact: the sidebar slides in over the transcript --------------
|
||||
/* Files dropped anywhere on the window: the engine copies them next to
|
||||
* the session and the next prompt carries them. The picker and Ctrl+V in
|
||||
* the composer land in the same list. */
|
||||
DropArea {
|
||||
id: dropZone
|
||||
objectName: "dropZone"
|
||||
anchors.fill: parent
|
||||
onDropped: function (drop) {
|
||||
if (!drop.hasUrls)
|
||||
return
|
||||
let note = ""
|
||||
let n = 0
|
||||
for (let i = 0; i < drop.urls.length; i++) {
|
||||
const r = agent.attachFile(composer.localPath(drop.urls[i]))
|
||||
if (r.length)
|
||||
note = r
|
||||
else
|
||||
n++
|
||||
}
|
||||
composer.note = note.length ? note
|
||||
: n + (n === 1 ? " file attached" : " files attached")
|
||||
drop.accept()
|
||||
}
|
||||
}
|
||||
|
||||
// While something is dragged over the window, say what will happen.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: dropZone.containsDrag
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: Theme.accent
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "drop to attach"
|
||||
color: Theme.accent
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: scrim
|
||||
objectName: "navScrim"
|
||||
visible: win.navOpen && win.compact
|
||||
anchors.fill: parent
|
||||
color: "#000000"
|
||||
opacity: 0.45
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: win.navOpen = false
|
||||
onWheel: function (wheel) { wheel.accepted = true }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: navPanel
|
||||
objectName: "navPanel"
|
||||
visible: win.navOpen && win.compact
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
width: Math.min(320, win.width * 0.88)
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
x: win.navOpen && win.compact ? 0 : -width
|
||||
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: panelSidebar.implicitHeight
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ShellSidebar {
|
||||
id: panelSidebar
|
||||
width: parent.width
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConsentDialog {
|
||||
id: consent
|
||||
onAnswered: function (choice) { agent.answerPermission(choice) }
|
||||
}
|
||||
|
||||
SettingsDialog {
|
||||
id: settings
|
||||
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
|
||||
win.applySettings(provider, model, key, temperature, topP, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
AskDialog {
|
||||
id: ask
|
||||
onAnswered: function (text) { agent.answerQuestion(text) }
|
||||
}
|
||||
|
||||
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
|
||||
Shortcut {
|
||||
sequence: "Ctrl+L"
|
||||
enabled: !agent.running
|
||||
onActivated: {
|
||||
transcript.clear()
|
||||
win.statusText = ""
|
||||
}
|
||||
}
|
||||
/* The back key a phone has instead of a mouse: it closes what is open, one
|
||||
* layer at a time -- the dialog on top, then the sliding sidebar. With
|
||||
* nothing open the key goes on to the platform, and a phone leaves the
|
||||
* window (Qt finishes the activity), which is right for the last layer.
|
||||
*
|
||||
* Which layer that is, and whether there is one, lives in one place: the
|
||||
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
|
||||
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
|
||||
* lets through. --uitest reads the same two functions. */
|
||||
function backLayer() {
|
||||
if (settings.visible)
|
||||
return "settings"
|
||||
if (ask.visible)
|
||||
return "ask"
|
||||
if (consent.visible)
|
||||
return "consent"
|
||||
if (win.navOpen)
|
||||
return "drawer"
|
||||
return ""
|
||||
}
|
||||
|
||||
/* One layer back. The dialogs are closed on the next turn of the event
|
||||
* loop: closing one from inside the key event leaves its modal overlay up
|
||||
* in this Qt (the dialog says it is closed, the overlay still eats every
|
||||
* click after it), which --uitest walks straight into. */
|
||||
function stepBack() {
|
||||
if (settings.visible)
|
||||
Qt.callLater(settings.close)
|
||||
else if (ask.visible)
|
||||
Qt.callLater(ask.close)
|
||||
else if (consent.visible)
|
||||
Qt.callLater(consent.close)
|
||||
else if (win.navOpen)
|
||||
win.navOpen = false
|
||||
}
|
||||
|
||||
|
||||
// ---- engine -> UI ----------------------------------------------------
|
||||
/* The signal handlers only forward: every one of them has a function
|
||||
* here, so the same path can be driven from --uitest (which is how the
|
||||
* two-calls-of-one-tool case stays fixed). */
|
||||
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
|
||||
function engineThink(text) { win.appendDelta("think", "agent", text) }
|
||||
|
||||
function engineToolEvent(phase, id, name, detail) {
|
||||
win.closeOpenRows("tool")
|
||||
let i = win.findTool(id, name)
|
||||
if (phase === "open" || phase === "arg") {
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, "pending")
|
||||
if (detail.length)
|
||||
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
|
||||
return
|
||||
}
|
||||
if (phase === "ready" || phase === "run") {
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
|
||||
transcript.setProperty(i, "detail", detail)
|
||||
return
|
||||
}
|
||||
// done | denied
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase)
|
||||
if (detail.length && transcript.get(i).output.length === 0)
|
||||
transcript.setProperty(i, "output", detail)
|
||||
}
|
||||
|
||||
function engineToolResult(id, name, exitCode, output) {
|
||||
let i = win.findTool(id, name)
|
||||
if (i < 0) /* a result whose call is not in the view: its own card */
|
||||
i = win.openTool(id, name, "done")
|
||||
transcript.setProperty(i, "output", output)
|
||||
transcript.setProperty(i, "exitCode", exitCode)
|
||||
transcript.setProperty(i, "state", "done")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
|
||||
function engineTurnFinished(rc, err) {
|
||||
win.renderStreaming(); /* one last render while the row is still open */
|
||||
win.closeAllRows()
|
||||
if (err.length)
|
||||
transcript.append({ kind: "note", role: "error", text: err, html: "",
|
||||
complete: true, name: "", callId: "", state: "error",
|
||||
detail: "", output: "", exitCode: 0 })
|
||||
win.statusText = ""
|
||||
}
|
||||
|
||||
/* The images the prompt just carried (AgentCore::promptImages): the
|
||||
* composer has appended the user row, so they attach to it. */
|
||||
function enginePromptImages(images) {
|
||||
if (!images.length)
|
||||
return
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind === "message" && r.role === "user") {
|
||||
transcript.setProperty(i, "images", images)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Files a tool produced, on the card of the call that made them. The
|
||||
* engine reports the result first and the files after it, so the card is
|
||||
* normally already complete by the time they arrive -- findTool() only
|
||||
* looks at cards still running, which is why this looks for the call id
|
||||
* itself (and falls back to findTool for a call that has no id). */
|
||||
function engineToolImages(callId, images) {
|
||||
if (!images.length)
|
||||
return
|
||||
let i = -1
|
||||
if (callId.length) {
|
||||
for (let k = transcript.count - 1; k >= 0; k--) {
|
||||
const r = transcript.get(k)
|
||||
if (r.kind === "tool" && r.callId === callId) {
|
||||
i = k
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i = win.findTool("", "")
|
||||
}
|
||||
if (i >= 0)
|
||||
transcript.setProperty(i, "images", images)
|
||||
}
|
||||
|
||||
/* For --uitest: one transcript row, or null. */
|
||||
function rowAt(i) {
|
||||
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
|
||||
}
|
||||
|
||||
/* For --uitest: the same row as JSON (a QObject's return value does not
|
||||
* survive QMetaObject::invokeMethod, a string does). */
|
||||
function rowJson(i) {
|
||||
return JSON.stringify(rowAt(i))
|
||||
}
|
||||
|
||||
/* While a turn runs the open answer is re-rendered a few times a second:
|
||||
* markdown text fills in formatted rather than at the end of the turn. */
|
||||
Timer {
|
||||
interval: 120
|
||||
repeat: true
|
||||
running: agent.running
|
||||
onTriggered: win.renderStreaming()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: agent
|
||||
|
||||
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
||||
function onSessionListChanged() { win.reloadSessions() }
|
||||
|
||||
function onAssistantDelta(text) { win.engineAssistant(text) }
|
||||
function onThinkDelta(text) { win.engineThink(text) }
|
||||
|
||||
function onStatusChanged(text) { win.statusText = text }
|
||||
|
||||
function onToolEvent(phase, id, name, detail) {
|
||||
win.engineToolEvent(phase, id, name, detail)
|
||||
}
|
||||
|
||||
function onToolResult(id, name, exitCode, output) {
|
||||
win.engineToolResult(id, name, exitCode, output)
|
||||
}
|
||||
|
||||
function onToolImages(callId, images) {
|
||||
win.engineToolImages(callId, images)
|
||||
}
|
||||
|
||||
function onPromptImages(images) {
|
||||
win.enginePromptImages(images)
|
||||
}
|
||||
|
||||
function onPermissionRequested(tool, detail, preview) {
|
||||
consent.tool = tool
|
||||
consent.detail = detail
|
||||
consent.preview = preview
|
||||
consent.open()
|
||||
}
|
||||
|
||||
function onQuestionAsked(question, options) {
|
||||
ask.question = question
|
||||
ask.optionText = options
|
||||
ask.open()
|
||||
}
|
||||
|
||||
/* Nobody answered (the app was in the background, or the dialog sat
|
||||
* there too long): the turn went on without it, so put the dialog
|
||||
* away instead of leaving a question that is no longer being asked. */
|
||||
function onPermissionExpired(tool) { consent.close() }
|
||||
function onQuestionExpired() { ask.close() }
|
||||
|
||||
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||
|
||||
function onRunningChanged() {
|
||||
if (!agent.running)
|
||||
win.closeAllRows()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One message in the transcript: the prompt, the answer, or an error. The
|
||||
// answer arrives as plain text while it streams and is rendered from markdown
|
||||
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string role: "agent" // user | agent | error
|
||||
property string text: ""
|
||||
property string html: ""
|
||||
property bool complete: true
|
||||
property string images: ""
|
||||
|
||||
radius: Theme.radius
|
||||
color: role === "user" ? Theme.surfaceAlt : Theme.surface
|
||||
border.width: 1
|
||||
border.color: role === "error" ? Theme.err
|
||||
: role === "user" ? Theme.border : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.role === "user" ? Theme.accent
|
||||
: card.role === "error" ? Theme.err : Theme.ok
|
||||
}
|
||||
Text {
|
||||
text: card.role === "user" ? "You"
|
||||
: card.role === "error" ? "Error" : "CST-agent"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
visible: !card.complete
|
||||
text: "streaming…"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.text.length > 0
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Copy"
|
||||
contentItem: ThemeIcon {
|
||||
name: "edit-copy"
|
||||
glyph: "⧉"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: {
|
||||
plain.text = card.text
|
||||
plain.selectAll()
|
||||
plain.copy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden holder so "copy" always has the plain text, rtf or not.
|
||||
TextEdit {
|
||||
id: plain
|
||||
visible: false
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: streamText
|
||||
visible: card.html.length === 0
|
||||
Layout.fillWidth: true
|
||||
text: card.text
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: card.role === "error" ? Theme.err : Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectionColor: Theme.accentDim
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: card.html.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: card.html
|
||||
textFormat: Text.RichText
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
|
||||
}
|
||||
|
||||
// What the prompt carried: pictures from the store, not their names.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A one-line note from the engine (status text, a refusal, a hint).
|
||||
RowLayout {
|
||||
id: note
|
||||
property string text: ""
|
||||
property string level: "info" // info | warn | error
|
||||
spacing: Theme.gap
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 6
|
||||
width: 5
|
||||
height: 5
|
||||
radius: 3
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: note.text
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One stored session in the sidebar: its name (a rename, else its first
|
||||
// prompt), how much is in it and when it was last written, and a way to drop
|
||||
// it. The open one is marked. A file of its own because the sidebar lists them
|
||||
// through a Repeater -- an inline component would not see modelData there.
|
||||
Rectangle {
|
||||
id: srow
|
||||
property var entry: null
|
||||
readonly property bool open: entry !== null && entry.current === true
|
||||
|
||||
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
|
||||
implicitHeight: rowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: srow.open ? Theme.accentDim
|
||||
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
|
||||
border.width: 1
|
||||
border.color: srow.open ? Theme.accent : "transparent"
|
||||
|
||||
signal activated(string id)
|
||||
signal removeRequested(string id)
|
||||
|
||||
// "3h ago"; the entries carry ms since the epoch.
|
||||
function ago(ms) {
|
||||
if (!ms)
|
||||
return "never"
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60)
|
||||
return "just now"
|
||||
if (s < 3600)
|
||||
return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400)
|
||||
return Math.floor(s / 3600) + "h ago"
|
||||
return Math.floor(s / 86400) + "d ago"
|
||||
}
|
||||
|
||||
HoverHandler { id: rowHover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: srow.activated(srow.entry.id)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
ColumnLayout {
|
||||
id: rowCol
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
|
||||
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
|
||||
: "(no prompt yet)"
|
||||
color: srow.open ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: {
|
||||
if (srow.entry === null)
|
||||
return ""
|
||||
const n = srow.entry.messages
|
||||
const when = srow.ago(srow.entry.mtime)
|
||||
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
|
||||
: when
|
||||
}
|
||||
color: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Delete this session and its transcript"
|
||||
contentItem: ThemeIcon {
|
||||
name: "edit-delete"
|
||||
glyph: "×"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.err
|
||||
: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: srow.removeRequested(srow.entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Provider, model and key: what the engine needs before it can talk to a model
|
||||
// at all. Everything here goes through AgentCore, which moves the engine's own
|
||||
// config (its session stays where it is) and remembers the choice. A key is
|
||||
// verified against the provider before it is saved and is never read back into
|
||||
// this window -- the dialog only ever says where the key it will use comes
|
||||
// from.
|
||||
//
|
||||
// Three things live under "Advanced options", because they are for the odd
|
||||
// provider or the odd model: the provider's own entry (name, base URL, key
|
||||
// variable, models -- the engine keeps those in <config>/models.json, which is
|
||||
// how a provider the presets do not know is added or an existing one is
|
||||
// pointed somewhere else) and the generation parameters (temperature, top-p,
|
||||
// max tokens), which are remembered per provider.
|
||||
Dialog {
|
||||
id: dlg
|
||||
objectName: "settingsDialog"
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
padding: Theme.pad
|
||||
|
||||
property var providers: [] // AgentCore::providers()
|
||||
property var choices: [] // AgentCore::modelChoices(picked)
|
||||
property string picked: ""
|
||||
property string model: ""
|
||||
property string note: "" // what the last action answered
|
||||
property string keyEnv: ""
|
||||
property string keyFrom: ""
|
||||
property bool hasKey: false
|
||||
property bool advanced: false
|
||||
property string modelFilter: "" // narrows a long model list
|
||||
/* The advanced form: the picked provider's entry, editable. */
|
||||
property string advName: ""
|
||||
property string advBase: ""
|
||||
property string advKeyEnv: ""
|
||||
property string advModel: ""
|
||||
property string advModels: ""
|
||||
property string tempText: ""
|
||||
property string topPText: ""
|
||||
property string maxTokText: ""
|
||||
|
||||
signal applyRequested(string provider, string model, string key,
|
||||
real temperature, real topP, int maxTokens)
|
||||
|
||||
width: Math.min(660, parent ? parent.width - 32 : 660)
|
||||
height: Math.min(640, parent ? parent.height - 40 : 640)
|
||||
|
||||
/* The Basic style paints a light panel; this window is dark, so the
|
||||
* dialog brings its own surface like every other one here. */
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
radius: Theme.radius
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
|
||||
// A long provider list (a router lists hundreds) is a wall of chips: the
|
||||
// filter narrows it and the rest is one line ("+N more").
|
||||
readonly property int chipCap: 40
|
||||
readonly property var chips: {
|
||||
const f = modelFilter.trim().toLowerCase()
|
||||
const out = []
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const c = String(choices[i])
|
||||
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
|
||||
out.push(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
|
||||
|
||||
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
|
||||
* which is not the same as sending 0). */
|
||||
function num(text) {
|
||||
const v = parseFloat(String(text).replace(",", "."))
|
||||
return isFinite(v) && v > 0 ? v : 0
|
||||
}
|
||||
|
||||
function pickedEntry() {
|
||||
for (let i = 0; i < providers.length; i++)
|
||||
if (providers[i].name === dlg.picked)
|
||||
return providers[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/* Fill it in: the presets, the provider the engine is on now, and the
|
||||
* model that provider last used. */
|
||||
function load(list, provider, currentModel) {
|
||||
providers = list
|
||||
picked = provider
|
||||
model = currentModel
|
||||
note = ""
|
||||
for (let i = 0; i < list.length; i++)
|
||||
if (list[i].current === true)
|
||||
picked = list[i].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
modelFilter = ""
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* Where the picked provider's key would come from and what it is set to.
|
||||
* From the entries -- a key itself never reaches the QML side. */
|
||||
function syncPick() {
|
||||
hasKey = false
|
||||
keyFrom = ""
|
||||
keyEnv = ""
|
||||
const p = pickedEntry()
|
||||
if (p) {
|
||||
hasKey = p.hasKey === true
|
||||
keyFrom = p.keyFrom
|
||||
keyEnv = p.keyEnv
|
||||
/* The advanced form describes the picked provider: edit it, or
|
||||
* type another name to add one. */
|
||||
advName = p.name
|
||||
advBase = p.base
|
||||
advKeyEnv = p.keyEnv
|
||||
advModel = p.model
|
||||
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
|
||||
}
|
||||
const g = picked.length ? agent.genParams(picked) : null
|
||||
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
|
||||
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
|
||||
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
|
||||
}
|
||||
|
||||
function keyState() {
|
||||
if (hasKey)
|
||||
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
|
||||
: "key from the environment: " + keyFrom
|
||||
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
|
||||
: "no key yet — paste one here"
|
||||
}
|
||||
|
||||
function usageLine() {
|
||||
const u = agent.usage()
|
||||
if (!u.requests)
|
||||
return "no tokens spent in this session yet"
|
||||
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
|
||||
" · " + u.prompt + " in · " + u.out + " out"
|
||||
if (u.priced)
|
||||
s += " · " + u.cost.toFixed(4) + " " + u.currency
|
||||
return s
|
||||
}
|
||||
|
||||
/* Write the form's entry into the engine's user file and list it again. */
|
||||
function saveProvider() {
|
||||
/* The fields are the truth here, not the properties: a hand-typed
|
||||
* edit is what the user wants saved. */
|
||||
const name = advNameField.text.trim()
|
||||
const err = agent.addProvider(name, advBaseField.text.trim(),
|
||||
advKeyEnvField.text.trim(),
|
||||
advModelField.text.trim(),
|
||||
advModelsField.text)
|
||||
if (err.length) {
|
||||
note = err
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
picked = name
|
||||
choices = agent.modelChoices(name)
|
||||
syncPick()
|
||||
note = "saved " + name + " to models.json — it is in the list above now"
|
||||
}
|
||||
|
||||
function removeProvider() {
|
||||
const typed = advNameField.text.trim()
|
||||
const name = typed.length ? typed : picked
|
||||
const rc = agent.removeProvider(name)
|
||||
if (rc < 0) {
|
||||
note = "could not remove " + name
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
note = rc === 0
|
||||
? name + " is not in models.json (a preset of that name stays)"
|
||||
: "removed " + name + " from models.json"
|
||||
if (rc === 1 && providers.length > 0)
|
||||
picked = providers[0].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* The form's fields follow the picked provider (and a user edit breaks
|
||||
* the binding, so the push is imperative). */
|
||||
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
|
||||
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
|
||||
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
|
||||
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
|
||||
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
|
||||
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
|
||||
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
|
||||
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
|
||||
|
||||
// One labelled field of the advanced form.
|
||||
component AdvField: ColumnLayout {
|
||||
property string label: ""
|
||||
property alias text: field.text
|
||||
property alias placeholder: field.placeholderText
|
||||
property int hintWidth: 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: parent.label
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TextField {
|
||||
id: field
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: field.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
objectName: "settingsPanel"
|
||||
spacing: Theme.gap
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider, model, key"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* Everything scrolls, so the buttons stay where they are and a long
|
||||
* model list cannot push them out of the dialog. */
|
||||
Flickable {
|
||||
id: body
|
||||
objectName: "settingsBody"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: bodyCol.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: bodyCol
|
||||
width: parent.width
|
||||
spacing: Theme.gap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: dlg.providers
|
||||
delegate: Rectangle {
|
||||
id: prow
|
||||
required property var modelData
|
||||
readonly property bool selected: dlg.picked === modelData.name
|
||||
|
||||
objectName: "providerRow:" + modelData.name
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: prowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: prow.selected ? Theme.accentDim
|
||||
: prowHover.hovered ? Theme.surfaceAlt
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: prow.selected ? Theme.accent : "transparent"
|
||||
|
||||
HoverHandler { id: prowHover }
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
dlg.picked = prow.modelData.name
|
||||
modelField.text = prow.modelData.model
|
||||
dlg.choices = agent.modelChoices(prow.modelData.name)
|
||||
dlg.modelFilter = ""
|
||||
dlg.syncPick()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: prowCol
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: prow.modelData.name
|
||||
color: prow.selected ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: prow.selected
|
||||
}
|
||||
Text {
|
||||
visible: prow.modelData.current === true
|
||||
text: "in use"
|
||||
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: prow.modelData.custom === true
|
||||
? "custom" : prow.modelData.source
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
text: prow.modelData.base + " · " +
|
||||
(prow.modelData.hasKey === true
|
||||
? (prow.modelData.keyFrom === "saved"
|
||||
? "key saved"
|
||||
: "key from " + prow.modelData.keyFrom)
|
||||
: (prow.modelData.keyEnv.length
|
||||
? "no key (" + prow.modelData.keyEnv + ")"
|
||||
: "no key"))
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Model"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: modelField
|
||||
objectName: "settingsModel"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.model
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "model name"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: modelField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 90
|
||||
elide: Text.ElideRight
|
||||
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
/* A router can list hundreds of models: filter, cap, say how
|
||||
* many are left instead of filling the dialog. */
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.choices.length > 12
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: filterField
|
||||
objectName: "settingsFilter"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.modelFilter
|
||||
implicitHeight: Theme.segmentHeight
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "filter " + dlg.choices.length + " models"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
onTextChanged: dlg.modelFilter = text
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: filterField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 110
|
||||
elide: Text.ElideRight
|
||||
text: dlg.chips.length + " shown"
|
||||
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: dlg.choices.length > 0
|
||||
|
||||
Repeater {
|
||||
model: dlg.chips.slice(0, dlg.chipCap)
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
readonly property string name: String(chip.modelData)
|
||||
|
||||
objectName: "modelChoice:" + chip.name
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 26
|
||||
radius: Theme.radiusSm
|
||||
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: modelField.text === chip.name ? Theme.accent
|
||||
: Theme.border
|
||||
HoverHandler { id: chipHover }
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: chip.name
|
||||
color: modelField.text === chip.name ? Theme.text
|
||||
: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TapHandler { onTapped: modelField.text = chip.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: keyField
|
||||
objectName: "settingsKey"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
echoMode: TextInput.Password
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "API key (leave empty to keep what is there)"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
||||
onAccepted: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dlg.keyState()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* --- advanced ------------------------------------------- */
|
||||
ToolButton {
|
||||
id: advToggle
|
||||
objectName: "settingsAdvanced"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
onClicked: dlg.advanced = !dlg.advanced
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
ThemeIcon {
|
||||
name: dlg.advanced ? "arrow-down" : "arrow-right"
|
||||
glyph: dlg.advanced ? "▾" : "▸"
|
||||
size: 14
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: "Advanced options"
|
||||
color: advToggle.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: "custom provider · model parameters"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.advanced
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
|
||||
"A name the presets know is overridden, a new one is added."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: Theme.gap
|
||||
rowSpacing: 4
|
||||
|
||||
AdvField {
|
||||
id: advNameField
|
||||
objectName: "advName"
|
||||
label: "Name"
|
||||
placeholder: "myllm"
|
||||
Layout.preferredWidth: 180
|
||||
}
|
||||
AdvField {
|
||||
id: advBaseField
|
||||
objectName: "advBase"
|
||||
label: "Base URL"
|
||||
placeholder: "https://api.example.com/v1"
|
||||
}
|
||||
AdvField {
|
||||
id: advKeyEnvField
|
||||
objectName: "advKeyEnv"
|
||||
label: "Key variable"
|
||||
placeholder: "MYLLM_API_KEY"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelField
|
||||
objectName: "advModel"
|
||||
label: "Default model"
|
||||
placeholder: "the model to start with"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelsField
|
||||
objectName: "advModels"
|
||||
label: "Models it offers (comma separated, optional)"
|
||||
placeholder: "m1, m2"
|
||||
Layout.columnSpan: 2
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Button {
|
||||
id: saveProviderBtn
|
||||
objectName: "settingsSaveProvider"
|
||||
text: "Save provider"
|
||||
implicitHeight: Theme.touchTarget
|
||||
enabled: advNameField.text.trim().length > 0 &&
|
||||
advBaseField.text.trim().length > 0
|
||||
onClicked: dlg.saveProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: saveProviderBtn.text
|
||||
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: removeProviderBtn
|
||||
objectName: "settingsRemoveProvider"
|
||||
text: "Remove from models.json"
|
||||
implicitHeight: Theme.touchTarget
|
||||
readonly property var entry: dlg.pickedEntry()
|
||||
enabled: entry !== null && entry.custom === true
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
|
||||
onClicked: dlg.removeProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: removeProviderBtn.hovered && removeProviderBtn.enabled
|
||||
? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: removeProviderBtn.text
|
||||
color: removeProviderBtn.enabled ? Theme.textDim
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: "Model parameters — remembered for " +
|
||||
(dlg.picked.length ? dlg.picked : "this provider") +
|
||||
", sent with every request. Empty = the provider's own default."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
AdvField {
|
||||
id: tempField
|
||||
objectName: "advTemperature"
|
||||
label: "Temperature"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: topPField
|
||||
objectName: "advTopP"
|
||||
label: "Top-p"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: maxTokField
|
||||
objectName: "advMaxTokens"
|
||||
label: "Max tokens"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 150
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
objectName: "settingsNote"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.note.length ? dlg.note : dlg.usageLine()
|
||||
color: dlg.note.length ? Theme.warn : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Button {
|
||||
id: closeBtn
|
||||
objectName: "settingsClose"
|
||||
text: "Close"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.close()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: closeBtn.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: applyBtn
|
||||
objectName: "settingsApply"
|
||||
text: "Use this"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
|
||||
}
|
||||
contentItem: Text {
|
||||
text: applyBtn.text
|
||||
color: Theme.accentInk
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The model parameters belong to the provider that is picked, so they are
|
||||
* applied first: useProvider() then takes over what was remembered for
|
||||
* whichever provider it ends on. */
|
||||
function apply() {
|
||||
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
|
||||
dlg.num(tempField.text), dlg.num(topPField.text),
|
||||
Math.round(dlg.num(maxTokField.text)))
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
keyField.text = ""
|
||||
modelField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// What the engine is set to: where it runs, how it frames itself, when it
|
||||
// asks, whether it is planning. Everything here writes straight into the
|
||||
// engine's config (AgentCore), never into a copy the engine cannot see.
|
||||
//
|
||||
// The panel scrolls (a phone-sized window cannot show it all) and every
|
||||
// control keeps a finger-sized hit area.
|
||||
Rectangle {
|
||||
id: side
|
||||
property string workspace: ""
|
||||
property string provider: ""
|
||||
property string model: ""
|
||||
property string mode: "coding"
|
||||
property int approval: 1 // cst_approval_t
|
||||
property bool planMode: false
|
||||
property bool running: false
|
||||
property string status: ""
|
||||
property string todo: ""
|
||||
property var sessions: [] // what the store holds (AgentCore::sessions)
|
||||
property string sessionId: ""
|
||||
|
||||
signal modeRequested(string mode)
|
||||
signal approvalRequested(int approval)
|
||||
signal planModeRequested(bool on)
|
||||
signal sessionRequested(string id)
|
||||
signal sessionNewRequested()
|
||||
signal sessionDeleteRequested(string id)
|
||||
|
||||
readonly property alias contentColumn: col
|
||||
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: col.implicitHeight + Theme.pad * 2
|
||||
|
||||
// One selectable row of a segmented control.
|
||||
component Seg: Button {
|
||||
id: seg
|
||||
property bool current: false
|
||||
flat: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
padding: 0
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
|
||||
border.width: 1
|
||||
border.color: seg.current ? Theme.accent : Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: seg.text
|
||||
color: seg.current ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: col.implicitHeight + Theme.pad * 2
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
x: Theme.pad
|
||||
y: Theme.pad
|
||||
width: parent.width - Theme.pad * 2
|
||||
spacing: Theme.gapL
|
||||
|
||||
// --- where ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
visible: side.running
|
||||
text: "turn running"
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.workspace.length ? side.workspace : "(no workspace)"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
ToolTip.visible: wsHover.hovered
|
||||
ToolTip.text: side.workspace
|
||||
HoverHandler { id: wsHover }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.provider.length > 0 || side.model.length > 0
|
||||
text: (side.provider.length ? side.provider : "?") +
|
||||
(side.model.length ? " · " + side.model : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessionId.length > 0
|
||||
text: "session " + side.sessionId
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.status.length > 0
|
||||
text: side.status
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// --- which conversation ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: "Sessions"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
// Starting a session while a turn runs would move the
|
||||
// engine out from under it: the whole block is off then.
|
||||
ToolButton {
|
||||
id: newSession
|
||||
objectName: "newSessionButton"
|
||||
implicitWidth: 68
|
||||
implicitHeight: Theme.segmentHeight
|
||||
enabled: !side.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a new session"
|
||||
contentItem: RowLayout {
|
||||
spacing: 4
|
||||
ThemeIcon {
|
||||
name: "list-add"
|
||||
glyph: "+"
|
||||
size: 14
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
text: "new"
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: side.sessionNewRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: side.sessions
|
||||
delegate: SessionRow {
|
||||
Layout.fillWidth: true
|
||||
entry: modelData
|
||||
onActivated: function (id) { side.sessionRequested(id) }
|
||||
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessions.length === 0
|
||||
text: "nothing stored here yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- how it frames itself ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
objectName: "modeCoding"
|
||||
Layout.fillWidth: true
|
||||
text: "coding"
|
||||
current: side.mode !== "general"
|
||||
onClicked: side.modeRequested("coding")
|
||||
}
|
||||
Seg {
|
||||
objectName: "modeGeneral"
|
||||
Layout.fillWidth: true
|
||||
text: "general"
|
||||
current: side.mode === "general"
|
||||
onClicked: side.modeRequested("general")
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.mode === "general" ? "documents, data, everyday files"
|
||||
: "code framed: repos, builds, tests"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- when it asks ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "ask"
|
||||
current: side.approval === 0
|
||||
onClicked: side.approvalRequested(0)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "auto"
|
||||
current: side.approval === 1
|
||||
onClicked: side.approvalRequested(1)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "yolo"
|
||||
current: side.approval === 2
|
||||
onClicked: side.approvalRequested(2)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.approval === 0 ? "every tool asks first"
|
||||
: side.approval === 2 ? "never asks — writes go through"
|
||||
: "sandbox and rules decide, asks on doubt"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
spacing: Theme.gap
|
||||
Switch {
|
||||
id: planSwitch
|
||||
objectName: "planSwitch"
|
||||
checked: side.planMode
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: side.planModeRequested(checked)
|
||||
/* The Basic style's indicator is a dark pill with a dark
|
||||
* knob here, so it is drawn: thumb-sized, accent when
|
||||
* on, clearly off when not. */
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 22
|
||||
x: planSwitch.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 11
|
||||
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: planSwitch.checked ? Theme.accent : Theme.border
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
y: 2
|
||||
x: planSwitch.checked ? parent.width - width - 3 : 3
|
||||
radius: 8
|
||||
color: planSwitch.checked ? Theme.text : Theme.textDim
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: "Plan mode"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.planMode
|
||||
text: "read-only tools and a planning note"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- the plan the engine keeps ---
|
||||
TodoPanel {
|
||||
Layout.fillWidth: true
|
||||
text: side.todo
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "engine in-process · v" + Qt.application.version
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// One place for the look: colors, spacing, fonts. The window, the cards and
|
||||
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
|
||||
// Kirigami.Theme) is a single file.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
|
||||
readonly property color bg: "#15181a"
|
||||
readonly property color surface: "#1e2225"
|
||||
readonly property color surfaceAlt: "#262b2f"
|
||||
readonly property color surfaceHi: "#313840"
|
||||
readonly property color border: "#343b41"
|
||||
readonly property color text: "#eff0f1"
|
||||
readonly property color textDim: "#9aa2a8"
|
||||
/* Faint is still text: small print (hints, secondary lines, a selected
|
||||
* row's detail) has to stay readable on both the background and the
|
||||
* accent fill, so it is a step lighter than it looks in a mockup. */
|
||||
readonly property color textFaint: "#8d959c"
|
||||
readonly property color accent: "#3daee9"
|
||||
readonly property color accentDim: "#1d6f95"
|
||||
/* What is written *on* the accent fill (a selection, the open session).
|
||||
* Named "accentText...", not "onAccent...": QML reads any `onFoo` as a
|
||||
* signal handler and refuses the assignment. */
|
||||
readonly property color accentText: "#ffffff"
|
||||
readonly property color accentTextDim: "#dceefb"
|
||||
/* The dark ink a filled button carries (accent, ok, err are all light). */
|
||||
readonly property color accentInk: "#12161a"
|
||||
readonly property color ok: "#27ae60"
|
||||
readonly property color warn: "#f67400"
|
||||
readonly property color err: "#da4453"
|
||||
/* Green on the accent fill is mud: the "in use" mark needs its own ink. */
|
||||
readonly property color okOnAccent: "#d8f5e3"
|
||||
|
||||
readonly property int radius: 6
|
||||
readonly property int radiusSm: 3
|
||||
readonly property int gap: 8
|
||||
readonly property int gapL: 14
|
||||
readonly property int pad: 12
|
||||
readonly property int padL: 16
|
||||
|
||||
readonly property int bodySize: 14
|
||||
readonly property int smallSize: 12
|
||||
readonly property int monoSize: 13
|
||||
readonly property string mono: "monospace"
|
||||
|
||||
readonly property int sidebarWidth: 268
|
||||
readonly property int contentMax: 1100
|
||||
|
||||
// Layout: below the breakpoint the sidebar slides over the transcript
|
||||
// instead of taking a column, and controls stay thumb-sized above it
|
||||
// (a finger needs ~34-40 px, a mouse is happy with 26).
|
||||
readonly property int compactBreakpoint: 880
|
||||
readonly property int touchTarget: 34
|
||||
readonly property int segmentHeight: 30
|
||||
readonly property int headerHeight: 42
|
||||
readonly property int jumpButton: 40
|
||||
|
||||
// Which Breeze icon stands for a tool: the card shows at a glance what
|
||||
// kind of thing ran. Unknown names still get one (no glyph fallbacks for
|
||||
// tools -- a missing icon just leaves the name alone).
|
||||
function toolIcon(name) {
|
||||
const n = String(name).toLowerCase()
|
||||
if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0)
|
||||
return "document-edit"
|
||||
if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0)
|
||||
return "text-x-generic"
|
||||
if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 ||
|
||||
n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0)
|
||||
return "folder-open"
|
||||
if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0)
|
||||
return "edit-find"
|
||||
if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 ||
|
||||
n.indexOf("command") >= 0 || n.indexOf("run") >= 0)
|
||||
return "code-context"
|
||||
if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0)
|
||||
return "internet-services"
|
||||
if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0)
|
||||
return "format-list-unordered"
|
||||
if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0)
|
||||
return "help-hint"
|
||||
return "code-context"
|
||||
}
|
||||
|
||||
// The tool output / diff line colors, used by ToolCard.
|
||||
function lineColor(line) {
|
||||
if (line.startsWith("@@"))
|
||||
return accent
|
||||
if (line.startsWith("+++") || line.startsWith("---"))
|
||||
return textDim
|
||||
if (line.startsWith("+"))
|
||||
return ok
|
||||
if (line.startsWith("-"))
|
||||
return err
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import QtQuick
|
||||
|
||||
// One icon, from the desktop's icon theme (image://csttheme/, see
|
||||
// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own.
|
||||
//
|
||||
// `glyph` is the last resort: a machine with no icon theme at all gets the
|
||||
// unicode character the app used before, rather than an empty hole. Every
|
||||
// place that shows an icon names its glyph, so nothing can go blank.
|
||||
Item {
|
||||
id: root
|
||||
property string name: ""
|
||||
property string glyph: ""
|
||||
property color color: Theme.textDim
|
||||
property int size: 16
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
|
||||
/* "#rrggbb": a color typed to a string in QML carries alpha and a
|
||||
* leading '#', which the provider's query would choke on. */
|
||||
function hex(c) {
|
||||
function two(v) {
|
||||
const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
|
||||
return s.length < 2 ? "0" + s : s
|
||||
}
|
||||
return "#" + two(c.r) + two(c.g) + two(c.b)
|
||||
}
|
||||
|
||||
Image {
|
||||
id: img
|
||||
anchors.fill: parent
|
||||
source: root.name.length
|
||||
? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color))
|
||||
: ""
|
||||
sourceSize.width: root.size
|
||||
sourceSize.height: root.size
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: img.status === Image.Error && root.glyph.length > 0
|
||||
text: root.glyph
|
||||
color: root.color
|
||||
font.pixelSize: Math.round(root.size * 0.85)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The model's reasoning stream, collapsed by default: it is context, not the
|
||||
// answer.
|
||||
Rectangle {
|
||||
id: block
|
||||
property string text: ""
|
||||
property bool complete: false
|
||||
property bool expanded: false
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
opacity: 0.9
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
leftMargin: Theme.pad
|
||||
rightMargin: Theme.pad
|
||||
topMargin: Theme.pad / 2
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
/* A handler, not a MouseArea: a layout decides the geometry of its
|
||||
* children, and anchoring one of them is undefined behavior. */
|
||||
TapHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onTapped: block.expanded = !block.expanded
|
||||
}
|
||||
ThemeIcon {
|
||||
name: block.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: block.expanded ? "▾" : "▸"
|
||||
size: Theme.smallSize + 2
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: block.complete ? "thinking" : "thinking…"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
visible: !block.expanded
|
||||
text: block.text.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
visible: block.expanded
|
||||
text: block.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.italic: true
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's todo list (on_todo), rendered as a small checklist. The text
|
||||
// is markdown-ish: "- [x] thing" lines.
|
||||
ColumnLayout {
|
||||
id: panel
|
||||
property string text: ""
|
||||
|
||||
function entries() {
|
||||
return text.split("\n")
|
||||
.map(function (l) { return l.trim() })
|
||||
.filter(function (l) { return l.length > 0 })
|
||||
.map(function (l) {
|
||||
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
|
||||
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
|
||||
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
|
||||
})
|
||||
}
|
||||
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "Plan"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: panel.entries()
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
name: modelData.done ? "task-complete" : "box"
|
||||
glyph: modelData.done ? "✓" : "○"
|
||||
size: Theme.smallSize + 2
|
||||
color: modelData.done ? Theme.ok : Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.label
|
||||
color: modelData.done ? Theme.textFaint : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.strikeout: modelData.done
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: panel.entries().length === 0
|
||||
text: "no plan yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A tool call: what ran, whether it is still running, and what came back.
|
||||
// Long output stays folded until asked for, diff lines are colored.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string name: ""
|
||||
property string state: "pending" // pending | ready | running | done | denied
|
||||
// | unfinished (replayed, no result)
|
||||
property string detail: ""
|
||||
property string output: ""
|
||||
property int exitCode: 0
|
||||
property bool hasResult: false
|
||||
property bool expanded: false
|
||||
property string images: ""
|
||||
|
||||
readonly property bool running: state === "pending" || state === "ready" || state === "running"
|
||||
readonly property color stateColor: state === "denied" ? Theme.err
|
||||
: state === "unfinished" ? Theme.textFaint
|
||||
: running ? Theme.warn
|
||||
: hasResult && exitCode !== 0 ? Theme.err
|
||||
: Theme.ok
|
||||
readonly property string stateText: state === "pending" ? "reading"
|
||||
: state === "ready" ? "queued"
|
||||
: state === "running" ? "running"
|
||||
: state === "denied" ? "denied"
|
||||
: state === "unfinished" ? "no result"
|
||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||
|
||||
/* How long this call has been going. A tool that takes minutes -- a
|
||||
* build, a wide search, a command nobody can predict -- must not read as
|
||||
* a window that has hung: while the card runs, its chip counts. */
|
||||
property double startedAt: 0
|
||||
property int runningFor: 0
|
||||
|
||||
function elapsedText() {
|
||||
const s = runningFor
|
||||
if (s < 60)
|
||||
return s + "s"
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60)
|
||||
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
|
||||
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
|
||||
}
|
||||
|
||||
/* The clock starts when the card starts running -- and a card that is
|
||||
* already running when it appears (a replayed transcript) starts one too,
|
||||
* hence Component.onCompleted. */
|
||||
function noteRunning() {
|
||||
if (running && startedAt === 0)
|
||||
startedAt = Date.now()
|
||||
else if (!running)
|
||||
startedAt = 0
|
||||
}
|
||||
|
||||
onRunningChanged: noteRunning()
|
||||
Component.onCompleted: noteRunning()
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: card.running && card.startedAt > 0
|
||||
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
|
||||
}
|
||||
|
||||
/* What the chip says: the state, how long it has been running (from five
|
||||
* seconds on -- before that it is noise), or the exit code once there is
|
||||
* a result. "no result" is not an outcome, so no exit code belongs on
|
||||
* it. */
|
||||
function chipText() {
|
||||
if (running)
|
||||
return runningFor >= 5
|
||||
? stateText + " · " + elapsedText()
|
||||
: stateText
|
||||
return stateText + (hasResult && state !== "unfinished"
|
||||
? " · exit " + exitCode : "")
|
||||
}
|
||||
|
||||
function lines() {
|
||||
const all = output.split("\n")
|
||||
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: expanded ? Theme.stateColor : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
BusyIndicator {
|
||||
visible: card.running
|
||||
running: card.running
|
||||
implicitWidth: 16
|
||||
implicitHeight: 16
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
visible: !card.running
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.stateColor
|
||||
}
|
||||
/* What kind of tool this was, from the icon theme (Theme.toolIcon
|
||||
* maps the engine's names onto Breeze ones). */
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
name: Theme.toolIcon(card.name)
|
||||
size: 16
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: card.name
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Theme.radiusSm
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: card.stateColor
|
||||
implicitWidth: chip.implicitWidth + 10
|
||||
implicitHeight: chip.implicitHeight + 2
|
||||
Text {
|
||||
id: chip
|
||||
objectName: "toolChip"
|
||||
anchors.centerIn: parent
|
||||
text: card.chipText()
|
||||
color: card.stateColor
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: card.detail.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.output.length > 0
|
||||
contentItem: ThemeIcon {
|
||||
name: card.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: card.expanded ? "▾" : "▸"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: card.expanded = !card.expanded
|
||||
}
|
||||
}
|
||||
|
||||
// What the tool made (view_image, an MCP image): shown as it is, with
|
||||
// the output left folded -- the picture is the result.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
|
||||
// Folded view: the first line of the result, so a done card still says
|
||||
// something without being opened.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !card.expanded && card.output.length > 0
|
||||
text: {
|
||||
const l = card.output.split("\n")
|
||||
return l[0] + (l.length > 1 ? " …" : "")
|
||||
}
|
||||
elide: Text.ElideRight
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: card.expanded && card.output.length > 0
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: card.expanded ? card.lines() : []
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.length ? modelData : " "
|
||||
color: Theme.lineColor(modelData)
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
wrapMode: Text.NoWrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.topMargin: 4
|
||||
visible: card.output.split("\n").length > 500
|
||||
text: "output truncated for the view"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/* AgentCore: the Qt wrapper around one cst_agent_t.
|
||||
*
|
||||
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
|
||||
* is turned into a Qt signal, and the two hooks that need an answer
|
||||
* (on_permission, on_ask_user) block the worker until the GUI replies. No
|
||||
* HTTP, no socket: the GUI owns the engine in its own process. */
|
||||
|
||||
#ifndef CST_APP_AGENTCORE_H
|
||||
#define CST_APP_AGENTCORE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
#include <QWaitCondition>
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_tools.h"
|
||||
|
||||
class QThread;
|
||||
|
||||
class AgentCore : public QObject {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
|
||||
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
|
||||
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
|
||||
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
|
||||
Q_PROPERTY(QString model READ model NOTIFY configChanged)
|
||||
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
|
||||
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
|
||||
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
|
||||
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
|
||||
|
||||
public:
|
||||
explicit AgentCore(QObject *parent = nullptr);
|
||||
~AgentCore() override;
|
||||
|
||||
/* Start the engine on a workspace. provider/model/apiKey may be empty to
|
||||
* fall back to the saved credentials and the provider's default model. */
|
||||
bool open(const QString &workspace, const QString &provider,
|
||||
const QString &model, const QString &apiKey, QString *err);
|
||||
void close();
|
||||
|
||||
bool running() const { return m_running; }
|
||||
QString workspace() const { return m_workspace; }
|
||||
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
|
||||
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
|
||||
QString model() const { return QString::fromUtf8(m_cfg.model); }
|
||||
int approval() const { return m_cfg.approval; }
|
||||
bool planMode() const { return m_cfg.plan_mode != 0; }
|
||||
QString todo() const { return m_todo; }
|
||||
QString sessionId() const;
|
||||
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
||||
* message looks the same here as it does in the WebUI. */
|
||||
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
||||
|
||||
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
|
||||
|
||||
/* Stored sessions, newest first; one map each: id, title (a rename, else
|
||||
* empty), firstUser, messages, mtime (ms since the epoch), current. */
|
||||
Q_INVOKABLE QVariantList sessions() const;
|
||||
/* The open session as transcript rows, in exactly the shape the live
|
||||
* signals build (kind, role, text, name, state, detail, output, exitCode,
|
||||
* complete, images), so a reopened session renders like the turn that wrote
|
||||
* it. */
|
||||
Q_INVOKABLE QVariantList history() const;
|
||||
/* None of these run during a turn; all return false when one does. */
|
||||
Q_INVOKABLE bool newSession();
|
||||
Q_INVOKABLE bool switchSession(const QString &id);
|
||||
Q_INVOKABLE bool deleteSession(const QString &id);
|
||||
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
|
||||
|
||||
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
|
||||
|
||||
/* The presets: name, base, model (remembered, else the preset's), hasKey
|
||||
* and keyFrom ("saved", else the environment variable), keyEnv, source and
|
||||
* current, custom (the user's own provider, kept in the engine's user
|
||||
* model file). Never a key: the engine keeps those out of the UI's reach. */
|
||||
Q_INVOKABLE QVariantList providers() const;
|
||||
/* A provider the engine's presets do not know: it goes into the user model
|
||||
* file (<config>/cst-agent/models.json), which is where the registry keeps
|
||||
* them, and the registry is reloaded, so the next providers() call lists
|
||||
* it. `modelsCsv` is a comma-separated model list (may be empty).
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
|
||||
const QString &keyEnv,
|
||||
const QString &defaultModel,
|
||||
const QString &modelsCsv);
|
||||
/* Drop a provider that came from that file (1 = it was there, 0 = it was
|
||||
* not, -1 = with a reason). A preset of the same name stays. */
|
||||
Q_INVOKABLE int removeProvider(const QString &name);
|
||||
/* The generation parameters remembered for a provider (0 = not set, the
|
||||
* field is not sent at all). */
|
||||
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
|
||||
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
|
||||
* apply them to the engine when that provider is the live one.
|
||||
* "" when it worked, else the reason. */
|
||||
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
|
||||
double topP, int maxTokens);
|
||||
/* Model candidates for a provider: its own list, what was fetched into the
|
||||
* cache, and the remembered pick. No network. */
|
||||
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
|
||||
/* "1M · vision" for the model picker, "" when nothing is known. */
|
||||
Q_INVOKABLE QString modelInfo(const QString &model) const;
|
||||
/* Switch provider, model and key in one call. The engine's own config moves
|
||||
* (its session does not), the choice is remembered, and an empty return
|
||||
* means it worked; anything else is a reason to show. A key is verified
|
||||
* against the provider's /models when one is given. */
|
||||
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
|
||||
const QString &key);
|
||||
/* Tokens of this session and of the last turn, plus the cost when priced. */
|
||||
Q_INVOKABLE QVariantMap usage() const;
|
||||
|
||||
/* --- attachments (see cst_attach.h) ------------------------------------- */
|
||||
|
||||
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
|
||||
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
|
||||
QStringList pendingAttachments() const;
|
||||
/* Copy a file next to the session and give it to the next prompt. "" when
|
||||
* it worked, else why not. */
|
||||
Q_INVOKABLE QString attachFile(const QString &path);
|
||||
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
|
||||
* reason -- "no image on the clipboard" -- when the editor should have the
|
||||
* keystroke instead. */
|
||||
Q_INVOKABLE QString attachClipboard();
|
||||
/* Whether Ctrl+V should become an attachment at all (an image is on the
|
||||
* clipboard) or stay with the editor (text). */
|
||||
Q_INVOKABLE bool clipboardHasImage() const;
|
||||
Q_INVOKABLE void clearPending();
|
||||
/* The images among the pending attachments, in the engine's order, in the
|
||||
* same text history() and toolImages() carry (see imagesText()): one line
|
||||
* per picture, "<stored name>|<name>|<origin>|<w>x<h>". Empty for a PDF or
|
||||
* a text file, and for a picture whose stored name the engine did not
|
||||
* report. */
|
||||
Q_INVOKABLE QString pendingImages() const;
|
||||
/* Where the current session keeps its stored images: the directory
|
||||
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
|
||||
* open. It follows the session. */
|
||||
Q_INVOKABLE QString attachmentDir() const;
|
||||
/* A file card's two buttons: the stored file goes to the system -- a phone
|
||||
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
|
||||
* it to its own application or to the clipboard. "" when it went through
|
||||
* (the header gets the line from statusChanged), else what to say. The name
|
||||
* is the stored one the card carries; anything else is refused. */
|
||||
Q_INVOKABLE QString openArtifact(const QString &rel);
|
||||
Q_INVOKABLE QString shareArtifact(const QString &rel);
|
||||
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
|
||||
Q_INVOKABLE int seesImages() const;
|
||||
|
||||
/* The engine, for the parts that need it (tool registration). */
|
||||
cst_agent_t *agent() const { return m_agent; }
|
||||
const cst_agent_config_t *config() const { return &m_cfg; }
|
||||
|
||||
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
||||
Q_INVOKABLE void send(const QString &prompt);
|
||||
Q_INVOKABLE void cancel();
|
||||
|
||||
/* Consent: answers map to CST_PERM_*. */
|
||||
Q_INVOKABLE void answerPermission(int choice);
|
||||
Q_INVOKABLE void answerQuestion(const QString &answer);
|
||||
|
||||
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
|
||||
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
|
||||
Q_INVOKABLE void setPlanMode(bool on);
|
||||
|
||||
/* Tools the front end implements itself. The engine copies the definition
|
||||
* (strings included), so a stack value is fine; ud must outlive it.
|
||||
* owner is what unregisterOwner() later matches. */
|
||||
bool registerTool(const cst_tool_def_t *def);
|
||||
void unregisterOwner(const QString &owner);
|
||||
/* Re-probe platform tools after a service appeared or went away. */
|
||||
void reloadToolMeta() { cst_tool_meta_reload(); }
|
||||
|
||||
/* Answer every consent question with "once" (the C smoke test, headless
|
||||
* runs, a --yes flag). */
|
||||
void setAutoApprove(bool on) { m_autoApprove = on; }
|
||||
|
||||
signals:
|
||||
void configChanged();
|
||||
void approvalChanged();
|
||||
void planModeChanged();
|
||||
void sessionChanged(); /* another session is open now */
|
||||
void sessionListChanged(); /* the store gained, lost or renamed one */
|
||||
void pendingChanged(); /* attachments for the next prompt changed */
|
||||
void statusChanged(const QString &text);
|
||||
void assistantDelta(const QString &text);
|
||||
void thinkDelta(const QString &text);
|
||||
/* A call is identified by its id: the same tool can run twice in one turn,
|
||||
* and only the id tells the two cards apart. Text protocols carry ids too
|
||||
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
|
||||
void toolEvent(const QString &phase, const QString &id, const QString &name,
|
||||
const QString &detail);
|
||||
void toolResult(const QString &id, const QString &name, int exitCode,
|
||||
const QString &output);
|
||||
/* Images a tool produced (view_image, an MCP image), right after its
|
||||
* result, as the text ImageStrip.qml decodes. */
|
||||
void toolImages(const QString &callId, const QString &images);
|
||||
/* The images the prompt that was just sent carries, in the same shape,
|
||||
* fired before the turn's first delta. */
|
||||
void promptImages(const QString &images);
|
||||
void todoChanged(const QString &text);
|
||||
void permissionRequested(const QString &tool, const QString &detail,
|
||||
const QString &preview);
|
||||
void questionAsked(const QString &question, const QString &options);
|
||||
/* The worker asked and nobody answered in time (or the app was in the
|
||||
* background, where the dialog cannot be seen): the turn went on with a
|
||||
* refusal, and the window puts its dialog away again. */
|
||||
void permissionExpired(const QString &tool);
|
||||
void questionExpired();
|
||||
void turnFinished(int rc, const QString &err);
|
||||
void runningChanged();
|
||||
void workspaceChanged();
|
||||
void modeChanged();
|
||||
|
||||
private:
|
||||
friend class Turn;
|
||||
void finishTurn(int rc, const QString &err);
|
||||
void setRunning(bool on);
|
||||
/* Take the engine's config over m_cfg again (a switch changed it) and say
|
||||
* so. */
|
||||
void syncConfig();
|
||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||
void afterSessionChange();
|
||||
/* True when the app is not on screen: a phone in the background (Qt
|
||||
* suspends it), or a window that is not there at all. A dialog opened now
|
||||
* would be invisible, so consent is refused instead of waited for. */
|
||||
bool backgrounded() const;
|
||||
|
||||
/* Hook trampolines (C, run on the worker thread). */
|
||||
static void hookStatus(const char *msg, void *ud);
|
||||
static void hookAssistant(const char *text, size_t len, void *ud);
|
||||
static void hookThink(const char *text, size_t len, void *ud);
|
||||
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
||||
const char *detail, void *ud);
|
||||
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||
size_t len, int exit_code, void *ud);
|
||||
static void hookToolImages(const ctl_tool_call_t *call,
|
||||
const cst_attachment_t *list, int n, void *ud);
|
||||
static void hookTodo(const char *text, void *ud);
|
||||
static int hookPermission(const char *tool, const char *detail,
|
||||
const char *preview, void *ud);
|
||||
static int hookAskUser(const char *question, const char *options, char *ans,
|
||||
size_t ans_sz, void *ud);
|
||||
/* Post a lambda onto the GUI thread. */
|
||||
template <typename F>
|
||||
void post(F f) {
|
||||
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
cst_agent_t *m_agent = nullptr;
|
||||
cst_agent_config_t m_cfg;
|
||||
cst_agent_hooks_t m_hooks;
|
||||
QString m_workspace;
|
||||
QThread *m_turn = nullptr;
|
||||
bool m_running = false;
|
||||
bool m_closing = false;
|
||||
|
||||
/* consent handshake between the worker and the GUI thread */
|
||||
QMutex m_askMu;
|
||||
QWaitCondition m_askCv;
|
||||
int m_permAnswer = CST_PERM_DENY;
|
||||
bool m_permPending = false;
|
||||
QString m_question;
|
||||
QString m_todo;
|
||||
/* The session list rebuilt only when a transcript moved: filling one row
|
||||
* walks the whole file, and the sidebar asks for the list on every store
|
||||
* change. Keyed by id, dropped when the id is gone. */
|
||||
struct SessionCache {
|
||||
qint64 mtime = 0;
|
||||
qint64 size = 0;
|
||||
QVariantMap row;
|
||||
};
|
||||
mutable QHash<QString, SessionCache> m_sessionCache;
|
||||
/* Names of the files this window attached, in the engine's pending order:
|
||||
* the engine's own description says what it is, not where it came from. */
|
||||
QStringList m_attachNames;
|
||||
QByteArray m_answer;
|
||||
bool m_questionPending = false;
|
||||
bool m_autoApprove = false;
|
||||
};
|
||||
|
||||
#endif /* CST_APP_AGENTCORE_H */
|
||||
@@ -0,0 +1,333 @@
|
||||
/* The window's own tools, in Qt rather than in the engine: a document the
|
||||
* person asked for, laid out as a PDF.
|
||||
*
|
||||
* Why here and not in the C engine: laying text out on pages is exactly what
|
||||
* Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already
|
||||
* links), while the engine has no layout code at all -- and md_to_html, its
|
||||
* sibling, lives in the engine because rendering markdown is a shared
|
||||
* capability. This one is a front-end capability: it is registered under
|
||||
* owner "app:qt" and is available on every platform the app runs on.
|
||||
*
|
||||
* The path of a document is the one md_to_html walks: markdown (or a
|
||||
* workspace .md/.html file) -> cst_web_md_document() -> a file inside the
|
||||
* workspace -> cst_attach_import_artifact(), which is what turns it into a
|
||||
* file card the person can open or share. The bytes never reach the model.
|
||||
*
|
||||
* Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only
|
||||
* module (a phone has no print subsystem), and a round of
|
||||
* pageSize + translate + drawContents per page is all a paginated PDF needs.
|
||||
* The tool runs on the turn's worker thread, so a long document does not
|
||||
* freeze the window. */
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMarginsF>
|
||||
#include <QPageLayout>
|
||||
#include <QPageSize>
|
||||
#include <QPainter>
|
||||
#include <QPdfWriter>
|
||||
#include <QRectF>
|
||||
#include <QString>
|
||||
#include <QTextDocument>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_tools.h"
|
||||
#include "cst_web_md.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* md_to_html caps its input the same way: a document nobody could read is
|
||||
* not worth laying out. */
|
||||
constexpr int kMaxInputBytes = 8 * 1024 * 1024;
|
||||
/* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the
|
||||
* default 300 (a 40-page document is laid out and written in a moment). */
|
||||
constexpr int kPdfDpi = 150;
|
||||
|
||||
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||
for (int i = 0; i < call->nargs; i++)
|
||||
if (strcmp(call->args[i].key, key) == 0)
|
||||
return call->args[i].value;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int setOut(cst_tool_result_t *r, const QString &text, int rc) {
|
||||
const QByteArray b = text.toUtf8();
|
||||
r->out = strdup(b.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc;
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* "docs/notes.md" -> "notes". */
|
||||
QString stemOf(const QString &path) {
|
||||
QString base = path;
|
||||
const int slash = qMax(base.lastIndexOf(QLatin1Char('/')),
|
||||
base.lastIndexOf(QLatin1Char('\\')));
|
||||
if (slash >= 0)
|
||||
base = base.mid(slash + 1);
|
||||
const int dot = base.lastIndexOf(QLatin1Char('.'));
|
||||
if (dot > 0)
|
||||
base = base.left(dot);
|
||||
return base.isEmpty() ? QStringLiteral("document") : base;
|
||||
}
|
||||
|
||||
/* What the file is called: `out` when given (a directory takes the source's
|
||||
* name inside it), otherwise the source's stem, or the title. */
|
||||
QString outPathOf(const ctl_tool_call_t *c, const QString &stem) {
|
||||
const char *out = argOf(c, "out");
|
||||
QString name = out && out[0] ? QString::fromUtf8(out) : QString();
|
||||
if (name.isEmpty())
|
||||
return stem + QStringLiteral(".pdf");
|
||||
if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\')))
|
||||
return name + stem + QStringLiteral(".pdf");
|
||||
if (!QFileInfo(name).fileName().contains(QLatin1Char('.')))
|
||||
name += QStringLiteral(".pdf");
|
||||
return name;
|
||||
}
|
||||
|
||||
/* The HTML that goes into the page: an .html file is already a document, a
|
||||
* .md file (or the `markdown` argument) goes through the engine's renderer,
|
||||
* which is the same one the WebUI and md_to_html use. */
|
||||
bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
QString *html, QString *out_name, QString *err) {
|
||||
const char *md = argOf(c, "markdown");
|
||||
const char *file = argOf(c, "file");
|
||||
const char *given = argOf(c, "title");
|
||||
QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||
QByteArray text;
|
||||
QString stem;
|
||||
if (file && file[0]) {
|
||||
char rel[4096] = "";
|
||||
if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) {
|
||||
*err = QStringLiteral("error: path outside the workspace or missing "
|
||||
"parent: %1")
|
||||
.arg(QString::fromUtf8(file));
|
||||
return false;
|
||||
}
|
||||
QFile in(QString::fromUtf8(rel));
|
||||
if (!in.open(QIODevice::ReadOnly)) {
|
||||
*err = QStringLiteral("error: cannot read %1")
|
||||
.arg(QString::fromUtf8(file));
|
||||
return false;
|
||||
}
|
||||
if (in.size() > kMaxInputBytes) {
|
||||
*err = QStringLiteral("error: %1 is larger than %2 MB")
|
||||
.arg(QString::fromUtf8(file))
|
||||
.arg(kMaxInputBytes >> 20);
|
||||
return false;
|
||||
}
|
||||
text = in.readAll();
|
||||
stem = stemOf(QString::fromUtf8(file));
|
||||
const QString lower = QString::fromUtf8(file).toLower();
|
||||
if (lower.endsWith(QLatin1String(".html")) ||
|
||||
lower.endsWith(QLatin1String(".htm"))) {
|
||||
/* already a document: what md_to_html wrote, or anything else */
|
||||
*html = QString::fromUtf8(text);
|
||||
if (title.isEmpty())
|
||||
title = stem;
|
||||
*out_name = outPathOf(c, stem);
|
||||
return true;
|
||||
}
|
||||
} else if (md && md[0]) {
|
||||
text = QByteArray(md);
|
||||
if (stem.isEmpty())
|
||||
stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title);
|
||||
} else {
|
||||
*err = QStringLiteral("error: give markdown, or a workspace .md or .html "
|
||||
"file to render");
|
||||
return false;
|
||||
}
|
||||
if (title.isEmpty())
|
||||
title = stem;
|
||||
char *doc =
|
||||
cst_web_md_document(text.constData(), static_cast<size_t>(text.size()),
|
||||
title.toUtf8().constData());
|
||||
if (!doc) {
|
||||
*err = QStringLiteral("error: cannot render the markdown");
|
||||
return false;
|
||||
}
|
||||
*html = QString::fromUtf8(doc);
|
||||
free(doc);
|
||||
*out_name = outPathOf(c, stem);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect,
|
||||
* translated down per page, and the document is asked for exactly that much
|
||||
* text at a time -- which is what makes Qt lay out pages instead of one long
|
||||
* strip. */
|
||||
bool writePdf(const QString &html, const QString &path, const QString &title,
|
||||
QString *err) {
|
||||
std::unique_ptr<QTextDocument> doc(paperDocument(html));
|
||||
if (!title.isEmpty())
|
||||
doc->setMetaInformation(QTextDocument::DocumentTitle, title);
|
||||
|
||||
QPdfWriter writer(path);
|
||||
writer.setPageSize(QPageSize(QPageSize::A4));
|
||||
writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter);
|
||||
writer.setResolution(kPdfDpi);
|
||||
if (!title.isEmpty())
|
||||
writer.setTitle(title);
|
||||
|
||||
QPainter painter(&writer);
|
||||
if (!painter.isActive()) {
|
||||
*err = QStringLiteral("error: cannot draw into %1").arg(path);
|
||||
return false;
|
||||
}
|
||||
const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution());
|
||||
doc->setPageSize(page.size());
|
||||
const int pages = qMax(1, doc->pageCount());
|
||||
for (int i = 0; i < pages; i++) {
|
||||
if (i)
|
||||
writer.newPage();
|
||||
painter.save();
|
||||
painter.translate(page.topLeft());
|
||||
painter.translate(0, -i * page.height());
|
||||
doc->drawContents(&painter,
|
||||
QRectF(0, 0, page.width(), page.height())
|
||||
.translated(0, i * page.height()));
|
||||
painter.restore();
|
||||
}
|
||||
painter.end();
|
||||
/* The painter says nothing about the file: the only proof the PDF landed is
|
||||
* that there is one with bytes in it. */
|
||||
if (QFileInfo(path).size() <= 0) {
|
||||
*err = QStringLiteral("error: nothing was written to %1").arg(path);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
char **out) {
|
||||
(void)ctx;
|
||||
const char *md = argOf(c, "markdown");
|
||||
const char *file = argOf(c, "file");
|
||||
if ((!md || !md[0]) && (!file || !file[0])) {
|
||||
*out = strdup("error: give markdown, or a .md or .html file to render");
|
||||
return -1;
|
||||
}
|
||||
const QString stem =
|
||||
file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document");
|
||||
const QString name = outPathOf(c, stem);
|
||||
QByteArray line;
|
||||
if (file && file[0])
|
||||
line = QStringLiteral("lay out %1 → %2 as a PDF\n")
|
||||
.arg(QString::fromUtf8(file), name)
|
||||
.toUtf8();
|
||||
else
|
||||
line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n")
|
||||
.arg(md ? qstrlen(md) : 0)
|
||||
.arg(name)
|
||||
.toUtf8();
|
||||
*out = strdup(line.constData());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ud;
|
||||
QString html, out_name, err;
|
||||
if (!sourceHtml(ctx, c, &html, &out_name, &err))
|
||||
return setOut(r, err, 1);
|
||||
|
||||
char full[4096] = "";
|
||||
if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(),
|
||||
full, sizeof(full)) != 0)
|
||||
return setOut(r, QStringLiteral("error: path outside the workspace: %1")
|
||||
.arg(out_name),
|
||||
1);
|
||||
const QString target = QString::fromUtf8(full);
|
||||
/* A directory in the way of the name is what the engine's write tools call
|
||||
* "missing parent": make it, the same way md_to_html does. */
|
||||
QDir().mkpath(QFileInfo(target).absolutePath());
|
||||
|
||||
const char *given = argOf(c, "title");
|
||||
const QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||
if (!writePdf(html, target, title, &err))
|
||||
return setOut(r, err, 1);
|
||||
|
||||
const qint64 bytes = QFileInfo(target).size();
|
||||
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr;
|
||||
cst_attachment_t *a = nullptr;
|
||||
char aerr[256] = "";
|
||||
if (session && session[0])
|
||||
a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr));
|
||||
|
||||
QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n")
|
||||
.arg(out_name)
|
||||
.arg(bytes / 1024);
|
||||
if (a)
|
||||
text += QStringLiteral("(the user sees it as a file card they can open or "
|
||||
"share; you will not be shown its bytes again)\n");
|
||||
else if (aerr[0])
|
||||
text += QStringLiteral("(the file is in the workspace; handing it over "
|
||||
"failed: %1)\n")
|
||||
.arg(QString::fromUtf8(aerr));
|
||||
const int rc = setOut(r, text, 0);
|
||||
if (a) {
|
||||
r->attach = a;
|
||||
r->nattach = 1;
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
/* The document writePdf lays out, with a colour filled in for text that names
|
||||
* none of its own.
|
||||
*
|
||||
* Paper is white and has no theme, but a QTextDocument takes its default text
|
||||
* colour from the application palette: in a window running a dark palette --
|
||||
* which is what a phone in dark mode hands Qt -- every plain markdown
|
||||
* paragraph came out #eeeeee on a white page, i.e. a PDF that looks blank
|
||||
* when printed, and reads as "the font is oddly faint" on screen. Naming the
|
||||
* colour makes the page depend on the paper rather than on the window. A
|
||||
* colour the document does name still wins (an .html file somebody handed us
|
||||
* keeps its own), so this only fills the holes. */
|
||||
QTextDocument *paperDocument(const QString &html) {
|
||||
auto *doc = new QTextDocument;
|
||||
doc->setDefaultStyleSheet(QStringLiteral("body { color: #000000; }"));
|
||||
doc->setHtml(html);
|
||||
return doc;
|
||||
}
|
||||
|
||||
int registerQtTools(AgentCore *core) {
|
||||
if (!core)
|
||||
return 0;
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"markdown", "string", 0, "markdown text to render"},
|
||||
{"file", "string", 0,
|
||||
"workspace .md file to render, or an .html one to lay out as it is"},
|
||||
{"out", "string", 0,
|
||||
"output path in the workspace (default: the source's name + .pdf)"},
|
||||
{"title", "string", 0, "document title (default: the file name)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
static cst_tool_def_t def;
|
||||
memset(&def, 0, sizeof(def));
|
||||
setMeta(&def, "md_to_pdf",
|
||||
"Write a document as a PDF: markdown (or a workspace .md/.html "
|
||||
"file) in, a laid-out A4 PDF out, saved in the workspace and "
|
||||
"handed to the person as a card they can open, print or share. "
|
||||
"Use it when the answer should be something to keep or send.",
|
||||
CST_TP_WRITE, "pdf", nullptr, nullptr);
|
||||
memcpy(def.params, params, sizeof(params));
|
||||
def.run_ex = mdToPdfRun;
|
||||
def.preview = mdToPdfPreview;
|
||||
def.owner = "app:qt";
|
||||
return core->registerTool(&def) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "ToolProvider.h"
|
||||
|
||||
#include "AgentCore.h"
|
||||
|
||||
namespace app {
|
||||
|
||||
/* Keep these in one place: every platform tool of the front end uses one of
|
||||
* them, so the engine's availability gate and the policy UI agree. */
|
||||
const char *const kDesktopOnly[] = {"desktop", NULL};
|
||||
const char *const kAndroidOnly[] = {"android", NULL};
|
||||
|
||||
int registerPlatformTools(AgentCore *core) {
|
||||
if (!core)
|
||||
return 0;
|
||||
int n = 0;
|
||||
#if defined(__ANDROID__)
|
||||
n += platformRegisterAndroid(core);
|
||||
#elif defined(__APPLE__)
|
||||
n += platformRegisterMac(core);
|
||||
#elif defined(_WIN32)
|
||||
n += platformRegisterWindows(core);
|
||||
#elif defined(__linux__)
|
||||
n += platformRegisterLinux(core);
|
||||
#endif
|
||||
/* Qt is not a platform API: these exist wherever the window does. */
|
||||
n += registerQtTools(core);
|
||||
/* A service may have appeared or gone since the last probe (a desktop
|
||||
* session starting, the notification daemon coming up). */
|
||||
cst_tool_meta_reload();
|
||||
return n;
|
||||
}
|
||||
|
||||
void unregisterPlatformTools(AgentCore *core) {
|
||||
if (!core)
|
||||
return;
|
||||
core->unregisterOwner(QStringLiteral("app:linux"));
|
||||
core->unregisterOwner(QStringLiteral("app:windows"));
|
||||
core->unregisterOwner(QStringLiteral("app:macos"));
|
||||
core->unregisterOwner(QStringLiteral("app:android"));
|
||||
core->unregisterOwner(QStringLiteral("app:qt"));
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,126 @@
|
||||
/* How the front end hands a tool to the engine.
|
||||
*
|
||||
* A tool implemented in C++ is an ordinary cst_tool_def_t: the engine owns a
|
||||
* copy of the definition, so a static (or stack) value is enough, and
|
||||
* `ud`/`run_ex` keep pointing at the front end's state. The callback runs on
|
||||
* the turn's worker thread -- touch Qt objects only through queued signals.
|
||||
*
|
||||
* See app/src/platform/linux/NotifyTool.cpp for a worked example, and
|
||||
* tests/test_embed.c for the same contract without Qt.
|
||||
*/
|
||||
|
||||
#ifndef CST_APP_TOOLPROVIDER_H
|
||||
#define CST_APP_TOOLPROVIDER_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "cst_tools.h"
|
||||
|
||||
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
||||
* travels through this header, so the class stays incomplete here. */
|
||||
class AgentCore;
|
||||
/* Same for the document md_to_pdf lays out (PdfTools.cpp): the caller owns
|
||||
* it, and the test in ui/UiTest.cpp inspects how it draws. */
|
||||
class QTextDocument;
|
||||
|
||||
namespace app {
|
||||
|
||||
/* Fill a definition in one place, so every front-end tool carries the same
|
||||
* metadata (class + policy name + platform list + probe). */
|
||||
inline void setMeta(cst_tool_def_t *def, const char *name, const char *desc,
|
||||
cst_tool_perm_t perm, const char *perm_name,
|
||||
const char *const *platforms,
|
||||
int (*available)(char *, size_t)) {
|
||||
def->name = name;
|
||||
def->desc = desc;
|
||||
def->perm = perm;
|
||||
def->perm_name = perm_name;
|
||||
def->platforms = platforms;
|
||||
def->available = available;
|
||||
}
|
||||
|
||||
/* Tools that only work on the phone. */
|
||||
extern const char *const kAndroidOnly[];
|
||||
/* Tools that only work on a desktop (linux / win / macos). */
|
||||
extern const char *const kDesktopOnly[];
|
||||
|
||||
/* The platform tools this app provides. Called once the engine exists;
|
||||
* returns how many were registered. */
|
||||
int registerPlatformTools(AgentCore *core);
|
||||
/* The tools this front end provides that are the same on every platform
|
||||
* (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt".
|
||||
* Called next to registerPlatformTools(). */
|
||||
int registerQtTools(AgentCore *core);
|
||||
/* The document md_to_pdf lays out from `html`: the page's own defaults
|
||||
* applied (text with no colour of its own is black, not the window's palette
|
||||
* colour). The caller owns the result. */
|
||||
QTextDocument *paperDocument(const QString &html);
|
||||
/* Drop them again (they belong to the owners "app:*"). */
|
||||
void unregisterPlatformTools(AgentCore *core);
|
||||
|
||||
/* One entry point per platform, implemented by the file under
|
||||
* src/platform/<os>/; ToolProvider.cpp picks one at compile time. */
|
||||
int platformRegisterLinux(AgentCore *core);
|
||||
int platformRegisterMac(AgentCore *core);
|
||||
int platformRegisterWindows(AgentCore *core);
|
||||
int platformRegisterAndroid(AgentCore *core);
|
||||
|
||||
/* Hand a file of the session's store to the system: a phone opens it with
|
||||
* ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever
|
||||
* the desktop uses. `title` is the name a person knows the file by (the
|
||||
* stored copy is named by its id, which means nothing outside), used for the
|
||||
* status line. Both return "" on success and a reason to show the user
|
||||
* otherwise; *status (optional) gets the line to put in the header.
|
||||
* CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without
|
||||
* opening anything for real). */
|
||||
QString openLocalFile(const QString &path, const QString &title, QString *status);
|
||||
/* The same file through the share sheet where there is one (Android); a
|
||||
* desktop has no share sheet, so the path goes to the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status);
|
||||
/* Hold the machine while a turn runs, so it survives the app leaving the
|
||||
* screen: a phone starts a foreground service with a partial wake lock (the
|
||||
* process is one Android will not freeze or kill, and the CPU keeps going
|
||||
* with the screen off) and shows a notification naming the work; a desktop
|
||||
* does nothing -- the window *is* the app there. Returns "" when the hold is
|
||||
* in place (or when the platform has nothing to do), a reason otherwise; the
|
||||
* turn runs either way. `text` is what the phone's notification says. */
|
||||
QString holdForeground(const QString &text);
|
||||
/* Drop it again, at the end of the turn (a phone: the notification goes). */
|
||||
void releaseForeground();
|
||||
|
||||
/* Turn what a file chooser handed over into a path the engine can open. A
|
||||
* desktop picker gives a path and nothing happens; a phone's is a SAF
|
||||
* content:// URI, which the app copies into its own directory first (the
|
||||
* grant travels with the URI, so no permission is involved -- and without
|
||||
* the copy the engine, which opens files by path, would fail with "cannot
|
||||
* read"). Returns the path to read, or "" with *err filled. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err);
|
||||
/* Does this app have "all files access" (Android 11+)? A desktop always
|
||||
* does. Without it the app still attaches whatever the picker handed it; what
|
||||
* it cannot do is read shared storage by path. */
|
||||
bool hasAllFilesAccess();
|
||||
/* Ask for it, through the one screen Android offers (there is no runtime
|
||||
* dialog for it). A desktop, a phone that already has it or one too old to
|
||||
* know about it returns "" and nothing opens. */
|
||||
QString requestAllFilesAccess();
|
||||
/* Take the whole screen. A phone's status bar -- and a tablet's taskbar, which
|
||||
* is a status bar with icons in it -- is drawn *over* the window, and the
|
||||
* window here draws from its own first pixel, so the bars sit on the app's top
|
||||
* row and the controls there cannot be touched. Hiding them is what "full
|
||||
* screen" means on a phone; a desktop has no such bars to ask about and
|
||||
* returns "". Asking is repeatable and cheap: the bars come back with a swipe
|
||||
* from the edge or a switch to another app, so the front end asks again every
|
||||
* time the window comes to the front. Returns "" once they are gone, a reason
|
||||
* otherwise (which the front end logs). */
|
||||
QString requestFullscreen();
|
||||
/* The arguments the Android activity was started with (the
|
||||
* "applicationArguments" intent extra): the way
|
||||
* `cst-agent-app --uitest --workspace DIR` is reachable on a device, since
|
||||
* Qt's own plumbing for that extra does not reach this build. Empty
|
||||
* everywhere else, and empty on a device that was not started with any. */
|
||||
QStringList activityArguments();
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_TOOLPROVIDER_H */
|
||||
@@ -0,0 +1,378 @@
|
||||
/* cst-agent-app: the Qt front end.
|
||||
*
|
||||
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
|
||||
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
|
||||
* cst-agent-app --selftest embedding checks, no window, no network
|
||||
*
|
||||
* The engine runs in this process (see app/README.md); nothing listens on a
|
||||
* port and no second process is started. */
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QQuickWindow>
|
||||
#include <QStandardPaths>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTextStream>
|
||||
#include <QUrl>
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
#include <QJniObject>
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
#endif
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
#include "ui/UiTest.h"
|
||||
#include "ui/BackKey.h"
|
||||
#include "ui/SessionImages.h"
|
||||
#include "ui/ThemeIcons.h"
|
||||
#include "cst_version.h"
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* Where an Android app is allowed to write: its own data directory. There is
|
||||
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
|
||||
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
|
||||
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
|
||||
* of them are pointed here -- once, before the engine exists. */
|
||||
QString androidRoot() {
|
||||
const QString root =
|
||||
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
|
||||
: root;
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
int runSelftest() {
|
||||
QTextStream out(stdout);
|
||||
int bad = 0;
|
||||
QTemporaryDir tmp, cfg;
|
||||
if (!tmp.isValid() || !cfg.isValid()) {
|
||||
out << "selftest: cannot create a scratch dir\n";
|
||||
return 1;
|
||||
}
|
||||
/* The window passes no --api-key, so the key the user saved for the provider
|
||||
* has to come back from <config>/cst-agent/credentials -- the file the
|
||||
* settings dialog writes. Planted in a scratch config dir, never in the
|
||||
* real one. */
|
||||
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
|
||||
QDir().mkpath(cfgdir);
|
||||
QFile creds(cfgdir + QStringLiteral("/credentials"));
|
||||
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
|
||||
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
|
||||
out << "selftest: cannot write a scratch credentials file\n";
|
||||
return 1;
|
||||
}
|
||||
creds.close();
|
||||
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
|
||||
&err)) {
|
||||
out << "selftest: engine did not start: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
const cst_agent_config_t *live =
|
||||
core.agent() ? cst_agent_config(core.agent()) : nullptr;
|
||||
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
|
||||
out << "selftest: the saved key did not reach the engine config\n";
|
||||
bad++;
|
||||
} else {
|
||||
out << "selftest: the saved key is in the engine config\n";
|
||||
}
|
||||
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
|
||||
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
|
||||
&err)) {
|
||||
out << "selftest: engine did not start: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
out << "selftest: engine started in-process\n";
|
||||
if (core.agent() == nullptr) {
|
||||
out << "selftest: no agent handle\n";
|
||||
return 1;
|
||||
}
|
||||
/* the front end's own tools go through the engine's registry */
|
||||
const int n = app::registerPlatformTools(&core);
|
||||
out << "selftest: platform tools registered: " << n << "\n";
|
||||
const cst_tool_def_t *t = cst_tool_find("notification_send");
|
||||
if (!t) {
|
||||
out << "selftest: notification_send is not in the registry\n";
|
||||
bad++;
|
||||
} else {
|
||||
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
|
||||
out << "selftest: notification_send has no app owner\n";
|
||||
bad++;
|
||||
}
|
||||
char why[160] = "";
|
||||
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
|
||||
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* The engine's own deliver_file: the file a tool hands to the person. */
|
||||
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
|
||||
if (!dv) {
|
||||
out << "selftest: deliver_file is not in the registry\n";
|
||||
bad++;
|
||||
} else {
|
||||
char why[160] = "";
|
||||
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
|
||||
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
|
||||
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
|
||||
<< "\n";
|
||||
}
|
||||
/* the mode comes from the engine, not from the UI */
|
||||
core.setMode(QStringLiteral("general"));
|
||||
if (core.mode() != QLatin1String("general")) {
|
||||
out << "selftest: mode did not stick\n";
|
||||
bad++;
|
||||
}
|
||||
app::unregisterPlatformTools(&core);
|
||||
if (cst_tool_find("notification_send") != nullptr) {
|
||||
out << "selftest: platform tools were not dropped\n";
|
||||
bad++;
|
||||
}
|
||||
core.close();
|
||||
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
|
||||
out.flush();
|
||||
return bad ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
/* --selftest stays headless: no window, no QML, no display needed. */
|
||||
bool selftest = false;
|
||||
for (int i = 1; i < argc; i++)
|
||||
if (strcmp(argv[i], "--selftest") == 0)
|
||||
selftest = true;
|
||||
if (selftest) {
|
||||
QCoreApplication app(argc, argv);
|
||||
return runSelftest();
|
||||
}
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
/* The window styles its own controls; the platform style only adds noise
|
||||
* here (the KDE Breeze plugin logs a line per registered type and trips
|
||||
* over the composer's TextArea). An explicit choice still wins. */
|
||||
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
|
||||
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
|
||||
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(
|
||||
QStringLiteral("CST-agent desktop front end (engine in-process)"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
|
||||
QStringLiteral("workspace directory"),
|
||||
QStringLiteral("dir"));
|
||||
QCommandLineOption providerOpt(QStringLiteral("provider"),
|
||||
QStringLiteral("provider name"),
|
||||
QStringLiteral("name"));
|
||||
QCommandLineOption modelOpt(QStringLiteral("model"),
|
||||
QStringLiteral("model name"), QStringLiteral("name"));
|
||||
QCommandLineOption planOpt(QStringLiteral("plan"),
|
||||
QStringLiteral("start in plan mode"));
|
||||
QCommandLineOption uitestOpt(
|
||||
QStringLiteral("uitest"),
|
||||
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
|
||||
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
||||
QStringLiteral("initial window size, e.g. 480x900"),
|
||||
QStringLiteral("WxH"));
|
||||
/* A device run cannot be handed an environment (see activityArguments()), so
|
||||
* what a desktop run asks for with CST_APP_SHOT=shot.png has to be an
|
||||
* argument here. */
|
||||
QCommandLineOption shotsOpt(
|
||||
QStringLiteral("shots"),
|
||||
QStringLiteral("--uitest: save a picture of the window at the interesting "
|
||||
"points, in the workspace"));
|
||||
parser.addOption(wsOpt);
|
||||
parser.addOption(providerOpt);
|
||||
parser.addOption(modelOpt);
|
||||
parser.addOption(planOpt);
|
||||
parser.addOption(uitestOpt);
|
||||
parser.addOption(sizeOpt);
|
||||
parser.addOption(shotsOpt);
|
||||
/* What the process was started with, plus -- on a phone -- what the
|
||||
* activity was started with: Qt's own applicationArguments plumbing does
|
||||
* not deliver the extra on the NX789J (see activityArguments()), and
|
||||
* without it there is no way to hand this window a workspace or a --uitest
|
||||
* run on a device that has no keyboard. */
|
||||
QStringList args = QCoreApplication::arguments();
|
||||
#if defined(Q_OS_ANDROID)
|
||||
args += app::activityArguments();
|
||||
#endif
|
||||
parser.process(args);
|
||||
|
||||
QString ws = parser.value(wsOpt);
|
||||
if (ws.isEmpty()) {
|
||||
#if defined(Q_OS_ANDROID)
|
||||
ws = androidRoot() + QStringLiteral("/workspace");
|
||||
#else
|
||||
ws = QDir::currentPath();
|
||||
#endif
|
||||
}
|
||||
ws = QFileInfo(ws).absoluteFilePath();
|
||||
|
||||
/* --uitest drives a scratch workspace when none was given: the session
|
||||
* checks create and delete sessions, which is not something to do in
|
||||
* whatever directory the app happened to be started from. */
|
||||
std::unique_ptr<QTemporaryDir> scratch;
|
||||
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
||||
scratch.reset(new QTemporaryDir);
|
||||
if (scratch->isValid()) {
|
||||
ws = scratch->path();
|
||||
/* settings.json and credentials live in <config>/cst-agent: point them
|
||||
* at the scratch dir too, so a test run never rewrites the user's own
|
||||
* provider choice or key. */
|
||||
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
|
||||
}
|
||||
}
|
||||
|
||||
/* Before the window exists: maybeShot() reads this at the point a picture is
|
||||
* wanted (the settings dialog, the end of the run). */
|
||||
if (parser.isSet(shotsOpt))
|
||||
qputenv("CST_APP_SHOT",
|
||||
(ws + QStringLiteral("/uitest-shot.png")).toUtf8());
|
||||
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* Before the engine is created: its settings, credentials and session store
|
||||
* all resolve their paths from the environment. */
|
||||
const QString root = androidRoot();
|
||||
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
|
||||
QDir().mkpath(root + QLatin1Char('/') + sub);
|
||||
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
|
||||
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
|
||||
qputenv("HOME", root.toUtf8());
|
||||
#endif
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
|
||||
QString(), &err)) {
|
||||
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
|
||||
return 1;
|
||||
}
|
||||
app::registerPlatformTools(&core); /* the app's own tools */
|
||||
#if defined(Q_OS_ANDROID)
|
||||
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
|
||||
* the tool is hidden while it is not granted (its probe says so): ask once,
|
||||
* here, so the tool is there for the first turn rather than a prompt that
|
||||
* shows up mid-answer. Denying it only costs the tool. */
|
||||
if (QNativeInterface::QAndroidApplication::context().isValid() &&
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
|
||||
.result() != QtAndroidPrivate::Authorized) {
|
||||
QtAndroidPrivate::requestPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
}
|
||||
/* "All files access" is the other half of storage: it is what lets the
|
||||
* engine read a path in shared storage (a file the user names, not one the
|
||||
* picker hands over -- that one is copied into the app either way, see
|
||||
* AttachFile.java). Android has no runtime dialog for it, only the Settings
|
||||
* screen, so it is opened once (a marker file remembers) and never
|
||||
* insisted on: without it everything still works, one picked file at a
|
||||
* time. The line below is what logcat shows when a device behaves oddly. */
|
||||
const bool allFiles = app::hasAllFilesAccess();
|
||||
qInfo("storage: all files access = %s", allFiles ? "yes" : "no");
|
||||
if (!allFiles) {
|
||||
const QString marker = androidRoot() + QStringLiteral("/.all-files-asked");
|
||||
if (!QFileInfo::exists(marker)) {
|
||||
const QString why = app::requestAllFilesAccess();
|
||||
if (!why.isEmpty())
|
||||
qWarning("all files access: %s", qUtf8Printable(why));
|
||||
QFile asked(marker);
|
||||
if (asked.open(QIODevice::WriteOnly))
|
||||
asked.write("1\n");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (parser.isSet(planOpt))
|
||||
core.setPlanMode(true);
|
||||
|
||||
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
|
||||
if (parser.isSet(uitestOpt))
|
||||
return app::runUiTest(core, parser.value(sizeOpt));
|
||||
|
||||
/* The layout is responsive; --size is how it gets exercised without a
|
||||
* different screen (see app/README.md). */
|
||||
int winW = 0, winH = 0;
|
||||
const QString size = parser.value(sizeOpt);
|
||||
if (!size.isEmpty()) {
|
||||
const int x = size.indexOf(QLatin1Char('x'));
|
||||
bool okW = false, okH = false;
|
||||
if (x > 0) {
|
||||
winW = size.left(x).toInt(&okW);
|
||||
winH = size.mid(x + 1).toInt(&okH);
|
||||
}
|
||||
if (!okW || !okH || winW < 320 || winH < 360) {
|
||||
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
/* image://csttheme/ plus the desktop's icon theme behind it. */
|
||||
app::installThemeIcons(&engine);
|
||||
/* image://cstattach/: the session's own store, so the pictures a tool made
|
||||
* (view_image, an MCP image) and the ones the user attached are shown, not
|
||||
* only named. The directory follows the session. */
|
||||
app::SessionImages *images = app::installSessionImages(&engine);
|
||||
images->setDir(core.attachmentDir());
|
||||
QObject::connect(&core, &AgentCore::sessionChanged, images,
|
||||
[images, &core] { images->setDir(core.attachmentDir()); });
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
|
||||
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
|
||||
QObject::connect(
|
||||
&engine, &QQmlApplicationEngine::objectCreated, &app,
|
||||
[url](QObject *obj, const QUrl &objUrl) {
|
||||
if (!obj && url == objUrl)
|
||||
QCoreApplication::exit(-1);
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
engine.load(url);
|
||||
if (engine.rootObjects().isEmpty())
|
||||
return 1;
|
||||
/* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */
|
||||
app::installBackKey(engine.rootObjects().value(0),
|
||||
engine.rootObjects().value(0));
|
||||
/* The system bars are drawn over the window (see requestFullscreen): ask for
|
||||
* the whole screen, and ask again whenever the app comes back to the front,
|
||||
* because a swipe from the edge or a switch to another app brings them back.
|
||||
* A refusal is not fatal -- it is logged and the window keeps working with
|
||||
* the bars over its top row. */
|
||||
{
|
||||
const QString why = app::requestFullscreen();
|
||||
if (!why.isEmpty())
|
||||
qWarning("fullscreen: %s", qUtf8Printable(why));
|
||||
QQuickWindow *qwin =
|
||||
qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
|
||||
if (qwin)
|
||||
QObject::connect(qwin, &QQuickWindow::sceneGraphInitialized, qwin,
|
||||
[] { app::requestFullscreen(); });
|
||||
QObject::connect(&app, &QGuiApplication::applicationStateChanged, &app,
|
||||
[](Qt::ApplicationState state) {
|
||||
if (state == Qt::ApplicationActive)
|
||||
app::requestFullscreen();
|
||||
});
|
||||
}
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,988 @@
|
||||
/* Platform tools on Android, implemented by the app: the same contract as
|
||||
* app/src/platform/linux/NotifyTool.cpp, with Android APIs behind it.
|
||||
*
|
||||
* The engine only ever sees the capability: `platforms = kAndroidOnly` (the
|
||||
* engine reports "android" when it was compiled with the NDK, see
|
||||
* src/tools/registry.c: cst_platform_name()) plus an `available` probe decide
|
||||
* whether a tool reaches the prompt and tools[] at all -- a Termux build on
|
||||
* the same device reports "termux" and gets the android tools too, an APK
|
||||
* never gets the desktop ones. `owner = "app:android"` is what
|
||||
* unregisterPlatformTools() drops again.
|
||||
*
|
||||
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never
|
||||
* on the GUI thread. JNI calls are fine from any thread (Qt attaches it),
|
||||
* Qt GUI objects are not -- a tool that needs the widget tree
|
||||
* has to hop back with a blocking queued call (onGuiThread() below).
|
||||
*
|
||||
* The clipboard tools keep the names of the engine's built-ins: those are
|
||||
* gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
|
||||
* name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
|
||||
* model sees one capability either way. */
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QFuture>
|
||||
#include <QGuiApplication>
|
||||
#include <QJniEnvironment>
|
||||
#include <QJniObject>
|
||||
#include <QMetaObject>
|
||||
#include <QSemaphore>
|
||||
#include <QStandardPaths>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QThread>
|
||||
|
||||
#include <QtCore/private/qandroidextras_p.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* --- small helpers ------------------------------------------------------- */
|
||||
|
||||
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||
for (int i = 0; i < call->nargs; i++)
|
||||
if (strcmp(call->args[i].key, key) == 0)
|
||||
return call->args[i].value;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
||||
r->out = strdup(text.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc;
|
||||
}
|
||||
|
||||
/* How long the worker is willing to wait for the GUI thread to pick its work
|
||||
* up. The hop itself is always waited for to the end (the closure writes into
|
||||
* the caller's stack), so what this bounds is the case that makes a turn look
|
||||
* stuck: a GUI thread that is not getting to the event loop at all. */
|
||||
constexpr int kGuiStallMs = 3000;
|
||||
|
||||
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
||||
* run on the GUI thread; the turn's worker thread waits for it.
|
||||
*
|
||||
* The wait is bounded: a blocking queued call to a GUI thread that is busy
|
||||
* (the session list reading transcripts used to do exactly that) would park
|
||||
* the worker for as long as it lasts. A cheap ping first tells whether the
|
||||
* GUI thread is there at all; only then is the real work handed over and
|
||||
* waited for, which keeps the closure valid for its whole life. */
|
||||
template <typename Fn>
|
||||
bool onGuiThread(Fn &&fn) {
|
||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||
fn();
|
||||
return true;
|
||||
}
|
||||
auto pinged = std::make_shared<QSemaphore>(0);
|
||||
QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); },
|
||||
Qt::QueuedConnection);
|
||||
if (!pinged->tryAcquire(1, kGuiStallMs))
|
||||
return false; /* a GUI thread that is not answering: do not wait on it */
|
||||
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||
Qt::BlockingQueuedConnection);
|
||||
return true;
|
||||
}
|
||||
|
||||
jint sdkInt() {
|
||||
static const jint sdk = QJniObject::getStaticField<jint>(
|
||||
"android/os/Build$VERSION", "SDK_INT");
|
||||
return sdk;
|
||||
}
|
||||
|
||||
QJniObject appContext() {
|
||||
return QNativeInterface::QAndroidApplication::context();
|
||||
}
|
||||
|
||||
/* The strings the activity was started with. Qt is meant to hand the
|
||||
* "applicationArguments" extra to QCoreApplication::arguments() by itself; on
|
||||
* the NX789J it does not -- `am start ... --esa applicationArguments
|
||||
* "--workspace,/sdcard/x"` left the window on its default workspace, and so
|
||||
* did the same extra as a plain string. So the intent is asked directly, which
|
||||
* is what makes `cst-agent-app --uitest --workspace DIR` (or any other
|
||||
* argument) reachable on a device with no keyboard attached.
|
||||
*
|
||||
* Everything here degrades to "no arguments": an invalid object, a missing
|
||||
* method, or a context that is not an activity all come back empty. */
|
||||
QStringList intentArguments() {
|
||||
QStringList out;
|
||||
QJniObject activity = appContext();
|
||||
if (!activity.isValid())
|
||||
return out;
|
||||
QJniObject intent =
|
||||
activity.callObjectMethod("getIntent", "()Landroid/content/Intent;");
|
||||
if (!intent.isValid())
|
||||
return out;
|
||||
const QJniObject key =
|
||||
QJniObject::fromString(QStringLiteral("applicationArguments"));
|
||||
/* Three shapes for the same extra, and which one arrives depends on who
|
||||
* sent it: `am start --esa applicationArguments "--uitest,--workspace,/x"`
|
||||
* stores a String[] (an empty launch, arguments and all, is what that cost
|
||||
* us -- getStringArrayListExtra answers null for an array), a launcher or
|
||||
* Qt's own loader uses putStringArrayListExtra, and `--es` puts one
|
||||
* comma-separated string there. Try each, in that order. */
|
||||
const QJniObject array = intent.callObjectMethod(
|
||||
"getStringArrayExtra", "(Ljava/lang/String;)[Ljava/lang/String;",
|
||||
key.object<jstring>());
|
||||
if (array.isValid()) {
|
||||
QJniEnvironment env;
|
||||
const jobjectArray a = reinterpret_cast<jobjectArray>(array.object());
|
||||
const jsize n = env->GetArrayLength(a);
|
||||
for (jsize i = 0; i < n; i++) {
|
||||
const jstring item =
|
||||
reinterpret_cast<jstring>(env->GetObjectArrayElement(a, i));
|
||||
if (!item)
|
||||
continue;
|
||||
const char *c = env->GetStringUTFChars(item, nullptr);
|
||||
if (c) {
|
||||
out << QString::fromUtf8(c);
|
||||
env->ReleaseStringUTFChars(item, c);
|
||||
}
|
||||
env->DeleteLocalRef(item);
|
||||
}
|
||||
if (!out.isEmpty())
|
||||
return out;
|
||||
}
|
||||
QJniObject list = intent.callObjectMethod(
|
||||
"getStringArrayListExtra", "(Ljava/lang/String;)Ljava/util/ArrayList;",
|
||||
key.object<jstring>());
|
||||
if (list.isValid()) {
|
||||
const jint n = list.callMethod<jint>("size", "()I");
|
||||
for (jint i = 0; i < n; i++) {
|
||||
QJniObject item =
|
||||
list.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
|
||||
if (item.isValid())
|
||||
out << item.toString();
|
||||
}
|
||||
if (!out.isEmpty())
|
||||
return out;
|
||||
}
|
||||
const QJniObject one = intent.callObjectMethod(
|
||||
"getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;",
|
||||
key.object<jstring>());
|
||||
if (one.isValid()) {
|
||||
const QStringList parts =
|
||||
one.toString().split(QLatin1Char(','), Qt::SkipEmptyParts);
|
||||
out << parts;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* --- notification_send --------------------------------------------------- */
|
||||
|
||||
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
||||
* that the permission does not exist and they always work. main.cpp asks for
|
||||
* it once at startup, so the probe is normally green on a device where the
|
||||
* user said yes. */
|
||||
int notifyProbe(char *why, size_t why_sz) {
|
||||
if (sdkInt() < 33)
|
||||
return 0;
|
||||
const QFuture<QtAndroidPrivate::PermissionResult> f =
|
||||
QtAndroidPrivate::checkPermission(
|
||||
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
|
||||
if (f.result() == QtAndroidPrivate::Authorized)
|
||||
return 0;
|
||||
snprintf(why, why_sz,
|
||||
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
|
||||
"first time the app runs)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool postNotification(const QString &title, const QString &message,
|
||||
QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject manager = ctx.callObjectMethod(
|
||||
"getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
QJniObject::fromString(QStringLiteral("notification")).object<jstring>());
|
||||
if (!manager.isValid()) {
|
||||
*err = QStringLiteral("no NotificationManager");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString channelId = QStringLiteral("cst-agent");
|
||||
if (sdkInt() >= 26) {
|
||||
QJniObject channel(
|
||||
"android/app/NotificationChannel",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;I)V",
|
||||
QJniObject::fromString(channelId).object<jstring>(),
|
||||
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
|
||||
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
|
||||
manager.callMethod<void>("createNotificationChannel",
|
||||
"(Landroid/app/NotificationChannel;)V",
|
||||
channel.object<jobject>());
|
||||
}
|
||||
|
||||
/* The small icon. The R classes are not reachable from here, so the id is
|
||||
* looked up by name, and build() throws IllegalArgumentException ("Invalid
|
||||
* notification (no valid small icon)") when there is none at all: every
|
||||
* candidate that can be there is tried -- the framework drawables first,
|
||||
* then the launcher icon the APK itself carries (AGP puts it in mipmap, a
|
||||
* hand made APK in drawable/icon). */
|
||||
QJniObject res = ctx.callObjectMethod("getResources",
|
||||
"()Landroid/content/res/Resources;");
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
||||
"()Ljava/lang/String;");
|
||||
jint icon = 0;
|
||||
if (res.isValid()) {
|
||||
auto idOf = [&res, &pkg](const char *name, const char *type,
|
||||
const char *owner) {
|
||||
return res.callMethod<jint>(
|
||||
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
|
||||
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
|
||||
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
|
||||
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
|
||||
: pkg.object<jstring>());
|
||||
};
|
||||
for (const char *n : {"ic_dialog_info", "ic_popup_reminder",
|
||||
"stat_notify_chat", "sym_def_app_icon"}) {
|
||||
icon = idOf(n, "drawable", "android");
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) {
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "mipmap", nullptr);
|
||||
if (icon == 0)
|
||||
icon = idOf(n, "drawable", nullptr);
|
||||
if (icon != 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (icon == 0) {
|
||||
*err = QStringLiteral("no small icon: it is in neither the framework "
|
||||
"resources nor the APK's own");
|
||||
return false;
|
||||
}
|
||||
/* Tapping it opens the app again (the launch intent of the package). */
|
||||
QJniObject pm = ctx.callObjectMethod(
|
||||
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
||||
/* The builder, the build and the notify happen in Java (Notifier.java):
|
||||
* a Java exception is invisible to C++ once Qt's JNI wrapper has cleared
|
||||
* it, so on the phone this only ever said "failed, no exception detail"
|
||||
* while the shade stayed empty. Java catches it and hands back the line to
|
||||
* show. */
|
||||
QJniObject launch;
|
||||
if (pm.isValid() && pkg.isValid())
|
||||
launch = pm.callObjectMethod(
|
||||
"getLaunchIntentForPackage",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>());
|
||||
QJniObject posted = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/Notifier", "post",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;"
|
||||
"Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;",
|
||||
ctx.object<jobject>(),
|
||||
QJniObject::fromString(channelId).object<jstring>(),
|
||||
QJniObject::fromString(title).object<jstring>(),
|
||||
QJniObject::fromString(message).object<jstring>(), icon,
|
||||
launch.isValid() ? launch.object<jobject>() : nullptr,
|
||||
static_cast<jint>(1));
|
||||
const QString java =
|
||||
posted.isValid() ? posted.toString()
|
||||
: QStringLiteral("the Notifier helper is not in this APK");
|
||||
if (!java.isEmpty()) {
|
||||
*err = QStringLiteral("cannot post the notification: %1").arg(java);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Runs on the turn's worker thread: no GUI objects here. */
|
||||
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *message = argOf(call, "message");
|
||||
const char *title = argOf(call, "title");
|
||||
if (!message || !message[0]) {
|
||||
setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = title && title[0] ? QString::fromUtf8(title)
|
||||
: QStringLiteral("CST-agent");
|
||||
const QString m = QString::fromUtf8(message);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = postNotification(t, m, &err); });
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("notification sent"), 0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- clipboard_read / clipboard_write ----------------------------------- */
|
||||
|
||||
int clipboardProbe(char *why, size_t why_sz) {
|
||||
if (QGuiApplication::clipboard())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no QClipboard (no GUI application)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int clipboardReadRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *cap = argOf(call, "max_bytes");
|
||||
const long max =
|
||||
cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L;
|
||||
QString text;
|
||||
bool have = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
text = cb->text();
|
||||
have = true;
|
||||
}
|
||||
});
|
||||
if (!have) {
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
QByteArray body = text.toUtf8();
|
||||
const bool cut = max > 0 && body.size() > max;
|
||||
if (cut)
|
||||
body.truncate(static_cast<int>(max));
|
||||
QByteArray out =
|
||||
QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) +
|
||||
QByteArrayLiteral(" bytes") +
|
||||
(cut ? QByteArrayLiteral(", truncated") : QByteArray()) +
|
||||
QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n");
|
||||
setOut(r, out, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int clipboardWriteRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *text = argOf(call, "text");
|
||||
if (!text) {
|
||||
setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1);
|
||||
return 0;
|
||||
}
|
||||
const QString t = QString::fromUtf8(text);
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
if (QClipboard *cb = QGuiApplication::clipboard()) {
|
||||
cb->setText(t);
|
||||
ok = true;
|
||||
}
|
||||
});
|
||||
if (ok)
|
||||
setOut(r, QByteArrayLiteral("clipboard: replaced with ") +
|
||||
QByteArray::number(t.toUtf8().size()) +
|
||||
QByteArrayLiteral(" bytes"),
|
||||
0);
|
||||
else
|
||||
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- share --------------------------------------------------------------- */
|
||||
|
||||
/* A path inside the workspace, resolved the way the engine's file tools do:
|
||||
* relative to the workspace and never outside it. */
|
||||
QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) {
|
||||
if (!ctx || !ctx->workspace_root || !rel || !rel[0])
|
||||
return QString();
|
||||
const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root));
|
||||
const QString full = QDir::cleanPath(root + QLatin1Char('/') +
|
||||
QString::fromUtf8(rel));
|
||||
if (full != root && !full.startsWith(root + QLatin1Char('/')))
|
||||
return QString();
|
||||
return full;
|
||||
}
|
||||
|
||||
QString mimeOf(const QString &path) {
|
||||
const QString ext = QFileInfo(path).suffix().toLower();
|
||||
if (ext == QLatin1String("png")) return QStringLiteral("image/png");
|
||||
if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg"))
|
||||
return QStringLiteral("image/jpeg");
|
||||
if (ext == QLatin1String("webp")) return QStringLiteral("image/webp");
|
||||
if (ext == QLatin1String("gif")) return QStringLiteral("image/gif");
|
||||
if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf");
|
||||
if (ext == QLatin1String("json")) return QStringLiteral("application/json");
|
||||
if (ext == QLatin1String("txt") || ext == QLatin1String("md") ||
|
||||
ext == QLatin1String("log"))
|
||||
return QStringLiteral("text/plain");
|
||||
return QStringLiteral("*/*");
|
||||
}
|
||||
|
||||
/* ACTION_SEND through the system share sheet. `text` shares a sentence (the
|
||||
* usual case: the user takes it to another app), `file` shares one file out of
|
||||
* the workspace by way of the FileProvider Qt's manifest template declares
|
||||
* (androidx.core.content.FileProvider, authority <package>.qtprovider). */
|
||||
bool shareText(const QString &text, const QString &subject, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
send.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("text/plain"))
|
||||
.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
if (!subject.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(subject).object<jstring>());
|
||||
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
|
||||
.object<jstring>());
|
||||
/* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000) /* NEW_TASK */);
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The content:// uri of a file inside the app's own data directory. The
|
||||
* FileProvider and its paths xml come from Qt's manifest template
|
||||
* (authority <package>.qtprovider, files-path "/"), which covers the whole
|
||||
* workspace and the session store. */
|
||||
QJniObject contentUriFor(const QString &path, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return QJniObject();
|
||||
}
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;");
|
||||
const QString authority = pkg.toString() + QStringLiteral(".qtprovider");
|
||||
QJniObject file("java/io/File", "(Ljava/lang/String;)V",
|
||||
QJniObject::fromString(path).object<jstring>());
|
||||
QJniObject uri = QJniObject::callStaticObjectMethod(
|
||||
"androidx/core/content/FileProvider", "getUriForFile",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
|
||||
ctx.object<jobject>(),
|
||||
QJniObject::fromString(authority).object<jstring>(),
|
||||
file.object<jobject>());
|
||||
if (!uri.isValid())
|
||||
*err = QStringLiteral("the FileProvider did not hand out a uri for ") + path;
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool shareFile(const QString &path, const QString &text, QString *err) {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
*err = QStringLiteral("no Android context");
|
||||
return false;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, err);
|
||||
if (!uri.isValid())
|
||||
return false;
|
||||
QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod("setType",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
|
||||
.object<jstring>(),
|
||||
uri.object<jobject>());
|
||||
if (!text.isEmpty())
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
|
||||
.object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */));
|
||||
QJniObject sheet = QJniObject::callStaticObjectMethod(
|
||||
"android/content/Intent", "createChooser",
|
||||
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
|
||||
intent.object<jobject>(),
|
||||
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
|
||||
QJniObject toStart = sheet.isValid() ? sheet : intent;
|
||||
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000));
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
toStart.object<jobject>());
|
||||
return true;
|
||||
}
|
||||
|
||||
int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ud;
|
||||
const char *text = argOf(call, "text");
|
||||
const char *file = argOf(call, "file");
|
||||
const char *subject = argOf(call, "subject");
|
||||
if ((!text || !text[0]) && (!file || !file[0])) {
|
||||
setOut(r, QByteArrayLiteral("error: share needs text or file"), 1);
|
||||
return 0;
|
||||
}
|
||||
QString path;
|
||||
if (file && file[0]) {
|
||||
path = resolveInWorkspace(ctx, file);
|
||||
if (path.isEmpty() || !QFileInfo::exists(path)) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) +
|
||||
QByteArrayLiteral(" is not inside the workspace"),
|
||||
1);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const QString body = text ? QString::fromUtf8(text) : QString();
|
||||
const QString subjectLine = subject ? QString::fromUtf8(subject) : QString();
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
ok = path.isEmpty() ? shareText(body, subjectLine, &err)
|
||||
: shareFile(path, body, &err);
|
||||
});
|
||||
if (!ok) {
|
||||
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
|
||||
return 0;
|
||||
}
|
||||
setOut(r, path.isEmpty()
|
||||
? QByteArrayLiteral("the share sheet is open with the text")
|
||||
: QByteArrayLiteral("the share sheet is open with ") +
|
||||
path.toUtf8(),
|
||||
0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int shareProbe(char *why, size_t why_sz) {
|
||||
if (appContext().isValid())
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no Android activity to share from");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||
cst_tool_def_t g_notify;
|
||||
cst_tool_def_t g_clip_read;
|
||||
cst_tool_def_t g_clip_write;
|
||||
cst_tool_def_t g_share;
|
||||
|
||||
} // namespace
|
||||
|
||||
/* What main() asks for (ToolProvider.h): the reading itself is the JNI helper
|
||||
* above, next to the Android context it needs. It sits outside the anonymous
|
||||
* namespace so main.cpp can link to it. */
|
||||
QStringList activityArguments() { return intentArguments(); }
|
||||
|
||||
/* A file card's "open": whatever app the phone has for the type, with the read
|
||||
* permission granted to the app that picks it up. */
|
||||
QString openLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
err = QStringLiteral("no Android context");
|
||||
return;
|
||||
}
|
||||
QJniObject uri = contentUriFor(path, &err);
|
||||
if (!uri.isValid())
|
||||
return;
|
||||
QJniObject action =
|
||||
QJniObject::fromString(QStringLiteral("android.intent.action.VIEW"));
|
||||
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
|
||||
action.object<jstring>());
|
||||
intent.callObjectMethod(
|
||||
"setDataAndType",
|
||||
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
|
||||
uri.object<jobject>(),
|
||||
QJniObject::fromString(mimeOf(path)).object<jstring>());
|
||||
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
|
||||
static_cast<jint>(0x10000000 /* NEW_TASK */ |
|
||||
0x00000001 /* GRANT_READ_URI */));
|
||||
/* Nothing installed for the type: say so rather than fail silently. */
|
||||
QJniObject pm = ctx.callObjectMethod(
|
||||
"getPackageManager", "()Landroid/content/pm/PackageManager;");
|
||||
QJniObject found =
|
||||
pm.isValid()
|
||||
? pm.callObjectMethod(
|
||||
"resolveActivity",
|
||||
"(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;",
|
||||
intent.object<jobject>(),
|
||||
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
|
||||
: QJniObject();
|
||||
if (!found.isValid()) {
|
||||
err = QStringLiteral("no app on the phone opens %1").arg(name);
|
||||
return;
|
||||
}
|
||||
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
|
||||
intent.object<jobject>());
|
||||
ok = true;
|
||||
});
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* The share sheet, the same way the share tool opens it. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QString err;
|
||||
bool ok = false;
|
||||
onGuiThread([&] { ok = shareFile(path, QString(), &err); });
|
||||
if (!ok)
|
||||
return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err;
|
||||
if (status)
|
||||
*status = QStringLiteral("share sheet: %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* An Intent aimed at one of our own classes, by name: the Java side is
|
||||
* reached through an explicit component, so no other app can call it (and no
|
||||
* intent filter has to exist). Empty action means "start". */
|
||||
QJniObject serviceIntent(const QJniObject &ctx, const QString &action) {
|
||||
QJniObject pkg = ctx.callObjectMethod("getPackageName",
|
||||
"()Ljava/lang/String;");
|
||||
if (!pkg.isValid())
|
||||
return QJniObject();
|
||||
QJniObject comp(
|
||||
"android/content/ComponentName", "(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
pkg.object<jstring>(),
|
||||
QJniObject::fromString(
|
||||
QStringLiteral("xyz.whcself.cstagent.CstService"))
|
||||
.object<jstring>());
|
||||
if (!comp.isValid())
|
||||
return QJniObject();
|
||||
QJniObject intent("android/content/Intent");
|
||||
intent.callObjectMethod(
|
||||
"setComponent",
|
||||
"(Landroid/content/ComponentName;)Landroid/content/Intent;",
|
||||
comp.object<jobject>());
|
||||
if (!action.isEmpty())
|
||||
intent.callObjectMethod("setAction",
|
||||
"(Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(action).object<jstring>());
|
||||
return intent;
|
||||
}
|
||||
|
||||
/* A turn can outlive the screen, and a phone freezes or kills an app it
|
||||
* cannot see: CstService (app/android/src/xyz/whcself/cstagent/CstService.java)
|
||||
* is started as a foreground service for as long as one runs, and it takes the
|
||||
* partial wake lock that keeps the CPU awake with the screen off. The explicit
|
||||
* component means nothing outside the app can start it. */
|
||||
QString holdForeground(const QString &text) {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
||||
return QString(); /* a test run leaves the phone alone */
|
||||
QString err;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
err = QStringLiteral("no Android context");
|
||||
return;
|
||||
}
|
||||
QJniObject intent = serviceIntent(ctx, QString());
|
||||
if (!intent.isValid()) {
|
||||
err = QStringLiteral("no ComponentName for CstService");
|
||||
return;
|
||||
}
|
||||
intent.callObjectMethod(
|
||||
"putExtra",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
|
||||
QJniObject::fromString(QStringLiteral("text")).object<jstring>(),
|
||||
QJniObject::fromString(text).object<jstring>());
|
||||
QJniObject started =
|
||||
sdkInt() >= 26
|
||||
? ctx.callObjectMethod(
|
||||
"startForegroundService",
|
||||
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
||||
intent.object<jobject>())
|
||||
: ctx.callObjectMethod(
|
||||
"startService",
|
||||
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
|
||||
intent.object<jobject>());
|
||||
if (!started.isValid())
|
||||
err = QStringLiteral("the phone refused the foreground service");
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
void releaseForeground() {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
|
||||
return;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
QJniObject intent = serviceIntent(
|
||||
ctx, QStringLiteral("xyz.whcself.cstagent.STOP"));
|
||||
if (intent.isValid())
|
||||
ctx.callMethod<jboolean>("stopService",
|
||||
"(Landroid/content/Intent;)Z",
|
||||
intent.object<jobject>());
|
||||
});
|
||||
}
|
||||
|
||||
/* What the picker gave us -> a path the engine can open. A SAF URI is copied
|
||||
* into the app's own cache (the URI's grant covers the read; no permission is
|
||||
* involved), a plain path is left alone. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||
if (err)
|
||||
err->clear();
|
||||
if (pathOrUri.isEmpty())
|
||||
return QString();
|
||||
if (!pathOrUri.startsWith(QLatin1String("content://"))) {
|
||||
QString p = pathOrUri;
|
||||
if (p.startsWith(QLatin1String("file://")))
|
||||
p = p.mid(7);
|
||||
return p;
|
||||
}
|
||||
const QString dest = QStandardPaths::writableLocation(
|
||||
QStandardPaths::CacheLocation) +
|
||||
QStringLiteral("/picked");
|
||||
QString result, failure;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid()) {
|
||||
failure = QStringLiteral("no Android context to copy the file with");
|
||||
return;
|
||||
}
|
||||
QJniObject out = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/AttachFile", "materialize",
|
||||
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)"
|
||||
"Ljava/lang/String;",
|
||||
ctx.object<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
|
||||
QJniObject::fromString(dest).object<jstring>());
|
||||
if (!out.isValid()) {
|
||||
failure = QStringLiteral("the phone could not hand that file over");
|
||||
return;
|
||||
}
|
||||
const QString s = out.toString();
|
||||
if (s.startsWith(QLatin1String("error: ")))
|
||||
failure = s.mid(7);
|
||||
else if (!s.isEmpty())
|
||||
result = s;
|
||||
/* "" means it was a path all along; the caller keeps what it had */
|
||||
});
|
||||
if (!failure.isEmpty()) {
|
||||
if (err)
|
||||
*err = failure;
|
||||
return QString();
|
||||
}
|
||||
return result.isEmpty() ? pathOrUri : result;
|
||||
}
|
||||
|
||||
bool hasAllFilesAccess() {
|
||||
bool granted = true;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
granted = QJniObject::callStaticMethod<jboolean>(
|
||||
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
|
||||
"(Landroid/content/Context;)Z", ctx.object<jobject>());
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
QString requestAllFilesAccess() {
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_PERM"))
|
||||
return QString(); /* a test run touches no settings */
|
||||
QString err;
|
||||
onGuiThread([&] {
|
||||
QJniObject ctx = appContext();
|
||||
if (!ctx.isValid())
|
||||
return;
|
||||
QJniObject out = QJniObject::callStaticObjectMethod(
|
||||
"xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess",
|
||||
"(Landroid/content/Context;)Ljava/lang/String;",
|
||||
ctx.object<jobject>());
|
||||
if (out.isValid()) {
|
||||
const QString s = out.toString();
|
||||
if (!s.isEmpty())
|
||||
err = s;
|
||||
}
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
/* The status bar -- and on a tablet in desktop mode the taskbar, which is a
|
||||
* status bar full of icons -- is drawn over the window, and this window draws
|
||||
* from its own first pixel, so the bars land on the app's top row and the
|
||||
* controls there cannot be touched. Which call hides them depends on the API
|
||||
* level: WindowInsetsController from Android 11 (API 30) on, the older
|
||||
* systemUiVisibility flags before it. Measured on the tablet: the app's own
|
||||
* title row was under the taskbar, this puts the window on the whole screen
|
||||
* and the row is touchable again.
|
||||
*
|
||||
* Nothing here is fatal: an invalid context, a missing class or a null insets
|
||||
* controller comes back as a reason (which main.cpp logs) and the window keeps
|
||||
* working with the bars over it. */
|
||||
QString requestFullscreen() {
|
||||
QString err;
|
||||
onGuiThread([&] {
|
||||
QJniObject activity = appContext();
|
||||
if (!activity.isValid()) {
|
||||
err = QStringLiteral("no activity");
|
||||
return;
|
||||
}
|
||||
QJniObject window =
|
||||
activity.callObjectMethod("getWindow", "()Landroid/view/Window;");
|
||||
if (!window.isValid()) {
|
||||
err = QStringLiteral("no window");
|
||||
return;
|
||||
}
|
||||
if (sdkInt() >= 30) {
|
||||
/* Edge to edge plus the bars hidden: the window is told to fill the
|
||||
* screen itself rather than lay out inside the bars. */
|
||||
window.callMethod<void>("setDecorFitsSystemWindows", "(Z)V", false);
|
||||
QJniObject control = window.callObjectMethod(
|
||||
"getInsetsController", "()Landroid/view/WindowInsetsController;");
|
||||
if (!control.isValid()) {
|
||||
err = QStringLiteral("no insets controller");
|
||||
return;
|
||||
}
|
||||
const jint bars = QJniObject::callStaticMethod<jint>(
|
||||
"android/view/WindowInsets$Type", "systemBars", "()I");
|
||||
control.callMethod<void>("hide", "(I)V", bars);
|
||||
/* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the edge brings
|
||||
* the bars back for a moment and they leave again by themselves. */
|
||||
control.callMethod<void>("setSystemBarsBehavior", "(I)V", 2);
|
||||
return;
|
||||
}
|
||||
QJniObject decor =
|
||||
window.callObjectMethod("getDecorView", "()Landroid/view/View;");
|
||||
if (!decor.isValid()) {
|
||||
err = QStringLiteral("no decor view");
|
||||
return;
|
||||
}
|
||||
/* SYSTEM_UI_FLAG_HIDE_NAVIGATION | FULLSCREEN | LAYOUT_STABLE |
|
||||
* LAYOUT_HIDE_NAVIGATION | LAYOUT_FULLSCREEN | IMMERSIVE_STICKY */
|
||||
const jint flags = 0x00000002 | 0x00000004 | 0x00000100 | 0x00000200 |
|
||||
0x00000400 | 0x00001000;
|
||||
decor.callMethod<void>("setSystemUiVisibility", "(I)V", flags);
|
||||
});
|
||||
return err;
|
||||
}
|
||||
|
||||
int platformRegisterAndroid(AgentCore *core) {
|
||||
if (!core)
|
||||
return 0;
|
||||
int n = 0;
|
||||
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
{"title", "string", 0, "title line (default: CST-agent)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_notify, 0, sizeof(g_notify));
|
||||
setMeta(&g_notify, "notification_send",
|
||||
"Show a notification in the phone's shade (what the user sees "
|
||||
"outside the app).",
|
||||
CST_TP_ASK, "notify", kAndroidOnly, notifyProbe);
|
||||
memcpy(g_notify.params, params, sizeof(params));
|
||||
g_notify.run_ex = notifyRun;
|
||||
g_notify.owner = "app:android";
|
||||
n += core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"max_bytes", "integer", 0, "cap on the returned text (default 65536)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_read, 0, sizeof(g_clip_read));
|
||||
setMeta(&g_clip_read, "clipboard_read",
|
||||
"Read the text on the phone's clipboard — what the user copied "
|
||||
"outside the workspace. Returns UTF-8 text.",
|
||||
CST_TP_READ, "read", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_read.params, params, sizeof(params));
|
||||
g_clip_read.run_ex = clipboardReadRun;
|
||||
g_clip_read.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_read) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 1, "text to put on the clipboard"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_clip_write, 0, sizeof(g_clip_write));
|
||||
setMeta(&g_clip_write, "clipboard_write",
|
||||
"Replace the phone's clipboard with text, so the user can paste it "
|
||||
"into another app. Overwrites what they had copied.",
|
||||
CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe);
|
||||
memcpy(g_clip_write.params, params, sizeof(params));
|
||||
g_clip_write.run_ex = clipboardWriteRun;
|
||||
g_clip_write.owner = "app:android";
|
||||
n += core->registerTool(&g_clip_write) ? 1 : 0;
|
||||
}
|
||||
{
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"text", "string", 0, "text to share"},
|
||||
{"file", "string", 0, "workspace-relative file to share"},
|
||||
{"subject", "string", 0, "subject line of the shared item"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_share, 0, sizeof(g_share));
|
||||
setMeta(&g_share, "share",
|
||||
"Hand text or a workspace file to the system share sheet, so the "
|
||||
"user can send it to another app.",
|
||||
CST_TP_ASK, "share", kAndroidOnly, shareProbe);
|
||||
memcpy(g_share.params, params, sizeof(params));
|
||||
g_share.run_ex = shareRun;
|
||||
g_share.owner = "app:android";
|
||||
n += core->registerTool(&g_share) ? 1 : 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,184 @@
|
||||
/* Platform tools on Linux, implemented by the app (not by the engine): they
|
||||
* exist exactly where the desktop service does, and the engine only ever sees
|
||||
* the capability -- platforms + probe decide whether the tool is offered. */
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* whereis-style check without a shell: the same rule the engine uses for the
|
||||
* clipboard helpers. */
|
||||
bool haveProgram(const char *name) {
|
||||
QByteArray path = qgetenv("PATH");
|
||||
const QList<QByteArray> dirs = path.split(':');
|
||||
for (const QByteArray &d : dirs) {
|
||||
QByteArray dir = d.isEmpty() ? QByteArray(".") : d;
|
||||
QByteArray cand = dir + "/" + name;
|
||||
if (QFileInfo::exists(QString::fromLocal8Bit(cand)) &&
|
||||
QFileInfo(QString::fromLocal8Bit(cand)).isExecutable())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 0 when notifications can be shown; -1 with the reason otherwise. */
|
||||
int notifyProbe(char *why, size_t why_sz) {
|
||||
if (haveProgram("notify-send"))
|
||||
return 0;
|
||||
if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty())
|
||||
return 0;
|
||||
snprintf(why, why_sz,
|
||||
"no notification service (no notify-send, no D-Bus session bus)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||
for (int i = 0; i < call->nargs; i++)
|
||||
if (strcmp(call->args[i].key, key) == 0)
|
||||
return call->args[i].value;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/* Runs on the turn's worker thread: no GUI objects here. */
|
||||
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *title = argOf(call, "title");
|
||||
const char *message = argOf(call, "message");
|
||||
if (!message || !message[0]) {
|
||||
const char *msg = "error: notification_send needs a message";
|
||||
r->out = strdup(msg);
|
||||
r->out_len = r->out ? strlen(msg) : 0;
|
||||
r->exit_code = 1;
|
||||
return 0;
|
||||
}
|
||||
QStringList args;
|
||||
if (title && title[0])
|
||||
args << QString::fromUtf8(title);
|
||||
else
|
||||
args << QStringLiteral("CST-agent");
|
||||
args << QString::fromUtf8(message);
|
||||
const int rc = QProcess::execute(QStringLiteral("notify-send"), args);
|
||||
const QByteArray text =
|
||||
(rc == 0 ? QByteArrayLiteral("notification sent")
|
||||
: QByteArrayLiteral("notify-send failed (rc=") +
|
||||
QByteArray::number(rc) + QByteArrayLiteral(")"));
|
||||
r->out = strdup(text.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = rc == 0 ? 0 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||
cst_tool_def_t g_notify;
|
||||
|
||||
} // namespace
|
||||
|
||||
/* A file card's "open": xdg-open through QDesktopServices, so the desktop's
|
||||
* own application for the type comes up. */
|
||||
QString openLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
|
||||
return QStringLiteral("no application on this desktop opens %1").arg(name);
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* No share sheet here: the path is what another program (or another window)
|
||||
* needs, so it goes to the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QGuiApplication::clipboard()->setText(path);
|
||||
if (status)
|
||||
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* A desktop has nothing to hold: the window is the app, and a turn runs as
|
||||
* long as it is open -- the machine does not freeze a process it can see.
|
||||
* Kept here so the front end calls one API on every platform. */
|
||||
QString holdForeground(const QString &text) {
|
||||
(void)text;
|
||||
return QString();
|
||||
}
|
||||
void releaseForeground() {}
|
||||
|
||||
/* A desktop picker hands over a path, so there is nothing to turn into one:
|
||||
* a file:// URL loses its scheme and a content:// URI (a portal that never
|
||||
* gave us a path) is refused with what the user should do instead. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||
QString s = pathOrUri;
|
||||
if (s.startsWith(QLatin1String("file://")))
|
||||
s = s.mid(7);
|
||||
if (s.startsWith(QLatin1String("content://"))) {
|
||||
if (err)
|
||||
*err = QStringLiteral("that picker handed over a content:// URI this "
|
||||
"desktop cannot open; pick the file by path "
|
||||
"instead");
|
||||
return QString();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Every desktop process reads the files its user can. */
|
||||
bool hasAllFilesAccess() { return true; }
|
||||
QString requestAllFilesAccess() { return QString(); }
|
||||
|
||||
/* A desktop window is not drawn under anything of the desktop's own: the bars
|
||||
* a panel draws are the panel's, and nothing here can be asked for them. */
|
||||
QString requestFullscreen() { return QString(); }
|
||||
|
||||
int platformRegisterLinux(AgentCore *core) {
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
{"title", "string", 0, "title line (default: CST-agent)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_notify, 0, sizeof(g_notify));
|
||||
setMeta(&g_notify, "notification_send",
|
||||
"Show a desktop notification (what the user sees outside the app).",
|
||||
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
|
||||
memcpy(g_notify.params, params, sizeof(params));
|
||||
g_notify.run = nullptr;
|
||||
g_notify.run_ex = notifyRun;
|
||||
g_notify.ud = nullptr;
|
||||
g_notify.owner = "app:linux";
|
||||
return core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,196 @@
|
||||
/* Platform tools on Windows, implemented by the app (not by the engine): they
|
||||
* exist exactly where the desktop service does, and the engine only ever sees
|
||||
* the capability -- platforms + probe decide whether the tool is offered.
|
||||
*
|
||||
* The Linux file next door is the same shape; what differs is the service
|
||||
* behind each call. A notification is the scripting host's popup (Wscript.Shell
|
||||
* through PowerShell) because Windows has no notify-send and a toast needs the
|
||||
* WinRT API; it carries the same two arguments and answers the same way. */
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QClipboard>
|
||||
#include <QDesktopServices>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QProcess>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* whereis-style check without a shell: PATH is split on the Windows separator
|
||||
* and a name may need one of the executable suffixes. */
|
||||
bool haveProgram(const char *name) {
|
||||
const QByteArray path = qgetenv("PATH");
|
||||
const QList<QByteArray> dirs = path.split(';');
|
||||
const QList<QByteArray> exts = {QByteArray(), QByteArray(".exe"),
|
||||
QByteArray(".cmd"), QByteArray(".bat")};
|
||||
for (const QByteArray &d : dirs) {
|
||||
if (d.isEmpty())
|
||||
continue;
|
||||
for (const QByteArray &ext : exts) {
|
||||
const QString cand = QFileInfo(QString::fromLocal8Bit(d + "\\" + name +
|
||||
ext))
|
||||
.absoluteFilePath();
|
||||
if (QFileInfo::exists(cand) && QFileInfo(cand).isExecutable())
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 0 when notifications can be shown; -1 with the reason otherwise. */
|
||||
int notifyProbe(char *why, size_t why_sz) {
|
||||
if (haveProgram("powershell") || haveProgram("pwsh"))
|
||||
return 0;
|
||||
snprintf(why, why_sz, "no powershell on PATH to show a popup with");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||
for (int i = 0; i < call->nargs; i++)
|
||||
if (strcmp(call->args[i].key, key) == 0)
|
||||
return call->args[i].value;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/* Runs on the turn's worker thread: no GUI objects here. The popup is started
|
||||
* detached -- it waits for its own timeout, and the turn must not. */
|
||||
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)ud;
|
||||
const char *title = argOf(call, "title");
|
||||
const char *message = argOf(call, "message");
|
||||
if (!message || !message[0]) {
|
||||
const char *msg = "error: notification_send needs a message";
|
||||
r->out = strdup(msg);
|
||||
r->out_len = r->out ? strlen(msg) : 0;
|
||||
r->exit_code = 1;
|
||||
return 0;
|
||||
}
|
||||
const QString shown =
|
||||
title && title[0] ? QString::fromUtf8(title) : QStringLiteral("CST-agent");
|
||||
/* The popup's own text is single-quoted for PowerShell; a quote inside it is
|
||||
* doubled, which is how PowerShell escapes one. */
|
||||
QString text = QString::fromUtf8(message);
|
||||
text.replace(QLatin1Char('\''), QLatin1String("''"));
|
||||
QString heading = shown;
|
||||
heading.replace(QLatin1Char('\''), QLatin1String("''"));
|
||||
const QString script =
|
||||
QStringLiteral("$w = New-Object -ComObject Wscript.Shell; "
|
||||
"$w.Popup('%1', 8, '%2', 64) | Out-Null")
|
||||
.arg(text, heading);
|
||||
const QString exe = haveProgram("pwsh") ? QStringLiteral("pwsh")
|
||||
: QStringLiteral("powershell");
|
||||
QStringList args;
|
||||
args << QStringLiteral("-NoProfile") << QStringLiteral("-NonInteractive")
|
||||
<< QStringLiteral("-Command") << script;
|
||||
const bool started = QProcess::startDetached(exe, args);
|
||||
const QByteArray out = started
|
||||
? QByteArrayLiteral("notification shown")
|
||||
: QByteArrayLiteral("could not start ") +
|
||||
exe.toLocal8Bit();
|
||||
r->out = strdup(out.constData());
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = started ? 0 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||
cst_tool_def_t g_notify;
|
||||
|
||||
} // namespace
|
||||
|
||||
/* A file card's "open": ShellExecute through QDesktopServices, so the file's
|
||||
* own application comes up. */
|
||||
QString openLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
|
||||
return QStringLiteral("no application on this machine opens %1").arg(name);
|
||||
if (status)
|
||||
*status = QStringLiteral("opened %1").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* No share sheet here: the path is what another program needs, so it goes to
|
||||
* the clipboard. */
|
||||
QString shareLocalFile(const QString &path, const QString &title,
|
||||
QString *status) {
|
||||
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||
if (status)
|
||||
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||
return QString();
|
||||
}
|
||||
if (!QFileInfo::exists(path))
|
||||
return QStringLiteral("the file is gone: %1").arg(name);
|
||||
QGuiApplication::clipboard()->setText(path);
|
||||
if (status)
|
||||
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
|
||||
return QString();
|
||||
}
|
||||
|
||||
/* Windows does not freeze a process whose window is open, and the app's own
|
||||
* window is what keeps it alive: nothing to hold. */
|
||||
QString holdForeground(const QString &text) {
|
||||
(void)text;
|
||||
return QString();
|
||||
}
|
||||
void releaseForeground() {}
|
||||
|
||||
/* A native file dialog hands over a path, so there is nothing to turn into
|
||||
* one: a file:// URL loses its scheme and anything else is taken as a path. */
|
||||
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||
(void)err;
|
||||
QString s = pathOrUri;
|
||||
if (s.startsWith(QLatin1String("file://")))
|
||||
s = s.mid(7);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Every process on Windows reads the files its user can. */
|
||||
bool hasAllFilesAccess() { return true; }
|
||||
QString requestAllFilesAccess() { return QString(); }
|
||||
|
||||
/* A Windows window has its own frame; there is no system bar drawn over it. */
|
||||
QString requestFullscreen() { return QString(); }
|
||||
|
||||
int platformRegisterWindows(AgentCore *core) {
|
||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||
{"message", "string", 1, "text of the notification"},
|
||||
{"title", "string", 0, "title line (default: CST-agent)"},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr},
|
||||
{nullptr, nullptr, 0, nullptr}};
|
||||
memset(&g_notify, 0, sizeof(g_notify));
|
||||
setMeta(&g_notify, "notification_send",
|
||||
"Show a notification outside the window (a popup that goes away by "
|
||||
"itself).",
|
||||
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
|
||||
memcpy(g_notify.params, params, sizeof(params));
|
||||
g_notify.run = nullptr;
|
||||
g_notify.run_ex = notifyRun;
|
||||
g_notify.ud = nullptr;
|
||||
g_notify.owner = "app:windows";
|
||||
return core->registerTool(&g_notify) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "ui/BackKey.h"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMetaObject>
|
||||
#include <QObject>
|
||||
#include <QVariant>
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
class BackKeyFilter : public QObject {
|
||||
public:
|
||||
BackKeyFilter(QObject *window, QObject *root)
|
||||
: QObject(window), m_window(window), m_root(root) {}
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override {
|
||||
if (!m_root || watched != m_window ||
|
||||
(event->type() != QEvent::KeyPress &&
|
||||
event->type() != QEvent::KeyRelease))
|
||||
return QObject::eventFilter(watched, event);
|
||||
QKeyEvent *key = static_cast<QKeyEvent *>(event);
|
||||
if (key->key() != Qt::Key_Back)
|
||||
return QObject::eventFilter(watched, event);
|
||||
/* The key is down and up: a press that was taken is followed by a release
|
||||
* that goes with it, so the platform never sees half of a key it did not
|
||||
* get. */
|
||||
if (event->type() == QEvent::KeyRelease)
|
||||
return m_tookPress ? (m_tookPress = false, true)
|
||||
: QObject::eventFilter(watched, event);
|
||||
QVariant layer;
|
||||
if (!QMetaObject::invokeMethod(m_root, "backLayer",
|
||||
Q_RETURN_ARG(QVariant, layer)))
|
||||
return QObject::eventFilter(watched, event);
|
||||
if (layer.toString().isEmpty())
|
||||
return QObject::eventFilter(watched, event); /* nothing open: not ours */
|
||||
/* One layer back, on the next turn of the event loop (the dialogs insist,
|
||||
* see Main.qml's stepBack). The key is spent: the window stays. */
|
||||
QMetaObject::invokeMethod(m_root, "stepBack");
|
||||
m_tookPress = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
QObject *m_window; /* whose keys are read; outlives this filter */
|
||||
QObject *m_root; /* the window's QML root */
|
||||
bool m_tookPress = false;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void installBackKey(QObject *window, QObject *root) {
|
||||
if (!window || !root)
|
||||
return;
|
||||
/* On the application, not on the window: a key press arrives here as an
|
||||
* event sent to the window, but a filter installed on the window itself
|
||||
* never sees one (measured -- a window-installed filter stayed silent while
|
||||
* the same key closed the drawer through a QML Shortcut). The filter keeps
|
||||
* to `window`'s own events, so nothing else in the process is touched. */
|
||||
QCoreApplication::instance()->installEventFilter(
|
||||
new BackKeyFilter(window, root));
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,33 @@
|
||||
/* The phone's back key, read before anything else sees the event.
|
||||
*
|
||||
* Android hands its back button to the window as a Qt::Key_Back key press, and
|
||||
* the platform leaves the activity (the window goes away) when nothing takes
|
||||
* the key. What takes it here is one layer at a time -- the dialog on top,
|
||||
* then the sliding sidebar -- and only while there is a layer: the window's
|
||||
* own QML answers which (Main.qml's backLayer()) and closes it (stepBack()).
|
||||
*
|
||||
* This lives in C++ rather than in a QML Shortcut because a window-level
|
||||
* shortcut never sees the key while a modal popup is up: the dialog keeps it
|
||||
* (a desktop run that presses Back with the settings dialog open shows the
|
||||
* shortcut does not fire, while the drawer -- no popup -- closes fine). A
|
||||
* filter on the window runs before the shortcut map, the popup and the focused
|
||||
* item, so the same key does the same thing on every platform; with nothing
|
||||
* open the key is passed on untouched, which is what leaves the window on a
|
||||
* phone and does nothing on a desktop.
|
||||
*
|
||||
* A window must not be given a second one: installBackKey() installs once. */
|
||||
#ifndef CST_APP_BACK_KEY_H
|
||||
#define CST_APP_BACK_KEY_H
|
||||
|
||||
class QObject;
|
||||
|
||||
namespace app {
|
||||
|
||||
/* `window` is the QQuickWindow (the object key events are sent to), `root` the
|
||||
* QML root object of that window (the one carrying backLayer()/stepBack()).
|
||||
* The filter is owned by the window. */
|
||||
void installBackKey(QObject *window, QObject *root);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif // CST_APP_BACK_KEY_H
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "ui/SessionImages.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QQmlApplicationEngine>
|
||||
|
||||
#include "cst_attach.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* "img-1a2b3c4d.png?w=160": the stored name, then what the view asked for.
|
||||
* The size comes from the QML side as sourceSize, which Qt passes in as
|
||||
* requestedSize; the query string is accepted too, so a plain URL can ask. */
|
||||
QString relOf(const QString &id) {
|
||||
const int q = id.indexOf(QLatin1Char('?'));
|
||||
return q < 0 ? id : id.left(q);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionImages::SessionImages()
|
||||
: QQuickImageProvider(QQuickImageProvider::Image) {}
|
||||
|
||||
void SessionImages::setDir(const QString &dir) {
|
||||
QMutexLocker lock(&m_mu);
|
||||
m_dir = dir;
|
||||
}
|
||||
|
||||
QImage SessionImages::requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) {
|
||||
const QString rel = relOf(id);
|
||||
if (!cst_attach_rel_ok(rel.toUtf8().constData()))
|
||||
return QImage(); /* QML sees Image.Error and falls back to the name */
|
||||
QString dir;
|
||||
{
|
||||
QMutexLocker lock(&m_mu);
|
||||
dir = m_dir;
|
||||
}
|
||||
if (dir.isEmpty())
|
||||
return QImage();
|
||||
const QString path = dir + QLatin1Char('/') + rel;
|
||||
if (!QFileInfo::exists(path))
|
||||
return QImage();
|
||||
|
||||
QImageReader reader(path);
|
||||
reader.setAutoTransform(true);
|
||||
const QSize full = reader.size();
|
||||
if (!full.isValid()) {
|
||||
/* No size in the header (or not a picture at all): hand back what Qt
|
||||
* makes of it and let the view scale it. */
|
||||
const QImage img = reader.read();
|
||||
if (size)
|
||||
*size = img.size();
|
||||
return img;
|
||||
}
|
||||
/* Shrink to what the view shows, never up: a screenshot is decoded at the
|
||||
* size of its thumbnail instead of at 4K. */
|
||||
const bool smaller = requestedSize.isValid() && requestedSize.width() > 0 &&
|
||||
requestedSize.height() > 0 &&
|
||||
(full.width() > requestedSize.width() ||
|
||||
full.height() > requestedSize.height());
|
||||
if (smaller) {
|
||||
const QSize scaled = full.scaled(requestedSize, Qt::KeepAspectRatio);
|
||||
reader.setScaledSize(scaled);
|
||||
if (size)
|
||||
*size = scaled;
|
||||
} else if (size) {
|
||||
*size = full;
|
||||
}
|
||||
return reader.read();
|
||||
}
|
||||
|
||||
SessionImages *installSessionImages(QQmlApplicationEngine *engine) {
|
||||
SessionImages *images = new SessionImages;
|
||||
if (engine)
|
||||
engine->addImageProvider(QStringLiteral("cstattach"), images);
|
||||
return images;
|
||||
}
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,45 @@
|
||||
/* The session's stored images as a QML image provider:
|
||||
*
|
||||
* image://cstattach/<stored name> (see cst_attach.h: "img-1a2b3c4d.png")
|
||||
*
|
||||
* The files live in the current session's attachment directory, which is what
|
||||
* AgentCore::attachmentDir() names. Only that directory is served, and only
|
||||
* names that pass the engine's own cst_attach_rel_ok() -- a transcript can
|
||||
* carry hand-edited JSON, and nothing in it may reach outside the store.
|
||||
*
|
||||
* requestImage() runs on Quick's render thread while setDir() comes from the
|
||||
* window's thread, hence the mutex. */
|
||||
#ifndef CST_APP_SESSION_IMAGES_H
|
||||
#define CST_APP_SESSION_IMAGES_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QMutex>
|
||||
#include <QQuickImageProvider>
|
||||
#include <QString>
|
||||
|
||||
class QQmlApplicationEngine;
|
||||
|
||||
namespace app {
|
||||
|
||||
class SessionImages : public QQuickImageProvider {
|
||||
public:
|
||||
SessionImages();
|
||||
|
||||
/* The directory holding the current session's images; "" disables serving. */
|
||||
void setDir(const QString &dir);
|
||||
|
||||
QImage requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) override;
|
||||
|
||||
private:
|
||||
mutable QMutex m_mu;
|
||||
QString m_dir;
|
||||
};
|
||||
|
||||
/* Registers image://cstattach/ on the engine and hands the provider back, so
|
||||
* the caller can keep its directory in step with the agent's session. */
|
||||
SessionImages *installSessionImages(QQmlApplicationEngine *engine);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif // CST_APP_SESSION_IMAGES_H
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "ui/ThemeIcons.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QPainter>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QScreen>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
|
||||
/* What a usable theme has to have; every window in the app needs it. */
|
||||
const char kProbe[] = "mail-attachment";
|
||||
|
||||
/* The desktop's own theme first (whatever the platform theme set), then
|
||||
* Breeze: this window is dark, so the dark variant, and Breeze proper as
|
||||
* Qt's fallback for the odd name that only lives there. Before any of that
|
||||
* the search paths have to be filled in -- Qt only builds them from XDG once
|
||||
* a theme name was set, and a shell that was started outside the desktop
|
||||
* session (nix-shell) has no XDG_DATA_DIRS pointing at the icon packages,
|
||||
* hence the system profile. */
|
||||
void adoptIconTheme() {
|
||||
const QStringList known = QStandardPaths::standardLocations(
|
||||
QStandardPaths::GenericDataLocation);
|
||||
QStringList paths;
|
||||
for (const QString &d : known)
|
||||
paths << d + QStringLiteral("/icons");
|
||||
paths << QDir::homePath() + QStringLiteral("/.icons");
|
||||
const QString system = QStringLiteral("/run/current-system/sw/share/icons");
|
||||
if (!paths.contains(system) && QDir(system).exists())
|
||||
paths << system;
|
||||
paths << QStringLiteral(":/icons");
|
||||
QIcon::setThemeSearchPaths(paths);
|
||||
|
||||
QStringList candidates;
|
||||
if (!QIcon::themeName().isEmpty())
|
||||
candidates << QIcon::themeName();
|
||||
candidates << QStringLiteral("breeze-dark") << QStringLiteral("breeze")
|
||||
<< QStringLiteral("hicolor");
|
||||
for (const QString &name : candidates) {
|
||||
QIcon::setThemeName(name);
|
||||
if (QIcon::hasThemeIcon(QLatin1String(kProbe)))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* The bundled set: app/icons/*.svg, compiled in as :/cst-icons/<name>.svg by
|
||||
* app/CMakeLists.txt. A machine without an icon theme -- Android, where the
|
||||
* Qt theme lookup finds nothing and QML was left showing its glyph fallback,
|
||||
* which the phone fonts cannot draw -- gets the same icons from here. */
|
||||
QIcon bundledIcon(const QString &name) {
|
||||
return QIcon(QStringLiteral(":/cst-icons/") + name + QStringLiteral(".svg"));
|
||||
}
|
||||
|
||||
QIcon iconForName(const QString &name) {
|
||||
const QIcon themed = QIcon::fromTheme(name);
|
||||
if (!themed.isNull())
|
||||
return themed;
|
||||
return bundledIcon(name);
|
||||
}
|
||||
|
||||
/* "?color=%23eff0f1": the tint, and nothing else so far. */
|
||||
QColor colorOption(const QString &id, QString *name) {
|
||||
QColor color;
|
||||
*name = id;
|
||||
const int q = id.indexOf(QLatin1Char('?'));
|
||||
if (q < 0)
|
||||
return color;
|
||||
*name = id.left(q);
|
||||
const QStringList options = id.mid(q + 1).split(QLatin1Char('&'));
|
||||
for (const QString &opt : options) {
|
||||
if (!opt.startsWith(QLatin1String("color=")))
|
||||
continue;
|
||||
const QColor c(QUrl::fromPercentEncoding(opt.mid(6).toUtf8()));
|
||||
if (c.isValid())
|
||||
color = c;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
qreal targetRatio() {
|
||||
if (const QScreen *s = QGuiApplication::primaryScreen())
|
||||
return qMax<qreal>(1.0, s->devicePixelRatio());
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ThemeIconProvider::ThemeIconProvider()
|
||||
: QQuickImageProvider(QQuickImageProvider::Image) {}
|
||||
|
||||
QImage ThemeIconProvider::requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) {
|
||||
QString name;
|
||||
const QColor color = colorOption(id, &name);
|
||||
if (name.isEmpty())
|
||||
return QImage();
|
||||
|
||||
const QIcon icon = iconForName(name);
|
||||
if (icon.isNull())
|
||||
return QImage(); /* QML shows its fallback glyph */
|
||||
|
||||
const int px = requestedSize.isValid() && requestedSize.width() > 0
|
||||
? requestedSize.width()
|
||||
: 22;
|
||||
const qreal ratio = targetRatio();
|
||||
QPixmap pm = icon.pixmap(QSize(px, px), ratio, QIcon::Normal, QIcon::Off);
|
||||
if (pm.isNull())
|
||||
return QImage();
|
||||
if (color.isValid()) {
|
||||
QPainter p(&pm);
|
||||
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||
p.fillRect(pm.rect(), color);
|
||||
}
|
||||
QImage img = pm.toImage();
|
||||
img.setDevicePixelRatio(ratio);
|
||||
if (size)
|
||||
*size = QSize(px, px);
|
||||
return img;
|
||||
}
|
||||
|
||||
void installThemeIcons(QQmlApplicationEngine *engine) {
|
||||
adoptIconTheme();
|
||||
if (engine)
|
||||
engine->addImageProvider(QStringLiteral("csttheme"), new ThemeIconProvider);
|
||||
}
|
||||
|
||||
QString iconThemeName() { return QIcon::themeName(); }
|
||||
|
||||
bool themeIconAvailable(const QString &name) { return !iconForName(name).isNull(); }
|
||||
|
||||
} // namespace app
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Icons from the desktop's icon theme (Breeze on KDE).
|
||||
*
|
||||
* The window did its icons with unicode glyphs (a clip emoji for attach, a
|
||||
* gear, a hamburger): they render in whatever font happens to cover them --
|
||||
* colour emoji in a grey toolbar -- and on a machine without that glyph they
|
||||
* are a box. Every other desktop app asks the icon theme instead, and Qt
|
||||
* Quick has no image provider for it without Kirigami, so this is one:
|
||||
*
|
||||
* image://csttheme/<icon-name>?color=%23rrggbb
|
||||
*
|
||||
* The icon is looked up with QIcon::fromTheme() in the desktop's theme
|
||||
* (breeze-dark on a KDE desktop, Breeze as fallback), tinted to the asked-for
|
||||
* color and rendered at the requested size. An unknown name yields a null
|
||||
* image, which QML sees as Image.Error -- that is the hook for a glyph
|
||||
* fallback, used on a machine with no icon theme at all. */
|
||||
|
||||
#ifndef CST_APP_THEMEICONS_H
|
||||
#define CST_APP_THEMEICONS_H
|
||||
|
||||
#include <QImage>
|
||||
#include <QQuickImageProvider>
|
||||
#include <QString>
|
||||
|
||||
class QQmlApplicationEngine;
|
||||
|
||||
namespace app {
|
||||
|
||||
class ThemeIconProvider : public QQuickImageProvider {
|
||||
public:
|
||||
ThemeIconProvider();
|
||||
QImage requestImage(const QString &id, QSize *size,
|
||||
const QSize &requestedSize) override;
|
||||
};
|
||||
|
||||
/* Points Qt at the desktop's icon theme and registers image://csttheme/.
|
||||
* Call it on every engine that loads the window (main.cpp and --uitest). */
|
||||
void installThemeIcons(QQmlApplicationEngine *engine);
|
||||
|
||||
/* The theme Qt was left on, for --selftest and the README. */
|
||||
QString iconThemeName();
|
||||
|
||||
/* Whether the theme has this icon (what the fallback glyphs are for). */
|
||||
bool themeIconAvailable(const QString &name);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_THEMEICONS_H */
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The --uitest entry point: see UiTest.cpp for what it drives. */
|
||||
|
||||
#ifndef CST_APP_UITEST_H
|
||||
#define CST_APP_UITEST_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
|
||||
namespace app {
|
||||
|
||||
/* Loads the window, drives its controls with synthesized mouse/touch input
|
||||
* (and a resize to the compact layout), prints one line per check and returns
|
||||
* how many failed as the process exit code. `size` is the usual WxH string,
|
||||
* empty for the default. */
|
||||
int runUiTest(AgentCore &core, const QString &size);
|
||||
|
||||
} // namespace app
|
||||
|
||||
#endif /* CST_APP_UITEST_H */
|
||||
@@ -0,0 +1,158 @@
|
||||
/* The app's C smoke test: does the engine embed the way app/ needs it to?
|
||||
*
|
||||
* It links libcstcore.a (or the cstcore target) and runs without Qt, so the
|
||||
* C side of the GUI stays verifiable on any machine -- including CI and the
|
||||
* Android cross-build. `cst-agent-app --selftest` runs the same checks inside
|
||||
* the real process. */
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_tools.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <process.h>
|
||||
#include <direct.h>
|
||||
#define getpid _getpid
|
||||
#else
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
static int failures;
|
||||
|
||||
#define CHECK(cond, what) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* A tool the app would implement (the Linux build uses notify-send; here it
|
||||
* only has to prove the plumbing). */
|
||||
static int ctx_runs;
|
||||
static int app_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||
cst_tool_result_t *r, void *ud) {
|
||||
(void)ctx;
|
||||
(void)call;
|
||||
(void)ud;
|
||||
ctx_runs++;
|
||||
r->out = strdup("app tool ran");
|
||||
r->out_len = r->out ? strlen(r->out) : 0;
|
||||
r->exit_code = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int probe_missing(char *why, size_t why_sz) {
|
||||
snprintf(why, why_sz, "the desktop service is not running");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* The scratch workspace goes where this host can actually write: /tmp on a
|
||||
* desktop, the app's own directory on Android (where /tmp is off limits,
|
||||
* so TMPDIR is pointed at it -- see app/src/main.cpp). */
|
||||
char tmp[1024] = "";
|
||||
if (os_temp_dir(tmp, sizeof(tmp)) != 0 || !tmp[0])
|
||||
snprintf(tmp, sizeof(tmp), "%s", "/tmp");
|
||||
char ws[1200];
|
||||
snprintf(ws, sizeof(ws), "%s/cst-app-selftest-%d", tmp, (int)getpid());
|
||||
#ifdef _WIN32
|
||||
_mkdir(ws); /* the engine wants its workspace to exist */
|
||||
#else
|
||||
mkdir(ws, 0700);
|
||||
#endif
|
||||
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", ws);
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||
snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1");
|
||||
snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key");
|
||||
snprintf(cfg.mode, sizeof(cfg.mode), "general");
|
||||
cfg.max_steps = 1;
|
||||
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
CHECK(a != NULL, "the app can start an engine in its own process");
|
||||
if (!a) {
|
||||
fprintf(stderr, "cst-app-selftest: cannot start the engine\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 1. a tool that exists here (no probe) is offered to the model */
|
||||
cst_tool_def_t plain;
|
||||
memset(&plain, 0, sizeof(plain));
|
||||
plain.name = "app_ping";
|
||||
plain.desc = "A tool the app provides.";
|
||||
plain.perm = CST_TP_READ;
|
||||
plain.run_ex = app_tool_run;
|
||||
plain.owner = "app:selftest";
|
||||
CHECK(cst_tool_register(&plain) == 0, "register an app tool");
|
||||
const cst_tool_def_t *t = cst_tool_find("app_ping");
|
||||
CHECK(t != NULL, "it is findable");
|
||||
CHECK(t && cst_tool_active(cst_agent_config(a), t), "and it is offered");
|
||||
ctl_tool_call_t call;
|
||||
memset(&call, 0, sizeof(call));
|
||||
snprintf(call.name, sizeof(call.name), "app_ping");
|
||||
cst_tool_result_t res;
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = ws;
|
||||
ctx.agent = a;
|
||||
cst_tool_run(&ctx, &call, &res);
|
||||
CHECK(ctx_runs == 1, "the app callback ran");
|
||||
CHECK(res.out && strcmp(res.out, "app tool ran") == 0, "its result came back");
|
||||
cst_tool_result_free(&res);
|
||||
|
||||
/* 2. a tool whose platform service is missing is never offered ... */
|
||||
cst_tool_def_t missing;
|
||||
memset(&missing, 0, sizeof(missing));
|
||||
missing.name = "app_no_service";
|
||||
missing.desc = "Needs a service that is not here.";
|
||||
missing.perm = CST_TP_ASK;
|
||||
missing.perm_name = "notify";
|
||||
missing.available = probe_missing;
|
||||
missing.run_ex = app_tool_run;
|
||||
missing.owner = "app:selftest";
|
||||
CHECK(cst_tool_register(&missing) == 0, "register a tool with a probe");
|
||||
const cst_tool_def_t *mt = cst_tool_find("app_no_service");
|
||||
char why[160] = "";
|
||||
CHECK(mt && cst_tool_available(mt, why, sizeof(why)) != 0,
|
||||
"the probe says it cannot run");
|
||||
CHECK(strstr(why, "desktop service") != NULL, "and passes its reason on");
|
||||
CHECK(mt && !cst_tool_active(cst_agent_config(a), mt),
|
||||
"so it stays out of tools[] and the prompt");
|
||||
snprintf(call.name, sizeof(call.name), "app_no_service");
|
||||
cst_tool_run(&ctx, &call, &res);
|
||||
CHECK(res.out && strstr(res.out, "cannot run here") != NULL,
|
||||
"asking for it anyway is answered with the reason");
|
||||
CHECK(ctx_runs == 1, "and its callback never ran");
|
||||
cst_tool_result_free(&res);
|
||||
|
||||
/* 3. the app can drop its tools again */
|
||||
CHECK(cst_tool_unregister_owner("app:selftest") == 2, "unregister both");
|
||||
CHECK(cst_tool_find("app_ping") == NULL, "app_ping is gone");
|
||||
CHECK(cst_tool_find("read_file") != NULL, "the built-ins are untouched");
|
||||
|
||||
cst_agent_destroy(a);
|
||||
{
|
||||
char cmd[1200];
|
||||
snprintf(cmd, sizeof(cmd), "rm -rf %s", ws);
|
||||
if (system(cmd) != 0)
|
||||
fprintf(stderr, "note: could not clean %s\n", ws);
|
||||
}
|
||||
if (failures) {
|
||||
fprintf(stderr, "cst-app-selftest: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf("cst-app-selftest: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# NixOS and other nix users: the desktop front end (Qt Quick) with the engine
|
||||
# it embeds, built and installed the nix way.
|
||||
#
|
||||
# nix-build # ./result/bin/cst-agent-app, run it in place
|
||||
# nix-env -f . -i cst-agent # into the user profile: PATH, menu entry, icon
|
||||
#
|
||||
# The Qt apps hook is what makes the installed binary find Qt's plugins and
|
||||
# QML modules outside a nix-shell (this is also what keeps them out of
|
||||
# nix-collect-garbage's reach). Only the window is installed: the CLI has its
|
||||
# own installer, scripts/bootstrap_linux.sh.
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
let
|
||||
# The version lives in one place: the header the engine and the APK read.
|
||||
vLine = re:
|
||||
let
|
||||
hit = pkgs.lib.findFirst
|
||||
(l: builtins.match "#define ${re} ([0-9]+)" l != null) ""
|
||||
(pkgs.lib.splitString "\n" (builtins.readFile ./include/cst_version.h));
|
||||
in
|
||||
builtins.head (builtins.match "#define ${re} ([0-9]+)" hit);
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "cst-agent";
|
||||
version =
|
||||
"${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}";
|
||||
|
||||
# Not the whole checkout: third_party/ (the static curl/mbedTLS/ncurses
|
||||
# sources, the NDK builds), the build directories and the session store are
|
||||
# gigabytes this build never reads -- curl, ncurses and Qt come from nixpkgs.
|
||||
src = builtins.filterSource
|
||||
(path: type:
|
||||
!(builtins.elem (baseNameOf path) [
|
||||
".agent"
|
||||
".cursor"
|
||||
".gen"
|
||||
".git"
|
||||
"build-android-arm64-v8a"
|
||||
"build-apk-android"
|
||||
"build-win-tui"
|
||||
"dist"
|
||||
"result"
|
||||
"third_party"
|
||||
]))
|
||||
./.;
|
||||
|
||||
nativeBuildInputs = with pkgs; [ cmake pkg-config qt6.wrapQtAppsHook ];
|
||||
# qtwayland so a Wayland session gets a native client, qtsvg for the icon
|
||||
# theme: Breeze's icons are SVG, and without the image format plugin the
|
||||
# window falls back to text glyphs.
|
||||
buildInputs = with pkgs; [
|
||||
qt6.qtbase
|
||||
qt6.qtdeclarative
|
||||
qt6.qtwayland
|
||||
qt6.qtsvg
|
||||
curl
|
||||
curl.dev
|
||||
ncurses
|
||||
ncurses.dev
|
||||
];
|
||||
|
||||
cmakeFlags = [ "-DCST_BUILD_APP=ON" "-DCMAKE_BUILD_TYPE=Release" ];
|
||||
|
||||
# The "app" component is the window, its launcher entry and the icon;
|
||||
# app/CMakeLists.txt is the one place that knows where each goes.
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
cmake --install . --component app --prefix "$out"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
doCheck = true;
|
||||
# One test cannot pass here: tests/test_sandbox.c has the engine run a
|
||||
# sandboxed command and asserts Landlock *denied* the write outside the
|
||||
# workspace, and Landlock does not work inside the nix build sandbox -- the
|
||||
# command succeeds and the assertion trips. The other 40 run; the full set
|
||||
# is `make check` / `ctest` in the dev tree.
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
ctest --output-on-failure -j"$NIX_BUILD_CORES" -E test_sandbox
|
||||
runHook postCheck
|
||||
'';
|
||||
# The tests write settings, sessions, undo files and a scratch workspace:
|
||||
# give them a home of their own.
|
||||
preCheck = ''
|
||||
export HOME="$TMPDIR/home"
|
||||
export XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache"
|
||||
export XDG_DATA_HOME="$HOME/.local/share"
|
||||
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME"
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description =
|
||||
"Local coding agent: Qt Quick front end with the engine in-process";
|
||||
homepage = "https://git.whcself.xyz/WHC/CST-agent";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
mainProgram = "cst-agent-app";
|
||||
};
|
||||
}
|
||||
@@ -1,82 +1,208 @@
|
||||
# CST-Agent on Windows
|
||||
|
||||
## Release layout
|
||||
原生 Windows 版:一个静态链接的 `cst-agent.exe`(UCRT,内嵌 UTF-8 清单)加一个
|
||||
`busybox.exe`(busybox-w32 Unicode 版)作为 shell,不依赖其他 DLL,也不需要管理员权限。
|
||||
|
||||
```
|
||||
cst-agent.exe
|
||||
busybox.exe # built from busybox-w32
|
||||
libwinpthread-1.dll # MinGW runtime (required)
|
||||
LICENSE.busybox
|
||||
README-windows.md
|
||||
## 安装
|
||||
|
||||
### 安装包
|
||||
|
||||
从 [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
|
||||
irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
|
||||
```
|
||||
|
||||
Prebuilt tree from Linux cross-compile: `agent/dist/windows-x86_64/`.
|
||||
|
||||
## Build (MinGW-w64 cross from Linux)
|
||||
|
||||
```bash
|
||||
cd agent
|
||||
# 1) third-party: curl (Schannel) + PDCurses + busybox-w32
|
||||
./scripts/build_third_party_mingw.sh all
|
||||
# sources:
|
||||
# https://github.com/rmyorston/busybox-w32.git
|
||||
# https://github.com/curl/curl.git
|
||||
# https://github.com/wmcbrine/PDCurses.git
|
||||
# Unix busybox (optional, Linux/Termux bundle):
|
||||
# ./scripts/build_third_party_mingw.sh busybox-unix
|
||||
# https://github.com/mirror/busybox.git
|
||||
|
||||
# 2) headless
|
||||
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
-DCST_BUILD_TUI=OFF -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
|
||||
cmake --build build-win
|
||||
|
||||
# 3) TUI (PDCurses)
|
||||
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw \
|
||||
-DPDCurses_ROOT=$PWD/third_party/prefix-mingw
|
||||
cmake --build build-win-tui
|
||||
```
|
||||
|
||||
Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and
|
||||
`libwinpthread-1.dll` into one folder.
|
||||
|
||||
## Build natively on Windows (MSYS2)
|
||||
|
||||
The CMake build also works directly on Windows with MSYS2's mingw-w64
|
||||
toolchain -- no separate Linux host needed:
|
||||
|
||||
```bash
|
||||
# in an MSYS2 MINGW64 shell
|
||||
pacman -S --needed --noconfirm \
|
||||
mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-make \
|
||||
mingw-w64-x86_64-curl mingw-w64-x86_64-pdcurses
|
||||
|
||||
cd agent
|
||||
cmake -B build-win -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build-win --target cst-agent -j"$(nproc)"
|
||||
```
|
||||
|
||||
`CST_BUILD_TUI=ON` (the default) picks up MSYS2's PDCurses automatically,
|
||||
including its `include/pdcurses/` layout; set `-DPDCurses_ROOT=...` only for
|
||||
a custom PDCurses.
|
||||
|
||||
Two differences from the cross-build release layout above:
|
||||
|
||||
- **Extra DLLs.** MSYS2's curl package is the shared OpenSSL build, so the
|
||||
executable needs its whole dependency chain beside it. Collect it with
|
||||
`ldd build-win/cst-agent.exe | grep mingw64`.
|
||||
- **Only build the `cst-agent` target.** Some sources under `tests/` are
|
||||
POSIX-only (two-argument `mkdir`, `setenv`, `arpa/inet.h`, `symlink`) and
|
||||
do not compile with MinGW. The main program is unaffected.
|
||||
|
||||
## Run
|
||||
脚本从 Gitea Release 下载 `cst-agent-VERSION-windows-x86_64.zip`,校验 `.sha256`,
|
||||
装到 `%LOCALAPPDATA%\Programs\cst-agent`,并把这个目录加进**用户** PATH。装完新开一个终端:
|
||||
|
||||
```bat
|
||||
set CST_API_KEY=...
|
||||
cst-agent.exe --headless --workspace . --prompt "echo hi"
|
||||
cst-agent.exe --workspace .
|
||||
cd C:\code\my-project
|
||||
cst-agent
|
||||
```
|
||||
|
||||
Shell: bundled `busybox.exe sh -c ...` unless `CST_SHELL` is set.
|
||||
Credentials: `%APPDATA%\cst-agent\credentials` (or console prompt).
|
||||
首次启动会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)。
|
||||
|
||||
以后升级:
|
||||
|
||||
```bat
|
||||
cst-agent-update :: 最新版
|
||||
cst-agent-update -Version 0.6.0 :: 指定版本
|
||||
```
|
||||
|
||||
正在运行的 `cst-agent.exe` 不会挡住升级:旧文件会先改名为 `.old`,下次升级时删掉。
|
||||
|
||||
安装脚本的参数(下载后用 `powershell -ExecutionPolicy Bypass -File install_windows.ps1 ...` 运行):
|
||||
|
||||
| 参数 | 环境变量 | 作用 |
|
||||
|---|---|---|
|
||||
| `-Version 0.6.0` | `CST_VERSION` | 指定版本,默认最新 |
|
||||
| `-Prefix D:\tools\cst` | `CST_PREFIX` | 安装目录 |
|
||||
| `-Zip FILE` | | 用本地 zip,不联网 |
|
||||
| `-NoPath` | | 不改用户 PATH |
|
||||
| | `CST_GITEA` / `CST_REPO` | 换 Gitea 服务器或仓库(默认 `WHC/CST-agent`) |
|
||||
|
||||
不想用脚本的话,把 zip 解压到任意目录直接运行 `cst-agent.exe` 也可以。
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Windows 10 1903(build 18362)及以上、Windows 11、Windows Server 2022 / 2025。
|
||||
更老的系统不认清单里的 `activeCodePage=UTF-8`,安装脚本会直接拒绝。
|
||||
- x86_64。Windows 10 1903 起系统自带 UCRT,无需另装运行库。
|
||||
- 终端:Windows Terminal 或传统控制台(conhost)都行。
|
||||
|
||||
## 发行包内容
|
||||
|
||||
```
|
||||
windows-x86_64/
|
||||
cst-agent.exe # 静态链接:curl(Schannel)、ncurses 6.6、winpthreads
|
||||
busybox.exe # busybox-w32 Unicode 版,run_shell 用它的 sh
|
||||
LICENSE.busybox
|
||||
README.md # 本文件
|
||||
VERSION
|
||||
cst-agent-update.cmd # 调用旁边的 install_windows.ps1 升级本目录
|
||||
install_windows.ps1
|
||||
```
|
||||
|
||||
## 文件位置
|
||||
|
||||
| 内容 | 位置 |
|
||||
|---|---|
|
||||
| API key(每个 provider 一个) | `%APPDATA%\cst-agent\credentials`,只有当前用户可读,并带“禁止向上读”标签(沙箱内读不到) |
|
||||
| 上次的 provider、模型、推理强度、确认模式 | `%APPDATA%\cst-agent\settings.json` |
|
||||
| 沙箱打过标签的目录 | `%APPDATA%\cst-agent\sandbox-labels` |
|
||||
| 会话、撤销快照、导出 | `<工作区>\.agent\` |
|
||||
|
||||
设置了绝对路径的 `XDG_CONFIG_HOME` 时用 `%XDG_CONFIG_HOME%\cst-agent`。
|
||||
|
||||
## 编码:全部 UTF-8
|
||||
|
||||
- exe 内嵌 `activeCodePage=UTF-8` 清单:进程的 ANSI 代码页就是 65001,命令行参数、
|
||||
环境变量、文件名都以 UTF-8 进出;内部所有路径在系统调用边界转成 UTF-16(`*W` API)。
|
||||
- 控制台输入输出代码页在启动时设为 65001,退出时恢复。
|
||||
- 文件按字节读写,不做换行转换。`read_file` 给模型看的是去掉 `\r` 的文本;`edit_file`、
|
||||
`apply_patch`、`write_file` 会保持原文件的 CRLF 换行和 UTF-8 BOM。
|
||||
- 子进程输出:先按 UTF-8 解码;不是合法 UTF-8 时依次尝试控制台代码页、系统 OEM / ANSI
|
||||
代码页(比如中文系统的 936),转成 UTF-8 再交给模型。
|
||||
- 中文用户名、中文工作区路径(`C:\Users\测试\项目`)都已在中英文系统上测过。
|
||||
|
||||
## 终端界面(TUI)
|
||||
|
||||
用的是打过补丁的 ncurses 6.6 Windows 控制台驱动(补丁在
|
||||
`scripts/patches/ncurses-6.6-win32-console.patch`):
|
||||
|
||||
- 中文按两列宽显示,光标位置正确;粘贴多行不会直接发送。
|
||||
- 鼠标:点击、拖选复制(写入 Windows 剪贴板)、滚轮滚动;`/mouse` 关闭。
|
||||
- `/copy` 和拖选复制走 Win32 剪贴板 API,不依赖终端的 OSC 52。
|
||||
- 控制台是点阵字体时(显示不了中文,会变成 `?`),启动时自动换成同字号的 Consolas
|
||||
(中文系统换成新宋体),退出时换回去。
|
||||
- 传统控制台不做字形回退:当前字体缺 logo 用的方块字符时(比如中文系统默认的新宋体),
|
||||
首页改用文字 logo。
|
||||
|
||||
## Shell 沙箱:Low 完整性级别
|
||||
|
||||
`auto` 确认模式下,`run_shell` 在 Low 完整性级别的受限令牌里运行(界面显示 `auto · lowil`):
|
||||
|
||||
- 子进程的令牌去掉了管理员组和特权,完整性级别为 Low(S-1-16-4096),放进 Job Object,
|
||||
超时或按 Esc 时整个进程树一起结束。
|
||||
- Low 进程只能写标为 Low 的位置。第一次在某个工作区跑沙箱命令时,CST-Agent 给工作区
|
||||
加上可继承的 Low 标签(`S:(ML;OICI;NW;;;LW)`),并在输出里提示一次;
|
||||
记录写进 `sandbox-labels`。沙箱的临时目录是 `%TEMP%\cst-agent-sandbox`(同样标为 Low),
|
||||
子进程的 `TEMP` / `TMP` 指向这里。
|
||||
- 不会给盘符根目录、用户目录本身、`%APPDATA%`、`%LOCALAPPDATA%`、`%TEMP%` 本身,
|
||||
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
|
||||
界面会问是否在沙箱外重跑。请在项目目录里启动。
|
||||
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
|
||||
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**:`curl`、`git pull`、
|
||||
`npm install` 等联网命令在 `auto` 模式下也直接在沙箱里运行。
|
||||
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
|
||||
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
|
||||
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
|
||||
(`0xc0000142`),同样会触发上面的重跑询问。Git for Windows 的 `git.exe` 本身不受影响。
|
||||
- `CST_SANDBOX=off` 关闭沙箱(`auto` 退回只读白名单规则);`CST_SANDBOX=lowil` 只用 Low IL。
|
||||
|
||||
它防的是误操作写坏工作区以外的文件,不是对抗恶意程序的安全边界。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
两种方式产物一样:`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 上交叉编译
|
||||
|
||||
```bash
|
||||
git clone https://git.whcself.xyz/WHC/CST-agent.git
|
||||
cd CST-agent
|
||||
scripts/bootstrap_windows.sh
|
||||
```
|
||||
|
||||
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 原生编译
|
||||
|
||||
装好 [MSYS2](https://www.msys2.org/) 后,打开 **MSYS2 UCRT64** 终端(不是 MINGW64:
|
||||
MINGW64 链接 msvcrt,UTF-8 清单不起作用):
|
||||
|
||||
```bash
|
||||
pacman -S --needed git
|
||||
git clone https://git.whcself.xyz/WHC/CST-agent.git
|
||||
cd CST-agent
|
||||
scripts/bootstrap_msys2.sh
|
||||
```
|
||||
|
||||
脚本会用 pacman 装 UCRT64 的 gcc / cmake / ninja,编译依赖和 `cst-agent.exe`,跑 ctest
|
||||
(`CST_SKIP_TESTS=1` 跳过),再打包。
|
||||
|
||||
### 测试
|
||||
|
||||
`scripts/win_test.sh` 在本机 QEMU 里的 Windows Server 虚拟机(英文 / 中文系统,管理员 /
|
||||
普通用户 / 中文用户名)上交叉编译后跑全部测试;虚拟机的创建和控制见 `scripts/winvm/`。
|
||||
|
||||
### 发布
|
||||
|
||||
CI:推 `main` 或 `v*` tag 且 `windows` 任务成功后,自动把 zip、安装包和各自的 `.sha256` 发到
|
||||
Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets
|
||||
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
|
||||
|
||||
本地:
|
||||
|
||||
```bash
|
||||
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,上传给出的文件和旁边的 `.sha256`。token 只从环境变量读取,经 stdin
|
||||
交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推
|
||||
`vX.Y.Z` tag 时必须与头文件一致。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 英文等非中文系统的传统控制台默认用 Consolas,它没有中文字形,中文会显示成方框(列宽仍然正确)。
|
||||
用 Windows Terminal,或在控制台“属性 → 字体”里选新宋体等中文字体即可。
|
||||
- 传统控制台里输入表情符号(BMP 以外的字符)可能显示不对;中文输入没问题。
|
||||
- 通过 SSH / ConPTY 远程运行时,鼠标事件不会转发过来,键盘操作不受影响。
|
||||
- 很大的工作区第一次打标签要等一会儿(标签要继承到每个文件)。
|
||||
- 只有 x86_64 发行包。
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# CST-agent on Android
|
||||
|
||||
The engine runs on the phone as a plain bionic binary -- no Termux, no proot,
|
||||
no second process for the UI to talk to. It is the same C core the CLI, the
|
||||
TUI and the WebUI use, cross-compiled with the NDK; on the phone the Qt front
|
||||
end (app/) will link `libcstcore.a` in-process and register its own platform
|
||||
tools under the owner `app:android`.
|
||||
|
||||
Two scripts cover the engine side:
|
||||
|
||||
| script | what it does |
|
||||
| --- | --- |
|
||||
| `scripts/build_third_party_android.sh ABI [all\|mbedtls\|curl]` | static mbedTLS + libcurl into `third_party/prefix-android-ABI` |
|
||||
| `scripts/build_android.sh [ABI] [--push] [--run] [--run-tests]` | `libcstcore.a`, `cst-agent`, the test binaries; optional push and on-device run |
|
||||
|
||||
## Toolchain
|
||||
|
||||
NixOS: nixpkgs carries the NDK (the Android SDK license makes it unfree, hence
|
||||
the flag; the packaged NDK has a patched interpreter, so its clang runs here):
|
||||
|
||||
```sh
|
||||
export ANDROID_NDK_HOME=$(NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link \
|
||||
--print-out-paths 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle
|
||||
```
|
||||
|
||||
`adb` comes from `nix-shell -p android-tools`; the builds need cmake, ninja and
|
||||
curl. End to end:
|
||||
|
||||
```sh
|
||||
nix-shell -p cmake ninja curl android-tools --run '
|
||||
JOBS=16 ./scripts/build_third_party_android.sh arm64-v8a all'
|
||||
nix-shell -p cmake ninja curl android-tools --run '
|
||||
JOBS=16 ./scripts/build_android.sh arm64-v8a --push --run'
|
||||
```
|
||||
|
||||
`--run` finishes with the two smoke checks on the device (`cst-agent
|
||||
--version` and `tests/test_embed`, the in-process embedding contract);
|
||||
`--run-tests` builds the whole `tests/` suite, pushes it and runs every binary
|
||||
on the phone, printing a pass/fail line per test.
|
||||
|
||||
## The APK (Qt front end)
|
||||
|
||||
`scripts/build_apk_android.sh [--install] [--logs] [--clean]` configures with
|
||||
the Qt for Android kit, builds the `apk` target (Gradle, no port opened) and
|
||||
optionally installs it. Bits it needs, all overridable through the
|
||||
environment:
|
||||
|
||||
| variable | what it points at |
|
||||
| --- | --- |
|
||||
| `QT_ANDROID` | Qt for Android 6.11.2 (`aqt install-qt all_os android 6.11.2 android_arm64_v8a -O ~/Qt --archives qtbase qtdeclarative qtquickcontrols2`) |
|
||||
| `QT_HOST` | the host Qt of the same version (`aqt install-qt linux desktop 6.11.2 linux_gcc_64 -O ~/Qt --archives qtbase qtdeclarative qtsvg`) |
|
||||
| `ANDROID_SDK_ROOT` | SDK with `platforms/android-<n>` and `build-tools/<v>` |
|
||||
| `ANDROID_NDK_HOME` | the NDK the engine was built with |
|
||||
| `JAVA_HOME` | JDK 17+ for Gradle |
|
||||
|
||||
### The SDK without sdkmanager
|
||||
|
||||
`nixpkgs`'s full `androidsdk` pulls the emulator and system images (gigabytes);
|
||||
the two components actually needed are cheap. The platform comes from the nix
|
||||
store (unfree, so the license flag is required) and build-tools from Google's
|
||||
own zip, laid out as the SDK `sdkmanager` would:
|
||||
|
||||
```sh
|
||||
PLATFORM=$(NIXPKGS_ALLOW_UNFREE=1 NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1 nix build \
|
||||
--impure --no-link --print-out-paths --expr \
|
||||
'builtins.elemAt (import <nixpkgs> {}).androidenv.androidPkgs.platforms 2') # android-35
|
||||
mkdir -p ~/android-sdk/{build-tools,platforms}
|
||||
ln -s $PLATFORM/libexec/android-sdk/platforms/android-35 ~/android-sdk/platforms/
|
||||
```
|
||||
|
||||
`androiddeployqt` then picks `build-tools` and `platform-tools` from there. For
|
||||
build-tools, `dl.google.com` is the only source: from here it hands out
|
||||
~100 KB/s per connection, so the script downloads the zip in 16 ranged
|
||||
requests (≈25x faster) and fixes the exec bits Python's `zipfile` drops.
|
||||
|
||||
### Host Qt on NixOS
|
||||
|
||||
The host Qt is built for a regular distribution: its tools (`qmlimportscanner`
|
||||
first of all) look for `libgssapi_krb5.so.2`, `libglib-2.0.so.0`, `libz.so.1`,
|
||||
`libfontconfig`, `libX11`, `libEGL`/`libGL`, `libzstd`, `libbrotlidec`,
|
||||
`libdbus-1`, `libfreetype`, `libstdc++`, none of which exist in NixOS's search
|
||||
paths. The tools carry `RUNPATH=$ORIGIN/../lib`, so symlinking those libraries
|
||||
from the nix store into `<QT_HOST>/lib/` is enough:
|
||||
|
||||
```sh
|
||||
nix build --no-link --print-out-paths nixpkgs#krb5.lib nixpkgs#dbus.lib \
|
||||
nixpkgs#libglvnd nixpkgs#fontconfig.lib nixpkgs#freetype nixpkgs#glib.lib \
|
||||
nixpkgs#libx11 nixpkgs#libxkbcommon nixpkgs#zlib nixpkgs#zstd \
|
||||
nixpkgs#brotli.lib nixpkgs#stdenv.cc.cc.lib # then ln -s <lib> ~/Qt/6.11.2/gcc_64/lib/
|
||||
```
|
||||
|
||||
Two more things the script does on the way:
|
||||
|
||||
* **GLESv2/EGL**: Qt's `FindGLESv2.cmake` only searches `CMAKE_FIND_ROOT_PATH`
|
||||
prefixes (`<sysroot>/include`), but the NDK keeps the headers in
|
||||
`<sysroot>/usr/include` -- without `-DGLESv2_INCLUDE_DIR`/`-DEGL_INCLUDE_DIR`
|
||||
`find_package(Qt6 Gui)` fails with "dependency GLESv2 could not be found".
|
||||
* **`-L$PREFIX/lib` on every link type**: the APK's payload is a *shared
|
||||
module*, so `CMAKE_SHARED_LINKER_FLAGS`/`CMAKE_MODULE_LINKER_FLAGS` need the
|
||||
prefix too, not just the EXE flags.
|
||||
|
||||
### Gradle
|
||||
|
||||
The generated project resolves plugins and dependencies from `google()` and
|
||||
`mavenCentral()`, plus a Gradle distribution from `services.gradle.org`. From
|
||||
here all three are slow (dl.google.com ~100 KB/s), so two local tweaks:
|
||||
|
||||
* `~/.gradle/init.d/*.gradle` (machine-local, not in the repo) prepends the
|
||||
Aliyun/Tencent mirrors for `pluginManagement`, `dependencyResolutionManagement`
|
||||
and every `buildscript`.
|
||||
* the wrapper in `<QT_ANDROID>/src/3rdparty/gradle/gradle/wrapper/` points at
|
||||
the Tencent Gradle mirror (51 MB/s vs 100 KB/s).
|
||||
|
||||
`scripts/gradle-android-init.gradle` (installed into `~/.gradle/init.d/` by the
|
||||
build script) switches off release lint: `lintVitalAnalyzeRelease` resolves its
|
||||
own tool jars straight from `dl.google.com`, bypassing every repository the
|
||||
build configures, and fails the build after all the real work is done.
|
||||
|
||||
### Signing
|
||||
|
||||
`androiddeployqt` copies the **unsigned** Gradle output and signs it itself
|
||||
(`QT_ANDROID_SIGN_APK` + the `QT_ANDROID_KEYSTORE_*` variables; a signed Gradle
|
||||
output makes it fail with "Could not copy unsigned APK"). The script exports
|
||||
those variables and creates the standard Android debug keystore
|
||||
(`~/.android/debug.keystore`, alias `androiddebugkey`) on first use;
|
||||
`CST_ANDROID_KEYSTORE` and `CST_ANDROID_KEY_ALIAS`/`_PASS` override it.
|
||||
`apksigner` is a bash script with a `#!/bin/bash` shebang, which NixOS does not
|
||||
have, so the script rewrites the shebang of the build-tools wrappers.
|
||||
|
||||
## What is Android-specific
|
||||
|
||||
* **API level 24** (`API_LEVEL=24`): bionic provides threads, `exec` and
|
||||
`/system/etc/security/cacerts`, which is where libcurl is pointed
|
||||
(`CURL_CA_PATH`). Bump the level if a newer syscall or CA layout is needed.
|
||||
* **No TUI, no WebUI**: `-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF` -- there is
|
||||
no ncurses on Android, and the phone front end talks to the engine in-process
|
||||
instead of over HTTP. `src/webui_stub.c` compiles in place of the server.
|
||||
* **Sandbox**: `src/sandbox_exec.c` only enables the seccomp/Landlock paths
|
||||
under `defined(__linux__) && !defined(__ANDROID__)`, so the phone build gets
|
||||
the plain exec path -- the same rule the Termux build follows.
|
||||
The seccomp supervisor used to compile in and the probe reported "seccomp",
|
||||
but on the device a write outside the workspace was not denied
|
||||
(`tests/test_sandbox.c:308`), so the phone build now reports no sandbox
|
||||
instead of one that does not hold.
|
||||
* **sashidown** is built per ABI (`vendor/sashidown/libsashidown-<abi>.a`) with
|
||||
the NDK's `aarch64-linux-android24-clang` wrapper: CMake's compiler is plain
|
||||
clang and keeps `--target`/`--sysroot` in its own variables, which the
|
||||
vendored makefile cannot see. The host archive stays host-built.
|
||||
* **Static libcurl** needs `-L$PREFIX/lib` on the link line, because curl's own
|
||||
CMake config lists its mbedTLS dependencies as `-l` flags rather than paths.
|
||||
|
||||
## Status
|
||||
|
||||
* arm64-v8a, Android 16 (NX789J): `cst-agent` runs, `tests/test_embed` passes.
|
||||
* The whole suite runs on the device (`scripts/build_android.sh arm64-v8a
|
||||
--run-tests`): 36/36 after the diff test learned to tell toybox's `diff` from
|
||||
GNU diffutils.
|
||||
* The APK builds, installs and runs: the Qt front end links the engine
|
||||
in-process (no port), `main.cpp` puts the workspace, config and tmp under
|
||||
`/data/user/0/xyz.whcself.cstagent/files/`, and a turn does a real HTTPS
|
||||
request from inside the ART process. The saved provider key is resolved into
|
||||
the engine's config on every start (`AgentCore::open()`: saved key, the
|
||||
provider's environment variable, `CST_API_KEY`), so the phone answers without
|
||||
being asked for the key again. Rotation keeps the session; the sidebar
|
||||
appears in landscape.
|
||||
* Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`):
|
||||
`notification_send`, `clipboard_get` / `clipboard_set` under the engine's
|
||||
own tool names and `share` (`ACTION_SEND` + chooser) all work on the device.
|
||||
The file
|
||||
card's **Open** builds a `content://` URI through the app's FileProvider
|
||||
(`<package>.qtprovider`, Qt's `@xml/qtprovider_paths`), checks
|
||||
`resolveActivity()` before starting `ACTION_VIEW` and grants the reader
|
||||
permission; **Share** reuses the share sheet.
|
||||
* **Java helpers**: a notification built entirely through `QJniObject` posted
|
||||
nothing on the NX789J -- `Notification.Builder.build()` came back null and
|
||||
the tool could only say "failed: no exception detail", because Qt clears a
|
||||
Java exception before C++ gets to look at it. The one step that has to fail
|
||||
visibly (a builder, a `PendingIntent`, `build()`, `notify()`) now lives in
|
||||
`app/android/src/xyz/whcself/cstagent/Notifier.java`, which catches the
|
||||
throwable and hands the text back for the tool result; the device then
|
||||
reported `notification sent` and the shade had it. androiddeployqt copies
|
||||
`app/android/` with the rest of the package source and Qt's Gradle template
|
||||
puts its `src/` on `java.srcDirs`, so **a class dropped in that directory
|
||||
needs no build-script change** -- the front service of the next step goes
|
||||
there too.
|
||||
* A session's transcript replays a delivered file (`deliver_file`) as a card
|
||||
with Open and Share -- the copy lives next to the session, marked `for_user`
|
||||
so no request ever contains it (see `app/README.md`, "Files the agent
|
||||
delivers").
|
||||
* **A turn survives the app leaving the screen**: `AgentCore::send()` calls
|
||||
`app::holdForeground()`, which starts `CstService`
|
||||
(`app/android/src/xyz/whcself/cstagent/CstService.java`) as a foreground
|
||||
service and takes a partial wake lock; `finishTurn()` stops it again. On the
|
||||
NX789J: with the app sent home and the screen off, the service showed
|
||||
`isForeground=true` with its `CST-agent is working` notification (id 2, the
|
||||
same `cst-agent` channel), the lock `cst-agent:turn` was acquired at the
|
||||
start of the turn and released at its end, `run_shell sleep 60` finished with
|
||||
the screen dark, and the service was gone once the turn was over.
|
||||
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
||||
* **A turn that asked and got no answer**: a consent question cannot outlive
|
||||
the app being off screen. `AgentCore::hookPermission()` / `hookAskUser()`
|
||||
refuse (or drop the question) at once while the app is in the background --
|
||||
Qt reports that as `Qt::ApplicationSuspended`, so the worker is never parked
|
||||
on a dialog nobody can see, and the transcript carries a line saying why. In
|
||||
the foreground the same two hooks wait two minutes (a permission) or five
|
||||
(a question) and then answer "no" with a reason instead of waiting for good.
|
||||
The GUI hop a platform tool makes (`onGuiThread()` in
|
||||
`app/src/platform/android/AndroidTools.cpp`) pings first and gives up after
|
||||
three seconds, and the header shows what the turn is doing
|
||||
(`shell: make -j8`) so a long turn is not mistaken for a stuck one.
|
||||
`--uitest` covers the window side of all this.
|
||||
* **Documents**: `md_to_pdf` (the window's own tool,
|
||||
`app/src/core/PdfTools.cpp`, owner `app:qt`) lays markdown or a workspace
|
||||
`.md`/`.html` out as an A4 PDF with 15 mm margins and hands it over as a file
|
||||
card, exactly the way `deliver_file` does; `QPrinter`/PrintSupport is not
|
||||
used, so the phone gets it too. Qt's PDF engine embeds a font *subset*, which
|
||||
is why a Chinese page is a few KB rather than the few MB a whole CJK font
|
||||
would cost (see `app/README.md`).
|
||||
* Still to come: the back gesture, and the icon glyphs -- `ThemeIcon` finds no
|
||||
icon files on the phone and draws empty boxes where the desktop shows Breeze
|
||||
icons.
|
||||
@@ -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
|
||||
|
||||
@@ -42,18 +42,20 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
|
||||
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
|
||||
when one is available. Commands may read and execute anything but may only
|
||||
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
|
||||
The network is not restricted.
|
||||
|
||||
| Platform | Sandbox | Notes |
|
||||
|---|---|---|
|
||||
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind |
|
||||
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below |
|
||||
| macOS | `sandbox_init` profile | also denies network |
|
||||
| Older kernels, Windows | none | falls back to the read-only rules |
|
||||
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | |
|
||||
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | see below |
|
||||
| macOS | `sandbox_init` profile | |
|
||||
| Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); see `README-windows.md` |
|
||||
| Older kernels | none | falls back to the read-only rules |
|
||||
|
||||
The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
|
||||
`auto · seatbelt` or `auto · rules`. `CST_SANDBOX=off` disables the
|
||||
sandbox; `CST_SANDBOX=landlock` or `CST_SANDBOX=seccomp` allows only that
|
||||
kind. When a sandboxed command looks blocked, the TUI asks whether to rerun
|
||||
`auto · seatbelt`, `auto · lowil` or `auto · rules`. `CST_SANDBOX=off`
|
||||
disables the sandbox; `CST_SANDBOX=landlock`, `seccomp` or `lowil` allows
|
||||
only that kind. When a sandboxed command looks blocked, the TUI asks whether to rerun
|
||||
it outside the sandbox. The policy is built in the parent; the forked child
|
||||
only makes a few async-signal-safe syscalls before `exec`, so the agent
|
||||
itself is never confined.
|
||||
@@ -65,16 +67,19 @@ every create / remove / rename / link / chmod / chown / utimes / xattr call
|
||||
stop until the supervisor has read the path from the child's memory,
|
||||
resolved it (cwd, dirfd, `..`, symlinks, `/proc/self`, `/dev/stdout`) and
|
||||
answered: continue when it lands in the allowed places, `EACCES` otherwise.
|
||||
Reads, exec and writes to already-open files are not checked. Background
|
||||
jobs that outlive their shell are no longer the agent's descendants: with
|
||||
Yama `ptrace_scope` ≥ 1 (common on desktop distros, absent on Android) the
|
||||
supervisor cannot read their memory and their checked writes fail with
|
||||
`EACCES`; once the run has finished they get `ENOSYS`. This keeps
|
||||
mistakes out of the rest of the disk but is not a security boundary: the
|
||||
kernel reads the path again after the check, so a hostile multi-threaded
|
||||
program can swap it in between. The availability check runs in a
|
||||
throwaway child, so a platform seccomp policy that kills the caller cannot
|
||||
take the agent down.
|
||||
Reads, exec and writes to already-open files are not checked. The probe also
|
||||
checks that the parent can peek a direct child's memory (`process_vm_readv`
|
||||
or `/proc/<pid>/mem`); with Yama `ptrace_scope=2` and no `CAP_SYS_PTRACE`
|
||||
that fails, so seccomp is not selected (forced `CST_SANDBOX=seccomp` then
|
||||
falls back to unavailable). Background jobs that outlive their shell are no
|
||||
longer the agent's descendants: with Yama `ptrace_scope` ≥ 1 (common on
|
||||
desktop distros, absent on Android) the supervisor cannot read their memory
|
||||
and their checked writes fail with `EACCES`; once the run has finished they
|
||||
get `ENOSYS`. This keeps mistakes out of the rest of the disk but is not a
|
||||
security boundary: the kernel reads the path again after the check, so a
|
||||
hostile multi-threaded program can swap it in between. The availability
|
||||
check runs in a throwaway child, so a platform seccomp policy that kills the
|
||||
caller cannot take the agent down.
|
||||
|
||||
## Terminal notes
|
||||
|
||||
@@ -140,7 +145,7 @@ TUI:
|
||||
- [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette
|
||||
- [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked
|
||||
- [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the
|
||||
sandbox?" (Linux/Termux/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks
|
||||
sandbox?" (Linux/Termux/macOS/Windows); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks
|
||||
- [ ] Resize the terminal during streaming: layout recovers
|
||||
- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens
|
||||
|
||||
@@ -149,6 +154,23 @@ WSL specifics:
|
||||
- [ ] Workspace under `/mnt/c/...` works (slower IO is expected); `~/project` is recommended
|
||||
- [ ] Windows Terminal: mouse wheel scroll and OSC 52 `/copy`
|
||||
|
||||
Windows specifics (`scripts/win_test.sh` runs the tests on English and
|
||||
Chinese VMs as Administrator, a standard user and a Chinese user name):
|
||||
|
||||
- [ ] `install_windows.ps1 -Zip dist\cst-agent-*.zip` installs, adds the user
|
||||
PATH; `cst-agent-update -Zip ...` works while `cst-agent.exe` runs
|
||||
- [ ] conhost with a raster font: Chinese renders (font switched), and the
|
||||
old font is back after exit; Windows Terminal renders the same
|
||||
- [ ] Chinese input via IME, paste, mouse click / drag-copy / wheel
|
||||
- [ ] Workspace under `C:\Users\<中文名>\项目`: read/write/edit/apply_patch
|
||||
keep CRLF and BOM; `run_shell` output in Chinese is readable on a
|
||||
936 system
|
||||
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
|
||||
asks to rerun; `curl ...` runs without asking; `/sandbox reset-labels`
|
||||
restores the label
|
||||
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
|
||||
`busybox.exe` behind
|
||||
|
||||
macOS specifics:
|
||||
|
||||
- [ ] Builds with Homebrew ncurses and with the system ncurses (`brew unlink ncurses`)
|
||||
|
||||
@@ -12,7 +12,10 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
||||
|---|---|---|---|
|
||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||
|
||||
显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。
|
||||
|
||||
@@ -25,6 +28,12 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
||||
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
|
||||
|
||||
Kimi 的 K3 请求使用 `reasoning_effort`;K2.6/K2.7 使用 `thinking`,并在后续请求中回传 `reasoning_content`。默认发送 Kimi 1 小时隐式 prompt cache 提示,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭。
|
||||
|
||||
当 provider 为 Kimi 或 DeepSeek 时,`web_search` 和 `web_fetch` 使用对应的官方联网 Search/Fetch API;其它 provider 使用通用 URL 抓取。
|
||||
|
||||
`kimi-code` 使用 Kimi Code 的 OpenAI-compatible API。它和按量计费的 `kimi` 开放平台使用不同的 Base URL 与 API Key,不能混用。
|
||||
|
||||
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
|
||||
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
||||
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
|
||||
@@ -41,6 +50,20 @@ cst-agent --provider deepseek # 首次会询问 key
|
||||
cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought
|
||||
```
|
||||
|
||||
### Kimi
|
||||
|
||||
```bash
|
||||
cst-agent --provider kimi # 默认 kimi-k3
|
||||
cst-agent --provider kimi --model kimi-k2.6 # K2 thinking 模式
|
||||
```
|
||||
|
||||
### Kimi Code
|
||||
|
||||
```bash
|
||||
cst-agent --provider kimi-code # 默认 k3
|
||||
cst-agent --provider kimi-code --model k3-256k
|
||||
```
|
||||
|
||||
### OpenAI 官方
|
||||
|
||||
```bash
|
||||
@@ -72,7 +95,7 @@ export OPENAI_MODEL='your-model'
|
||||
| `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) |
|
||||
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) |
|
||||
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) |
|
||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认 120) |
|
||||
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限,Ctrl+C / 停止按钮仍可随时中断) |
|
||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
|
||||
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
|
||||
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# WebUI(内置 HTTP + SSE 前端)
|
||||
|
||||
`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端)
|
||||
连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端,
|
||||
**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。
|
||||
|
||||
这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现,
|
||||
不需要读 C 代码。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 默认只监听 127.0.0.1:8787
|
||||
cst-agent --web
|
||||
|
||||
# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL)
|
||||
cst-agent --web --web-port 8787
|
||||
|
||||
# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上)
|
||||
cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令
|
||||
|
||||
# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译
|
||||
cst-agent --web --web-dev ./web
|
||||
|
||||
# 启动后自动打开浏览器
|
||||
cst-agent --web --web-open
|
||||
|
||||
# 启动即跑一轮(不点页面也能用)
|
||||
cst-agent --web -p "看看这个仓库有什么问题"
|
||||
```
|
||||
|
||||
启动日志:
|
||||
|
||||
```
|
||||
webui: http://127.0.0.1:8787/
|
||||
webui: token 3f9a… (append #token=3f9a… to the URL)
|
||||
```
|
||||
|
||||
带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器,
|
||||
页面会用 `?token=` 再请求一次)。
|
||||
|
||||
环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。
|
||||
|
||||
退出:终端里 Ctrl+C,或 `POST /api/quit`。
|
||||
|
||||
## 安全模型
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding);带 `Origin` 的请求必须来自本页面自己(`http://<Host>`),否则 401 —— 防止任意网页跨站 POST 驱动 agent |
|
||||
| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/<rel>` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) |
|
||||
| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 |
|
||||
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
|
||||
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
|
||||
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
|
||||
| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`<script>` 全部丢弃) |
|
||||
| 请求体 | 默认 1 MiB,图片上传 32 MiB;超限 413 |
|
||||
|
||||
**不要**把 WebUI 直接暴露到公网:没有 TLS,token 会以明文出现在网络里。需要远程访问时
|
||||
放在反向代理(https)后面,并确认 `X-Accel-Buffering: no` 之类的缓冲设置不会掐断 SSE。
|
||||
|
||||
## 引擎 API(协议 `cst-webui/1`)
|
||||
|
||||
所有响应都是 JSON(`Content-Type: application/json`)。`GET /api/state` 的
|
||||
`protocol` 字段给出版本号,客户端应当先看它再决定支持哪些端点。
|
||||
|
||||
### 事件流
|
||||
|
||||
```
|
||||
GET /api/events?since=<seq> → text/event-stream (chunked)
|
||||
```
|
||||
|
||||
* 每条事件都有 `id: <seq>`(单调递增)与 `data: {"type":…,"seq":…, …}`。
|
||||
* 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行),
|
||||
服务端保留最近 2000 条,超出后从可用的最老一条开始;比当前 seq 还大(服务端重启过)
|
||||
就当作新连接。
|
||||
* 新连接(不带 `since`)只收之后的事件;但若有尚未回答的 `permission`/`ask_user`,
|
||||
会从那条事件开始补发,刷新页面不会丢掉正在等的问题。
|
||||
* 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。
|
||||
* `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。
|
||||
|
||||
事件类型:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `state` | 见下表 | 会话/模型/审批/todo/用量快照 |
|
||||
| `status` | `message` | 引擎自己的一句话状态("calling model (step 2)…") |
|
||||
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
|
||||
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
|
||||
| `think_delta` | `text` | 推理过程增量(Thought) |
|
||||
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`progress`/`done`;`progress` 是子代理的最近几步(`detail` 形如 `explore · grep foo · read_file x`),可以出现多次 |
|
||||
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
|
||||
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
|
||||
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 |
|
||||
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
|
||||
| `ask_user` | `id`,`question`,`options[]` | 需要回答 |
|
||||
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
|
||||
| `turn_end` | `rc`,`error` | 本轮结束(`rc=0` 正常,非 0 见 `error`) |
|
||||
|
||||
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
|
||||
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
|
||||
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens,
|
||||
completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`
|
||||
`turn_usage{…}`(上一轮,字段同 `usage`)`api_base` `key_set` `key_from`
|
||||
(`saved` / 环境变量名 / `session` / 空,**从不含 key 本身**)
|
||||
`model_info{label,context,vision,reasoning,source,price?}` `effort_applies`
|
||||
`sees_images`(1 / 0 / -1 未知)`max_steps` `tool_profile` `perm_timeout`
|
||||
`show_thinking`(思考过程默认展开,存在 `settings.json`,和 TUI 的 `/thinking` 是同一个开关)。
|
||||
`cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。
|
||||
|
||||
`think_delta` / `assistant_delta` 的 `text` 总是完整的 UTF-8:上游在多字节字符中间
|
||||
断开时,残缺的尾巴留到下一块再发。
|
||||
|
||||
### 请求
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/state` | 上面那份快照 |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与转录 `.agent/sessions/<id>/session.jsonl` 的行一致,另加的字段见下) |
|
||||
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
|
||||
| POST | `/api/sessions/new` | `{}` → `{id}` |
|
||||
| POST | `/api/sessions/switch` | `{"id":"…"}` |
|
||||
| POST | `/api/sessions/rename` | `{"id":"…"?,"title":"…"}`(不带 `id` = 当前会话);标题按字符截到 120 字节,空串 = 恢复默认(第一句提问);没这个会话 → 404 |
|
||||
| POST | `/api/sessions/delete` | `{"id":"…"}` → `{ok,current}`;删的是当前会话时先开一个新会话(并发 `session` 事件);没这个会话 → 404 |
|
||||
| POST | `/api/retry` | `{}` = 重新生成最后一轮;`{"text":"…"}` = 把最后一句提问换成新文字再跑(编辑重发)。先把最后一轮从会话里退掉,再开新一轮 → 202 `{ok,text}`;没有可重来的轮次 / 轮次在跑 → 409;空串或非字符串 → 400 |
|
||||
| GET | `/api/export` | 当前会话的 Markdown(`text/markdown`,与 TUI `/export` 同一格式) |
|
||||
| GET | `/api/diff[?path=p]` | 工作区的真实 diff:`{git,path,files:[{status,path}],diff,truncated}`。`files` 来自 `git status --short`(含未跟踪),`diff` 是 `git diff`(未暂存部分,限 512 KiB);不是 git 仓库 / 没装 git → `git:false`;10 s 超时 |
|
||||
| GET | `/api/settings` | 运行设置:`protocol`,`protocols[]`,`max_steps`,`tool_profile`,`profiles[]`,`confirm{shell,write,net}`,`approval`,`tools[{name,perm,on,desc,…}]`,`sandbox{kind,active,labels?}` |
|
||||
| POST | `/api/settings` | `{protocol?,max_steps?,tool_profile?,tools?:{名字:bool},confirm?:{shell?,write?,net?}}`;整体校验后才生效(任何一项不对 → 400,什么都不改),成功后发 `state` |
|
||||
| GET | `/api/permissions` | 已保存的"总是允许"规则:`{text,tools[],shell_prefixes[]}`(来自 `<workspace>/.agent/permissions.json`) |
|
||||
| DELETE | `/api/permissions` | 清空这些规则 |
|
||||
| POST | `/api/init` | `{}` → 202 `{ok,text}`:开一轮,让 agent 分析仓库写 `AGENTS.md`(与 TUI `/init` 同一段提示词) |
|
||||
| POST | `/api/skill` | `{"name":"…","task":"…"?}` → 202 `{ok,text}`:把 skill 正文内联进这条消息再开一轮(同 TUI `/skill`);没有这个 skill → 404;缺 `name` / 超过 63 字节 / `task` 不是字符串 → 400 |
|
||||
| GET | `/api/extensions` | `{enabled,trusted,report,task_tool,mcp:[{name,source,command,state,tools,error}],plugins:[{name,source,desc,tools,mcp,skills,error}],skills:[{name,source,desc}],roles:[{name,source,desc,model,read_only}],runs:[{file,role,desc,status,steps,tools,ms}]}`;`state` = `starting/ready/failed/exited/off`,`runs` 是当前会话的子代理运行 |
|
||||
| POST | `/api/extensions/reload` | 停掉 MCP 服务器和插件,重新读取它们和 skills → `{ok,trusted,report}` |
|
||||
| POST | `/api/extensions/trust` | `{"on":true\|false}`:信任(或取消)当前工作区——决定它的 `.agent/plugins`、`.agent/mcp.json` 是否加载;写进 `settings.json` 后重新加载,回复同上 |
|
||||
| POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换,扩展按新目录重新加载),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 |
|
||||
| POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** |
|
||||
| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程);挂起的权限问题按拒绝、提问按忽略处理 |
|
||||
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix |
|
||||
| POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` |
|
||||
| POST | `/api/config` | `{model?,effort?,approval?,plan_mode?,show_thinking?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) |
|
||||
| GET | `/api/providers` | `{current, providers:[{name,api_base,model,has_key,key_from,key_env,source,current}]}` |
|
||||
| GET | `/api/models[?refresh=1]` | 当前提供商的模型:`{provider,current,api_base,key_set,api_count,models:[{id,api,label,context,vision,reasoning,source,price?}]}`;先用缓存,过期/缺失/`refresh=1` 时向 `/v1/models` 拉;key 被拒 → 401(仍带内置模型和 `auth:true`),其它失败放在 `api_error` |
|
||||
| POST | `/api/provider` | `{"name":"…","key":"…"?}`:切换提供商。缺 key → 400 `{need_key:true}`;key 被拒 → 401;没这个提供商 → 404;成功 → `{ok,provider,model,warning?}`,key 验证后存进 `credentials`(0600) |
|
||||
| POST | `/api/key` | `{"key":"…"}`:换当前提供商的 key,验证与回复同上 |
|
||||
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
|
||||
| POST | `/api/compact` | 手动压缩上下文 |
|
||||
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 |
|
||||
| DELETE | `/api/attach` | 清空待发送图片 |
|
||||
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
|
||||
| POST | `/api/quit` | 关闭进程 |
|
||||
|
||||
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
|
||||
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`sessions/delete`、
|
||||
`workspace`、`undo`、`compact`、`attach`(上传/清空)、`provider`、`key`、`retry`、`diff`、
|
||||
`POST /api/settings`、`DELETE /api/permissions`、`init`、`skill`、`extensions/reload`、
|
||||
`extensions/trust` 以及改 `model/effort` 的 `config` 一律 **409**;
|
||||
`approval`/`plan_mode`/`show_thinking` 可以中途改,`sessions/rename`、`export` 与
|
||||
`GET /api/extensions` 在轮次进行中也能用。
|
||||
|
||||
`/api/session` 的每条消息在会话文件的字段之外还带:
|
||||
|
||||
| 字段 | 出现在 | 说明 |
|
||||
|---|---|---|
|
||||
| `html` | assistant | 已消毒的 Markdown 渲染结果(文本协议时只渲染说明文字,不含调用帧) |
|
||||
| `text` | 文本协议的 assistant | 去掉调用帧后的说明文字(`content` 仍是原文) |
|
||||
| `calls` | 带工具调用的 assistant | `[{id,name,args}]`:原生协议来自 `tool_calls`,文本协议从 `content` 解析 |
|
||||
| `tool_result` | 工具结果 | `true`;原生协议 role 是 `tool`,文本协议 role 是 `user`(凭 `tool_call_id` 区分) |
|
||||
| `out`,`exit`,`truncated` | 工具结果 | 去掉 `[tool …]` / `result … end` 外框后的输出(截到 32 KiB,按字符边界)与退出码 |
|
||||
| `note` | 引擎插入的提示 | `true`;如中途切换计划模式时的 `[cst] …` 说明,不是用户说的话 |
|
||||
|
||||
客户端据此在刷新后重建"本轮工具调用"列表:`calls` 建行,`tool_call_id` 相同的结果填进去。
|
||||
|
||||
key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key`
|
||||
跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。
|
||||
|
||||
### 客户端接入示例(10 行)
|
||||
|
||||
```python
|
||||
import json, urllib.request
|
||||
base = "http://127.0.0.1:8787"
|
||||
def api(path, body=None, token=None):
|
||||
req = urllib.request.Request(base + path, method="POST" if body is not None else "GET")
|
||||
if token: req.add_header("Authorization", "Bearer " + token)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
return urllib.request.urlopen(req, data).read()
|
||||
api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流
|
||||
```
|
||||
|
||||
### 空 body 的 POST
|
||||
|
||||
`POST /api/cancel`、`/api/undo`、`/api/compact`、`/api/quit` 不需要 body。
|
||||
没有 `Content-Length` 头的 POST 按 RFC 9112 §6.3 视为**空 body**(`curl -X POST`
|
||||
正是这样发的),但该连接不再复用(服务端随后关闭它):后面是否跟着未声明的
|
||||
字节无法判断。
|
||||
|
||||
## 前端实现(web/)
|
||||
|
||||
没有构建步骤:`web/index.html` + `web/app.js` + `web/style.css` 是纯 ES module,
|
||||
编译时被 `scripts/embed_web.sh` 塞进二进制(`.gen/web_assets.inc`,跟内置模型数据同一招),
|
||||
所以 Windows 发布包依旧是单个 exe。`--web-dev DIR` 可以绕过内嵌版本直接读目录。
|
||||
|
||||
Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML;
|
||||
因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。
|
||||
|
||||
界面本身:左栏可收(顶栏按钮 / `?side=0`),右栏可收(`?panel=0`),主题 `?theme=dark|light`;
|
||||
三个开关都会记进 `localStorage`,带查询参数时以参数为准。空状态只留一句提示,第一轮
|
||||
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
|
||||
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
|
||||
|
||||
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`);
|
||||
左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。
|
||||
|
||||
过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行
|
||||
("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条
|
||||
细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。
|
||||
出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块——
|
||||
历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。
|
||||
`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行;
|
||||
`/details` 只管工具行是否展开。
|
||||
|
||||
图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是
|
||||
152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在
|
||||
输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/<rel>`);
|
||||
从别的客户端加的待发图片仍然显示为文字条。
|
||||
|
||||
### 命令与快捷键
|
||||
|
||||
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全,
|
||||
Enter 执行),`/model`、`/effort`、`/approval`、`/provider`、`/skill`、`/thinking`、`/details`
|
||||
还会补全参数。`/usr/bin 是什么` 这种第一个词里带 `/` 的照常发送;未知命令留在输入框里并提示,
|
||||
开头加个空格即可当普通消息发出。
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `/new` `/sessions` `/rename [标题]` | 新建 / 会话列表 / 重命名 |
|
||||
| `/retry` `/edit` `/stop` `/undo` `/compact` | 重新生成 / 编辑重发 / 停止 / 撤销写入 / 压缩上下文 |
|
||||
| `/export` `/copy` `/diff [文件]` | 导出 Markdown / 复制最后一条回答 / 工作区 diff |
|
||||
| `/model [id]` `/effort [级别]` `/provider [名字]` `/key` | 模型、推理强度、提供商、API key |
|
||||
| `/approval [ask\|auto\|yolo]` `/plan` `/build` | 审批权限、计划/构建模式 |
|
||||
| `/settings`(`/protocol` `/tools` `/max-steps` `/permissions`) | 运行设置 |
|
||||
| `/init` `/skill 名字 [任务]` `/skills` `/extensions`(`/mcp` `/plugins` `/agents`) | 写 AGENTS.md、用 skill、扩展状态 |
|
||||
| `/thinking [on\|off]` `/details [on\|off]` | 思考过程默认展开(存 `settings.json`)、工具输出全部展开(存浏览器) |
|
||||
| `/theme` `/palette` `/help` | 主题、命令面板、帮助 |
|
||||
|
||||
快捷键:Ctrl+P 命令面板,Ctrl+K 会话列表,Shift+Tab 循环审批(每次询问 → 自动 → 完全放行),
|
||||
F1 或 `?` 帮助,Esc 关闭弹窗 / 取消编辑 / 停止当前一轮,Ctrl+B / Ctrl+J 收起左栏 / 右栏。
|
||||
|
||||
引擎的英文状态、错误和会话里的 `[cst]` 提示在页面上换成中文;一轮失败时(`turn_end.rc≠0`)
|
||||
显示可读的原因(比如"API key 无效或已过期(HTTP 401)"、"用满了 30 步上限"),不再是 `rc=-1`。
|
||||
|
||||
TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目录> --new`),
|
||||
日志在 `<workspace>/.agent/webui.log`:TUI 不能被 `cst_webui_run()` 的阻塞循环占住,
|
||||
而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。
|
||||
|
||||
自动压缩的门槛是模型上下文窗口的 **75%**(`CST_CTX_BUDGET` 可覆盖);到点后下一轮
|
||||
开始前先总结历史,而不是等到塞满。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
|
||||
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
|
||||
# (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流;
|
||||
# e2e_webui_sessions.py:重新生成/编辑重发/导出/重命名/删除/历史字段;
|
||||
# e2e_webui_workspace.py:diff、运行设置、权限规则;
|
||||
# e2e_webui_ext.py:/init、/skill、扩展、信任、思考开关)
|
||||
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
|
||||
# (deepseek-ctl 录的是一次契约错误连发的失败运行,不能拿来跑这个)
|
||||
```
|
||||
|
||||
手工验收清单(浏览器):
|
||||
|
||||
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
|
||||
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
|
||||
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
|
||||
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行
|
||||
过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。
|
||||
`/thinking off` 时流式输出的思考也保持收起。
|
||||
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
|
||||
5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。
|
||||
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
|
||||
|
||||
## 限制与已知问题
|
||||
|
||||
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
|
||||
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
|
||||
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
|
||||
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
|
||||
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
|
||||
这时应当重新拉一次 `/api/session`。
|
||||
@@ -1,11 +1,9 @@
|
||||
# Windows self-test checklist
|
||||
|
||||
Run on a Windows machine (or wine) after MinGW build + busybox.exe beside the binary.
|
||||
Automated: `scripts/win_test.sh` cross-builds and runs every test on the
|
||||
English and Chinese Windows Server VMs as Administrator, a standard user and
|
||||
a Chinese user name (VM tooling in `scripts/winvm/`).
|
||||
|
||||
1. `cst-agent.exe --help` exits 0
|
||||
2. `set CST_API_KEY=...` then `--headless --workspace . --prompt "run echo hello"` — tool output contains hello
|
||||
3. `write_file` under workspace succeeds; path outside workspace rejected
|
||||
4. Credentials saved under `%APPDATA%\cst-agent\credentials`
|
||||
5. With PDCurses build: launch without `--headless`, confirm TUI draws, `/help` works, Esc cancels
|
||||
|
||||
Shell must resolve to bundled busybox unless `CST_SHELL` is set.
|
||||
Manual: the "Windows specifics" list in [compat.md](compat.md), plus the
|
||||
general CLI and TUI items there. Install and sandbox details are in
|
||||
[README-windows.md](README-windows.md).
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "ctl.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -11,7 +12,6 @@ extern "C" {
|
||||
|
||||
#define CST_AGENT_MAX_MSG 256
|
||||
#define CST_AGENT_MAX_CONTENT (512 * 1024)
|
||||
#define CST_AGENT_CTX_WINDOW 80 /* max non-system msgs sent to LLM */
|
||||
#define CST_DEFAULT_MAX_STEPS 30
|
||||
#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024)
|
||||
|
||||
@@ -47,6 +47,26 @@ 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 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, ...; 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 (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 {
|
||||
cst_role_t role;
|
||||
char *content;
|
||||
@@ -55,6 +75,10 @@ typedef struct {
|
||||
char tool_name[CTL_MAX_NAME];
|
||||
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
|
||||
char *meta_json; /* optional JSON object (kind, protocol, ...) */
|
||||
cst_attachment_t *attach; /* images shown with this message, or NULL */
|
||||
int nattach;
|
||||
char *reasoning; /* assistant: reasoning_content as streamed, or NULL */
|
||||
int prefix; /* wire only: assistant prefix the model continues */
|
||||
} cst_message_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -65,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 */
|
||||
@@ -97,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;
|
||||
|
||||
@@ -113,6 +153,12 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i);
|
||||
const char *cst_session_path(const cst_session_t *s);
|
||||
int cst_session_clear(cst_session_t *s, int keep_system);
|
||||
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
|
||||
/* Tool output: role=tool, or a text-protocol user message answering a call. */
|
||||
int cst_message_is_tool_result(const cst_message_t *m);
|
||||
/* The transcript as Markdown, malloc'd (NULL on OOM). Tool output and call
|
||||
* arguments are cut at 4000 bytes. Takes the session lock. */
|
||||
char *cst_session_export_md(cst_session_t *s, const char *title,
|
||||
const char *sid, const char *model);
|
||||
/* Readers on another thread must hold the lock while using pointers returned
|
||||
* by cst_session_get (append may reallocate). Append locks internally. */
|
||||
void cst_session_lock(cst_session_t *s);
|
||||
@@ -121,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);
|
||||
@@ -131,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;
|
||||
@@ -143,12 +200,23 @@ 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 {
|
||||
char *out;
|
||||
size_t out_len;
|
||||
int exit_code;
|
||||
/* Images the tool produced for the model (view_image, image-returning MCP
|
||||
* tools). cst_tool_result_free releases them. */
|
||||
cst_attachment_t *attach;
|
||||
int nattach;
|
||||
} cst_tool_result_t;
|
||||
|
||||
void cst_tool_result_free(cst_tool_result_t *r);
|
||||
@@ -200,7 +268,9 @@ typedef struct {
|
||||
char finish_reason[32];
|
||||
long prompt_tokens;
|
||||
long completion_tokens;
|
||||
long cached_tokens;
|
||||
long cached_tokens; /* prompt tokens read from the provider's cache */
|
||||
long cache_miss_tokens; /* prompt tokens billed at the full input price */
|
||||
long cache_write_tokens; /* prompt tokens written to the cache (Anthropic) */
|
||||
double t_first_byte_ms; /* relative to request start; -1 if none */
|
||||
double t_end_ms;
|
||||
int client_stopped; /* stream ended by stop_flag (not an error) */
|
||||
@@ -222,8 +292,18 @@ typedef struct {
|
||||
void *userdata;
|
||||
int enable_tools; /* send tools[] schema */
|
||||
const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */
|
||||
const char *tools_json; /* tools[] sent verbatim (frozen); NULL = tool_cfg */
|
||||
int tool_choice_none; /* "tool_choice":"none" */
|
||||
int send_reasoning; /* assistant reasoning goes back as reasoning_content */
|
||||
int cache_style; /* CST_CACHE_* hints to add */
|
||||
const char *cache_key; /* prompt_cache_key (CST_CACHE_OPENAI) */
|
||||
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 */
|
||||
cst_llm_stats_t *stats; /* optional */
|
||||
@@ -242,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
|
||||
@@ -274,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;
|
||||
|
||||
@@ -291,9 +379,40 @@ cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a);
|
||||
void cst_agent_request_cancel(cst_agent_t *a);
|
||||
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
|
||||
int cst_agent_switch_session(cst_agent_t *a, const char *id);
|
||||
/* Re-root the agent at another directory. Per-workspace state (permission
|
||||
* rules, metrics, project notes) follows, the system prompt is rebuilt and the
|
||||
* token budget is re-measured. The session is not touched: the caller decides
|
||||
* whether to keep it or start a new one. */
|
||||
int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err,
|
||||
size_t err_sz);
|
||||
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
|
||||
/* Bare id of the current session (file name without .jsonl). */
|
||||
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
|
||||
/* Transcript path of the current session (images are stored beside it). */
|
||||
const char *cst_agent_session_path(const cst_agent_t *a);
|
||||
/* Images picked for the next user message (/attach). They ride along with the
|
||||
* next cst_agent_run_turn*() call and are then part of the session.
|
||||
* cst_agent_attach_file copies the file into the session store; *err gets a
|
||||
* short reason on failure (-1). */
|
||||
int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err,
|
||||
size_t err_sz);
|
||||
/* Same for bytes already in memory (clipboard); name is display-only. */
|
||||
int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
|
||||
size_t n, char *err, size_t err_sz);
|
||||
/* Whether the current model is shown images: 1 yes, -1 unknown (tried; a
|
||||
* 400 turns sending off), 0 no (images go as text notes). */
|
||||
int cst_agent_sees_images(const cst_agent_t *a);
|
||||
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);
|
||||
/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */
|
||||
@@ -311,6 +430,19 @@ void cst_agent_undo_key(const cst_agent_t *a, char *sid, size_t sid_sz,
|
||||
char *tag, size_t tag_sz);
|
||||
/* Last prompt_tokens reported by the model (0 if unknown). */
|
||||
long cst_agent_last_prompt_tokens(const cst_agent_t *a);
|
||||
/* Token use since the session was opened, and of the latest turn. */
|
||||
typedef struct {
|
||||
int requests;
|
||||
long prompt, cached, miss, write, out;
|
||||
double cost; /* in currency; valid when priced */
|
||||
int priced; /* every request had a known price */
|
||||
char currency[8];
|
||||
} cst_usage_t;
|
||||
void cst_agent_usage(const cst_agent_t *a, cst_usage_t *session,
|
||||
cst_usage_t *turn);
|
||||
/* "[cst] ..." notes the agent appends when Plan mode, approval, branch,
|
||||
* date or AGENTS.md change mid-session (the system prompt stays frozen). */
|
||||
int cst_message_is_note(const cst_message_t *m);
|
||||
|
||||
const char *cst_agent_system_prompt(void);
|
||||
const char *cst_agent_system_prompt_for(cst_protocol_t p);
|
||||
@@ -330,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 */
|
||||
@@ -347,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);
|
||||
@@ -384,6 +537,39 @@ const cst_provider_t *cst_provider_at(int i);
|
||||
/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */
|
||||
long cst_model_context_tokens(const char *model);
|
||||
|
||||
/* How a provider caches prompt prefixes.
|
||||
* auto : implicit prefix cache, nothing to send (DeepSeek, MiniMax...)
|
||||
* openai : implicit, routed by "prompt_cache_key"
|
||||
* anthropic : explicit "cache_control" breakpoints on content parts */
|
||||
typedef enum {
|
||||
CST_CACHE_AUTO = 0,
|
||||
CST_CACHE_OPENAI,
|
||||
CST_CACHE_ANTHROPIC
|
||||
} cst_cache_style_t;
|
||||
typedef struct {
|
||||
int cache_style; /* cst_cache_style_t */
|
||||
int reasoning_passback; /* history keeps reasoning_content (DeepSeek) */
|
||||
int prefix_completion; /* assistant "prefix": true via <base>/beta */
|
||||
int vision; /* 1 takes images, 0 text only, -1 unknown */
|
||||
} cst_model_caps_t;
|
||||
/* CST_NO_CACHE_HINTS=1 forces CST_CACHE_AUTO. */
|
||||
void cst_model_caps(const char *provider, const char *api_base,
|
||||
const char *model, cst_model_caps_t *out);
|
||||
|
||||
/* Prices per million tokens. CST_PRICE="miss,hit,out[,write[,currency]]"
|
||||
* overrides the built-in table. */
|
||||
typedef struct {
|
||||
char currency[8]; /* "¥", "$" */
|
||||
double miss, hit, write, out;
|
||||
int offpeak_half; /* DeepSeek: half price outside Beijing peak hours */
|
||||
} cst_price_t;
|
||||
/* 0 when a price is known for model, -1 otherwise. */
|
||||
int cst_model_price(const char *model, cst_price_t *out);
|
||||
/* DeepSeek peak hours: Beijing time Mon-Fri 9:00-12:00 and 14:00-18:00. */
|
||||
int cst_price_is_peak(time_t when);
|
||||
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
|
||||
long out, time_t when);
|
||||
|
||||
/* Reasoning effort: low | medium | high | max. */
|
||||
#define CST_DEFAULT_EFFORT "medium"
|
||||
int cst_effort_valid(const char *effort);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#ifndef CST_ATTACH_H
|
||||
#define CST_ATTACH_H
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* --- 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), 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). */
|
||||
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz);
|
||||
/* Pixel size from the header when it is readable; 0 for unknown. */
|
||||
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h);
|
||||
|
||||
/* A stored file name as store() makes it ("<id>.<ext>"); rejects anything
|
||||
* that could leave the attachment directory. */
|
||||
int cst_attach_rel_ok(const char *rel);
|
||||
/* Directory holding this session's stored images (created on demand). */
|
||||
int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
|
||||
/* Absolute path of a stored image (0 / -1). */
|
||||
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
||||
char *out, size_t out_sz);
|
||||
|
||||
/* 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 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 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; images only, NULL otherwise. */
|
||||
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
|
||||
/* "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);
|
||||
/* Free a list of n entries allocated as separate blocks. */
|
||||
void cst_attachments_free(cst_attachment_t *list, int n);
|
||||
/* Deep copy (NULL on OOM or when n <= 0). */
|
||||
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n);
|
||||
/* Append src to *list (grows with realloc). 0 / -1. */
|
||||
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
|
||||
const cst_attachment_t *src);
|
||||
|
||||
/* JSON array of n attachments for the session JSONL (malloc'd), or NULL. */
|
||||
char *cst_attachments_to_json(const cst_attachment_t *list, int n);
|
||||
/* Parse a JSON array; caller frees with cst_attachments_free. */
|
||||
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out);
|
||||
|
||||
/* Base64 of n bytes (malloc'd, no line breaks). */
|
||||
char *cst_base64_encode(const void *data, size_t n);
|
||||
/* Standard or URL-safe base64 (whitespace ignored) to bytes; malloc'd,
|
||||
* NULL on a bad character. */
|
||||
char *cst_base64_decode(const char *s, size_t n, size_t *out_len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,53 @@
|
||||
#ifndef CST_CLIPBOARD_H
|
||||
#define CST_CLIPBOARD_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Image bytes on the system clipboard (PNG, or BMP from a Windows DIB).
|
||||
* Wayland: wl-paste; X11: xclip; macOS: pngpaste or osascript; Windows: the
|
||||
* clipboard API. Returns 0 with *data malloc'd, or -1 with err filled in
|
||||
* ("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
|
||||
|
||||
/* Split pasted text into file paths the way terminals paste dropped files:
|
||||
* whitespace- or newline-separated, '...' / "..." quoting, file:// URIs
|
||||
* (percent-decoded) and, outside Windows, backslash escapes. Returns the
|
||||
* count, or -1 when the text is empty or holds more than max paths. Whether
|
||||
* the paths exist is up to the caller. */
|
||||
int cst_paste_paths(const char *text, size_t len,
|
||||
char out[][CST_PASTE_PATH_MAX], int max);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,108 @@
|
||||
#ifndef CST_HTTP_H
|
||||
#define CST_HTTP_H
|
||||
|
||||
/* A small HTTP/1.1 server for the built-in WebUI (src/http_srv.c).
|
||||
*
|
||||
* Only what a local single-page app needs: exact-match routes, keep-alive,
|
||||
* fixed-length bodies and chunked streaming responses (SSE). No TLS, no
|
||||
* HTTP/2, no chunked *request* bodies, no CGI. Every connection is served
|
||||
* by its own thread; a handler may keep the connection as long as it wants
|
||||
* (that is how the event stream works). */
|
||||
|
||||
#include "cst_net.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct cst_http_conn cst_http_conn_t;
|
||||
|
||||
typedef struct {
|
||||
char method[8]; /* GET / POST / DELETE ... */
|
||||
char path[1024]; /* percent-decoded, without the query string */
|
||||
char query[1024]; /* raw, without '?' */
|
||||
const char *body; /* NUL-terminated; may hold binary bytes */
|
||||
size_t body_len;
|
||||
/* Headers of interest, NULL when absent (pointers into the request). */
|
||||
const char *authorization;
|
||||
const char *cookie;
|
||||
const char *origin;
|
||||
const char *host;
|
||||
const char *content_type;
|
||||
const char *user_agent;
|
||||
const char *last_event_id; /* an EventSource reconnecting */
|
||||
int keep_alive;
|
||||
} cst_http_req_t;
|
||||
|
||||
/* 0 = handled (a response was sent), -1 = not found (the server replies). */
|
||||
typedef int (*cst_http_fn)(cst_http_conn_t *c, void *ud);
|
||||
|
||||
typedef struct {
|
||||
const char *method; /* "*" matches any */
|
||||
const char *path; /* exact; "*" = fallback (try last) */
|
||||
cst_http_fn fn;
|
||||
void *ud;
|
||||
int auth; /* ask the auth hook before the handler */
|
||||
size_t max_body; /* request body cap; 0 = server default */
|
||||
} cst_http_route_t;
|
||||
|
||||
typedef struct {
|
||||
const char *host; /* NULL/"" = 127.0.0.1 */
|
||||
int port; /* 0 = pick a free port */
|
||||
size_t max_body; /* default 1 MiB */
|
||||
int max_conn; /* default 32 */
|
||||
int read_timeout_ms; /* waiting for the request headers (default 15000) */
|
||||
int idle_timeout_ms; /* keep-alive pause between requests (default 60000) */
|
||||
const cst_http_route_t *routes;
|
||||
int nroutes;
|
||||
/* 0 = allow, -1 = the server answers 401 before the handler runs. */
|
||||
int (*auth)(const cst_http_req_t *req, void *ud);
|
||||
void *auth_ud;
|
||||
/* One line per request, e.g. "GET /api/state -> 200". */
|
||||
void (*on_log)(const char *line, void *ud);
|
||||
void *log_ud;
|
||||
} cst_http_opts_t;
|
||||
|
||||
typedef struct cst_http_server cst_http_server_t;
|
||||
|
||||
/* Start listening. NULL on failure with a reason in err. */
|
||||
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
|
||||
size_t err_sz);
|
||||
int cst_http_port(const cst_http_server_t *s);
|
||||
/* Non-zero once stop() was called: long-lived handlers must return. */
|
||||
int cst_http_stopping(const cst_http_server_t *s);
|
||||
/* Close the listener and every connection, then wait (30 s at most) for the
|
||||
* handler threads. Returns how many still run: their ud must stay alive. */
|
||||
int cst_http_stop(cst_http_server_t *s);
|
||||
/* Does nothing while handlers still run on s. */
|
||||
void cst_http_free(cst_http_server_t *s);
|
||||
|
||||
/* --- inside a handler --- */
|
||||
const cst_http_req_t *cst_http_request(cst_http_conn_t *c);
|
||||
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const void *body, size_t n);
|
||||
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const char *s);
|
||||
/* json may be NULL (then "null" is sent); it is copied, not owned. */
|
||||
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json);
|
||||
/* {"error":"..."} */
|
||||
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg);
|
||||
/* True once a response (or stream) began for this request. */
|
||||
int cst_http_sent(const cst_http_conn_t *c);
|
||||
|
||||
/* Chunked response: begin(), any number of write()s, end(). */
|
||||
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype);
|
||||
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n);
|
||||
int cst_http_stream_end(cst_http_conn_t *c);
|
||||
|
||||
/* Query parameter, percent-decoded ('+' = space). 0 when found. */
|
||||
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
|
||||
size_t out_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_HTTP_H */
|
||||
@@ -64,6 +64,7 @@ typedef enum {
|
||||
CST_KA_COMPACT,
|
||||
CST_KA_COPY,
|
||||
CST_KA_EXPORT,
|
||||
CST_KA_PASTE_IMAGE,
|
||||
/* model */
|
||||
CST_KA_MODEL,
|
||||
CST_KA_PROVIDER,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#ifndef CST_MODELS_H
|
||||
#define CST_MODELS_H
|
||||
|
||||
/* Model registry: what cst-agent knows about providers and models.
|
||||
*
|
||||
* Three layers, highest first, merged field by field:
|
||||
* 1. <config>/models.json user overrides and extra providers
|
||||
* 2. <config>/models-cache/<provider>.json GET /models metadata, exact id
|
||||
* 3. built-in data (src/models_builtin.json, embedded at build time)
|
||||
* then defaults (128000 tokens, vision unknown). Model rules match the name
|
||||
* without its "vendor/" prefix, case-insensitively: a plain "match" is a
|
||||
* prefix, one with '*' is a glob, one with '/' is tested against the full
|
||||
* id. Within a layer the most specific rule (most literal characters) that
|
||||
* sets a field wins, so table order does not matter.
|
||||
*
|
||||
* CST_CTX_TOKENS, CST_PRICE, CST_MODELS and CST_NO_CACHE_HINTS still
|
||||
* override everything. */
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
long context; /* tokens */
|
||||
int vision; /* 1 takes images, 0 text only, -1 unknown */
|
||||
int cache_style; /* cst_cache_style_t */
|
||||
int reasoning; /* takes OpenAI-style reasoning_effort */
|
||||
int has_price;
|
||||
cst_price_t price;
|
||||
/* Layers that supplied anything, highest first: "user+api+built-in",
|
||||
* "default", "env+built-in"... */
|
||||
char source[48];
|
||||
} cst_model_info_t;
|
||||
|
||||
/* provider may be NULL: then every provider's fetched cache is searched. */
|
||||
void cst_model_info(const char *provider, const char *model,
|
||||
cst_model_info_t *out);
|
||||
/* Short picker label: "1M · vision", "200K · text only", "128K". */
|
||||
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n);
|
||||
|
||||
/* One entry of GET /models, with the optional OpenRouter-style metadata. */
|
||||
typedef struct {
|
||||
char id[128];
|
||||
long context; /* 0 = not given */
|
||||
int vision; /* -1 = not given */
|
||||
int has_price; /* prices in USD per 1M tokens */
|
||||
double miss, hit, write, out;
|
||||
} cst_model_meta_t;
|
||||
|
||||
/* Like cst_llm_list_models, keeping context_length,
|
||||
* architecture.input_modalities (or modalities) and pricing when present. */
|
||||
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
|
||||
cst_model_meta_t *out, int max_out, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
#define CST_MODELS_CACHE_TTL (24L * 3600)
|
||||
|
||||
/* Store a fetched list as <config>/models-cache/<provider>.json and use it
|
||||
* from now on. 0 / -1. */
|
||||
int cst_models_cache_save(const char *provider, const char *api_base,
|
||||
const cst_model_meta_t *list, int n);
|
||||
/* The cached list for provider: count (up to max), or -1 when there is
|
||||
* none. *age_s gets the seconds since it was fetched. */
|
||||
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
|
||||
long *age_s);
|
||||
|
||||
/* The "models" a provider entry lists (built-in or user file). */
|
||||
int cst_provider_models(const cst_provider_t *p, char out[][128], int max);
|
||||
/* Where a provider entry came from: "built-in" or "user". */
|
||||
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);
|
||||
/* Tests: load these documents instead of the built-in data and the user
|
||||
* file (NULL = none). Caches still come from <config>. 0, or -1 with err. */
|
||||
int cst_models_load_json(const char *builtin_json, const char *user_json,
|
||||
char *err, size_t err_sz);
|
||||
/* The embedded built-in document. */
|
||||
const char *cst_models_builtin_json(void);
|
||||
/* Problems found while loading (bad user file...), or "". */
|
||||
const char *cst_models_load_error(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,68 @@
|
||||
#ifndef CST_NET_H
|
||||
#define CST_NET_H
|
||||
|
||||
/* Minimal TCP sockets for the embedded HTTP server (src/net.c).
|
||||
*
|
||||
* The point of this layer is that src/http_srv.c and src/webui.c never
|
||||
* include a socket header: Unix file descriptors and Windows SOCKETs differ
|
||||
* in type, close() and error reporting, and Windows needs WSAStartup before
|
||||
* anything works. */
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
typedef intptr_t os_socket_t;
|
||||
#define OS_SOCKET_BAD ((os_socket_t)-1)
|
||||
#else
|
||||
typedef int os_socket_t;
|
||||
#define OS_SOCKET_BAD (-1)
|
||||
#endif
|
||||
|
||||
/* Longest "address:port" os_net_peer writes (IPv6 hosts are bracketed so a
|
||||
* URL built from it stays unambiguous). */
|
||||
#define OS_NET_ADDR_MAX 64
|
||||
|
||||
/* Winsock startup (no-op on Unix, which also ignores SIGPIPE so a client
|
||||
* that hangs up cannot kill the process). 0 / -1. */
|
||||
int os_net_init(void);
|
||||
void os_net_cleanup(void);
|
||||
|
||||
/* Bind and listen on host:port. host may be NULL/"" (then "127.0.0.1"),
|
||||
* "0.0.0.0"/"::" (every interface) or any literal address / name; port 0
|
||||
* picks a free one. Returns the listening socket, or OS_SOCKET_BAD with a
|
||||
* short reason in err. */
|
||||
os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz);
|
||||
/* The port actually bound (0 when unknown). */
|
||||
int os_net_port(os_socket_t s);
|
||||
/* A one-line "host:port" for messages. */
|
||||
void os_net_peer(os_socket_t s, char *out, size_t out_sz);
|
||||
|
||||
/* Connect to host:port, waiting up to timeout_ms: the socket, or
|
||||
* OS_SOCKET_BAD. Used by the tests and by anything that drives the WebUI
|
||||
* API from inside the process. */
|
||||
os_socket_t os_net_connect(const char *host, int port, int timeout_ms);
|
||||
|
||||
/* Accept one connection, waiting up to timeout_ms (<0 = forever).
|
||||
* Returns the socket, OS_SOCKET_BAD on error, or OS_SOCKET_BAD with
|
||||
* *timed_out set when the wait ran out. */
|
||||
os_socket_t os_net_accept(os_socket_t s, int timeout_ms, int *timed_out);
|
||||
|
||||
/* Read at most n bytes, waiting up to timeout_ms: bytes read, 0 at EOF,
|
||||
* -1 on error, -2 when nothing arrived in time. */
|
||||
long os_net_recv(os_socket_t s, void *buf, size_t n, int timeout_ms);
|
||||
/* Write everything (timeout_ms per call): 0 / -1. */
|
||||
int os_net_send_all(os_socket_t s, const void *buf, size_t n, int timeout_ms);
|
||||
/* Wake up a thread blocked in recv/send on this socket (shutdown, no close). */
|
||||
void os_net_shutdown(os_socket_t s);
|
||||
void os_net_close(os_socket_t s);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_NET_H */
|
||||
@@ -0,0 +1,27 @@
|
||||
#ifndef CST_PDF_H
|
||||
#define CST_PDF_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Text layer of a PDF, in page order, with no external dependency (no
|
||||
* poppler, no zlib): classic objects and object streams, FlateDecode plus
|
||||
* ASCIIHex/ASCII85, PNG/TIFF predictors, Tj/TJ/'/" text with per-font
|
||||
* ToUnicode CMaps and a WinAnsi / Latin-1 fallback. Scanned pages have no
|
||||
* text layer and are reported as such.
|
||||
* Returns a malloc'd NUL-terminated UTF-8 string, or NULL with err filled in
|
||||
* ("PDF is encrypted ...", "no extractable text ..."). *pages (optional) gets
|
||||
* the page count; the result is truncated at CST_PDF_TEXT_MAX bytes. */
|
||||
char *cst_pdf_text(const void *data, size_t n, int *pages, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
#define CST_PDF_TEXT_MAX (4u * 1024u * 1024u)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -26,6 +26,9 @@ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz);
|
||||
/* 1 when every segment of cmd (split on | || && ;) is a read-only command
|
||||
* from a fixed whitelist, with no output redirection or substitution. */
|
||||
int cst_shell_is_safe(const char *cmd);
|
||||
/* 1 when cmd looks like it talks to the network (curl, ssh, git push,
|
||||
* npm install, ...). Errs on the side of yes. */
|
||||
int cst_shell_uses_network(const char *cmd);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#ifndef CST_PROVIDER_SWITCH_H
|
||||
#define CST_PROVIDER_SWITCH_H
|
||||
|
||||
/* Model lists, API keys and provider switches, shared by the TUI and the
|
||||
* WebUI so both front ends validate and persist the same way.
|
||||
*
|
||||
* A key is checked with GET /models: a 401/403 (CST_MODELS_AUTH) rejects
|
||||
* it, any other failure (offline, no /models route) lets it through
|
||||
* unverified. A key that passes is saved to <config>/credentials. Keys are
|
||||
* never copied into err. */
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_models.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define CST_PS_MAX_MODELS 512
|
||||
#define CST_PS_UNKNOWN (-1) /* no such provider */
|
||||
#define CST_PS_NEED_KEY (-10) /* no key given, none saved or in the env */
|
||||
/* Non-negative results are flags: */
|
||||
#define CST_PS_UNVERIFIED 1 /* /models failed for another reason; err says */
|
||||
#define CST_PS_UNSAVED 2 /* the credentials file could not be written */
|
||||
|
||||
typedef struct {
|
||||
char id[128];
|
||||
int api; /* listed by the provider's /models (else built-in only) */
|
||||
cst_model_info_t info;
|
||||
} cst_ps_model_t;
|
||||
|
||||
/* GET /models; a good answer refreshes models-cache/<provider>.json. The
|
||||
* count, CST_MODELS_AUTH, or another negative value with err set. */
|
||||
int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
|
||||
char ids[][128], int max, char *err, size_t err_sz);
|
||||
/* The cached list: count, or -1; *fresh when younger than the TTL. */
|
||||
int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh);
|
||||
|
||||
/* Base URL for provider: cfg's own when it is the current provider (it may
|
||||
* be overridden), else the preset's; "" when unknown. */
|
||||
const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider);
|
||||
|
||||
/* The model picker list for cfg's provider: the API's ids (the cache while
|
||||
* it is fresh, else a fetch; refresh always fetches) followed by the
|
||||
* built-in names it did not list. *api_rc gets the fetch result (count,
|
||||
* CST_MODELS_AUTH, -1 with err) or the cached count. Returns the count. */
|
||||
int cst_ps_models(const cst_agent_config_t *cfg, int refresh,
|
||||
cst_ps_model_t *out, int max, int *api_rc, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
/* Checks key against /models and saves it: CST_MODELS_AUTH (err set), or
|
||||
* the CST_PS_* flags. When ids is given it gets the fetched list and *nids
|
||||
* the raw fetch result. */
|
||||
int cst_ps_verify_save(const char *provider, const char *api_base,
|
||||
const char *key, char ids[][128], int max, int *nids,
|
||||
char *err, size_t err_sz);
|
||||
|
||||
/* Point cfg at provider p with key: base URL, the remembered model (else
|
||||
* the preset's), settings saved. */
|
||||
void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p,
|
||||
const char *key);
|
||||
|
||||
/* The whole switch without a prompt: key NULL/"" uses the saved or env
|
||||
* key (else CST_PS_NEED_KEY); a given key is verified and saved first. A
|
||||
* preset with no model takes the first one the API lists. The CST_PS_*
|
||||
* flags, or CST_PS_UNKNOWN / CST_PS_NEED_KEY / CST_MODELS_AUTH. */
|
||||
int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
|
||||
const char *key, char *err, size_t err_sz);
|
||||
|
||||
/* Replace the current provider's key: verified, saved, put in cfg. Same
|
||||
* results as cst_ps_verify_save. */
|
||||
int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
|
||||
size_t err_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -2,16 +2,19 @@
|
||||
#define CST_SANDBOX_H
|
||||
|
||||
/*
|
||||
* OS sandbox for run_shell: reads and exec stay allowed everywhere, writes
|
||||
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and
|
||||
* the network is cut where the OS can do it.
|
||||
* Linux Landlock (network needs ABI 4, kernel 6.7+)
|
||||
* OS sandbox for run_shell: reads, exec and the network stay allowed;
|
||||
* writes are limited to the workspace, the temp dir, /dev/null and /dev/tty.
|
||||
* Linux Landlock
|
||||
* Linux seccomp user-notify when Landlock is missing (Android/Termux,
|
||||
* older kernels): a supervisor thread checks the path of every
|
||||
* write-type syscall and IPv4/IPv6 sockets are refused. Guards
|
||||
* write-type syscall. Guards
|
||||
* against mistakes, not hostile code (the path is checked before
|
||||
* the kernel reads it again).
|
||||
* macOS sandbox_init (Seatbelt profile)
|
||||
* Windows Low integrity level: a restricted token (no admin privileges,
|
||||
* Low IL) can only write objects labeled Low, so the workspace and
|
||||
* a temp dir get that label; the credentials file is labeled
|
||||
* no-read-up.
|
||||
* other none: callers fall back to rule-based approval
|
||||
*/
|
||||
|
||||
@@ -27,17 +30,18 @@ typedef enum {
|
||||
CST_SANDBOX_NONE = 0,
|
||||
CST_SANDBOX_LANDLOCK,
|
||||
CST_SANDBOX_SEATBELT,
|
||||
CST_SANDBOX_SECCOMP
|
||||
CST_SANDBOX_SECCOMP,
|
||||
CST_SANDBOX_LOWIL
|
||||
} cst_sandbox_kind_t;
|
||||
|
||||
/* What this process can use; cached. CST_SANDBOX=off|0|none forces NONE,
|
||||
* CST_SANDBOX=landlock|seccomp allows only that kind. */
|
||||
* CST_SANDBOX=landlock|seccomp|lowil allows only that kind. */
|
||||
int cst_sandbox_probe(void);
|
||||
/* Drops the cache and probes again (tests that change CST_SANDBOX). */
|
||||
int cst_sandbox_reprobe(void);
|
||||
/* "landlock" | "seatbelt" | "seccomp" | "none" */
|
||||
/* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */
|
||||
const char *cst_sandbox_name(int kind);
|
||||
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */
|
||||
/* Landlock ABI version (0 when unavailable). */
|
||||
int cst_sandbox_landlock_abi(void);
|
||||
|
||||
typedef struct {
|
||||
@@ -45,6 +49,9 @@ typedef struct {
|
||||
int ruleset_fd; /* Landlock, built in the parent */
|
||||
char *profile; /* Seatbelt */
|
||||
void *sc; /* seccomp: filter program and supervisor thread */
|
||||
void *token; /* lowil: primary token for CreateProcessAsUserW */
|
||||
char *tmpdir; /* lowil: Low-labeled TEMP for the child */
|
||||
char *note; /* lowil: shown once when a directory was just labeled */
|
||||
} cst_sandbox_plan_t;
|
||||
|
||||
/* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */
|
||||
@@ -61,9 +68,17 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
|
||||
size_t max_out, os_run_result_t *r);
|
||||
|
||||
/* Output of a failed sandboxed run that looks like the sandbox got in the
|
||||
* way (EACCES / EPERM / EROFS / blocked network). */
|
||||
* way (EACCES / EPERM / EROFS). */
|
||||
int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
|
||||
|
||||
/* lowil: directories this user's cst-agent labeled Low (recorded in the
|
||||
* config dir), one per line; malloc'd, NULL when there are none. */
|
||||
char *cst_sandbox_labels(void);
|
||||
/* Gives every recorded directory its old label back. Returns how many were
|
||||
* restored (-1 when some failed; those stay recorded); *report gets a
|
||||
* malloc'd summary. */
|
||||
int cst_sandbox_reset_labels(char **report);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -45,6 +45,8 @@ char *cst_skill_read(const char *workspace, const char *name,
|
||||
* task. NULL when the skill does not exist. malloc'd. */
|
||||
char *cst_skill_prompt(const char *workspace, const char *name,
|
||||
const char *task);
|
||||
/* User message for `/init`: have the agent write AGENTS.md. */
|
||||
const char *cst_init_prompt(void);
|
||||
/* Register the `skill` tool (idempotent). */
|
||||
int cst_skills_register_tool(void);
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx,
|
||||
const ctl_tool_call_t *call,
|
||||
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);
|
||||
|
||||
@@ -25,6 +25,38 @@ char *cst_buf_steal(cst_buf_t *b);
|
||||
size_t cst_utf8_floor(const char *s, size_t n);
|
||||
/* Copy s[0..n) replacing invalid UTF-8 with '?'. malloc'd, NUL-terminated. */
|
||||
char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
|
||||
int cst_utf8_valid(const char *s, size_t n);
|
||||
|
||||
/* Re-chunks a byte stream so no piece ends inside a UTF-8 sequence: the
|
||||
* incomplete tail (at most 3 bytes) waits in the hold for the next feed. */
|
||||
typedef struct {
|
||||
char b[4];
|
||||
size_t n;
|
||||
} cst_utf8_hold_t;
|
||||
/* Returns the complete prefix of hold+s[0..n) (malloc'd, NUL-terminated,
|
||||
* *out_len may be 0), or NULL on OOM. */
|
||||
char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n,
|
||||
size_t *out_len);
|
||||
|
||||
/* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3),
|
||||
* and an in-place strip of the NUL-terminated s (*len updated when given). */
|
||||
size_t cst_bom_len(const char *s, size_t n);
|
||||
void cst_strip_bom(char *s, size_t *len);
|
||||
/* Line endings: "\r\n" -> "\n" in place (returns the new length); whether
|
||||
* every line of s ends in "\r\n" (and at least one does); and a malloc'd
|
||||
* copy of s with bare "\n" turned into "\r\n" when crlf, its BOM dropped and
|
||||
* a BOM added when bom. Edits keep a Windows file's style with these. */
|
||||
size_t cst_crlf_to_lf(char *s, size_t n);
|
||||
int cst_text_is_crlf(const char *s, size_t n);
|
||||
char *cst_text_restyle(const char *s, size_t n, int crlf, int bom,
|
||||
size_t *out_len);
|
||||
|
||||
/* fnmatch(3) without FNM_PATHNAME ('*' also crosses '/'): * ? [..] [!..]
|
||||
* [[:class:]] and backslash escapes over UTF-8 code points. Returns 0 on a
|
||||
* match. Always case-insensitive on Windows. */
|
||||
#define CST_FNM_PERIOD 1 /* a leading '.' must be matched literally */
|
||||
#define CST_FNM_CASEFOLD 2
|
||||
int cst_fnmatch(const char *pat, const char *str, int flags);
|
||||
|
||||
uint64_t cst_fnv1a64(uint64_t h, const void *data, size_t n);
|
||||
#define CST_FNV_INIT 1469598103934665603ULL
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#ifndef CST_VERSION_H
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION "0.5.0"
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 9
|
||||
#define CST_VERSION_PATCH 8
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
#define CST_VERSION \
|
||||
CST_VERSION_STR(CST_VERSION_MAJOR) \
|
||||
"." CST_VERSION_STR(CST_VERSION_MINOR) "." CST_VERSION_STR(CST_VERSION_PATCH)
|
||||
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#ifndef CST_WEB_MD_H
|
||||
#define CST_WEB_MD_H
|
||||
|
||||
/* Markdown for the WebUI: the same sashidown renderer the TUI uses, but
|
||||
* HTML output with a strict allowlist.
|
||||
*
|
||||
* sashidown renders straight to HTML and escapes text, quotes and '&', so
|
||||
* no raw markup can reach the browser. Two holes remain, and both matter
|
||||
* because every byte rendered here comes from a model or a tool: a link's
|
||||
* href keeps any scheme ("javascript:") and an image's src may point
|
||||
* anywhere (a tracking pixel, or worse). cst_web_md_* therefore rewrites
|
||||
* tags instead of passing them through: unknown tags are dropped, hrefs
|
||||
* are limited to http/https/mailto/relative, and images to the session's
|
||||
* own attachment endpoint.
|
||||
*
|
||||
* The streaming form renders confirmed HTML as the model talks, which is
|
||||
* the CST property the project is built on: nothing is emitted before it
|
||||
* is safe to emit. A tag split across two deltas is held back until its
|
||||
* '>' arrives. */
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Called with chunks of sanitized HTML; return non-zero to stop. */
|
||||
typedef int (*cst_web_md_emit_fn)(const char *html, size_t len, void *ud);
|
||||
|
||||
typedef struct cst_web_md cst_web_md_t;
|
||||
|
||||
cst_web_md_t *cst_web_md_new(cst_web_md_emit_fn emit, void *ud);
|
||||
/* Feed model output. Returns 0, or -1 on OOM. */
|
||||
int cst_web_md_feed(cst_web_md_t *s, const char *data, size_t len);
|
||||
/* Flush the pending input and close the renderer. */
|
||||
int cst_web_md_finish(cst_web_md_t *s);
|
||||
void cst_web_md_free(cst_web_md_t *s);
|
||||
|
||||
/* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */
|
||||
char *cst_web_md_html(const char *md, size_t len);
|
||||
/* One-shot: markdown -> a whole HTML document (doctype, charset, title, a
|
||||
* small stylesheet; no script and no external request), for a file handed to
|
||||
* the user. title is optional; NULL or empty means "Document". */
|
||||
char *cst_web_md_document(const char *md, size_t len, const char *title);
|
||||
/* The sanitizer alone (tests, and any HTML that did not come from here). */
|
||||
char *cst_web_md_sanitize(const char *html, size_t len);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_WEB_MD_H */
|
||||
@@ -0,0 +1,44 @@
|
||||
#ifndef CST_WEBUI_H
|
||||
#define CST_WEBUI_H
|
||||
|
||||
/* The built-in WebUI: an HTTP + SSE front end for one agent process.
|
||||
*
|
||||
* It is a second implementation of the same interface the TUI uses
|
||||
* (cst_agent_hooks_t), which is why the engine needs no changes: the turn
|
||||
* runs on a worker thread and every live event goes out as a Server-Sent
|
||||
* Event. The protocol is documented in docs/webui.md and is meant to be
|
||||
* the contract a dedicated client speaks later. */
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* What --web listens on when --web-port/CST_WEB_PORT says nothing (the
|
||||
* README and --help quote this number, so keep them in sync). */
|
||||
#define CST_WEBUI_DEFAULT_PORT 8787
|
||||
|
||||
typedef struct {
|
||||
const char *host; /* NULL = 127.0.0.1; "0.0.0.0" = every interface */
|
||||
int port; /* 0 = pick a free port */
|
||||
const char *token; /* NULL = generate one when needed */
|
||||
const char *root; /* serve the UI from this directory instead of the
|
||||
copy embedded in the binary (development) */
|
||||
int open_browser; /* best-effort xdg-open / open / start */
|
||||
/* Prompt to run once at startup (--web -p ...); NULL = wait for the UI. */
|
||||
const char *prompt;
|
||||
int perm_timeout_s; /* how long a permission question waits (0 = 600) */
|
||||
} cst_webui_opts_t;
|
||||
|
||||
/* Serve until interrupted (Ctrl+C) or the event stream asks to quit.
|
||||
* Returns the process exit code. */
|
||||
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_WEBUI_H */
|
||||
@@ -2,6 +2,7 @@
|
||||
#define CST_OS_COMPAT_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
@@ -12,6 +13,13 @@ extern "C" {
|
||||
#define OS_PATH_MAX 4096
|
||||
#endif
|
||||
|
||||
/* --- text --- */
|
||||
/* One line of a file into *lineptr, growing it as needed: POSIX getline(3),
|
||||
* which a MinGW CRT does not have (the session file readers used it directly
|
||||
* and only the Windows build noticed). Returns the number of bytes read -- 0
|
||||
* for an empty line -- or -1 at the end of the file. */
|
||||
long long os_getline(char **lineptr, size_t *n, FILE *stream);
|
||||
|
||||
/* --- path --- */
|
||||
int os_path_is_abs(const char *p);
|
||||
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
|
||||
@@ -22,11 +30,36 @@ int os_realpath(const char *path, char *out, size_t out_sz);
|
||||
/* True if path is under root (after realpath/norm). Trailing slash rules apply. */
|
||||
int os_path_under(const char *root, const char *path);
|
||||
|
||||
/* --- directory listing / file type --- */
|
||||
typedef struct os_dir os_dir_t;
|
||||
typedef struct {
|
||||
const char *name; /* UTF-8; valid until the next os_dir_next */
|
||||
int is_dir;
|
||||
int is_link; /* symlink, or a junction on Windows (not followed) */
|
||||
} os_dirent_t;
|
||||
os_dir_t *os_dir_open(const char *path);
|
||||
/* Next entry, skipping "." and ".."; NULL at the end. */
|
||||
const os_dirent_t *os_dir_next(os_dir_t *d);
|
||||
void os_dir_close(os_dir_t *d);
|
||||
|
||||
typedef struct {
|
||||
int is_dir, is_reg, is_link;
|
||||
long long size;
|
||||
double mtime; /* seconds since the epoch */
|
||||
} os_stat_t;
|
||||
/* Like lstat(2): a link is reported as a link, not followed. */
|
||||
int os_lstat(const char *path, os_stat_t *st);
|
||||
/* Replace `to` if it exists (atomic where the OS allows). */
|
||||
int os_rename_replace(const char *from, const char *to);
|
||||
|
||||
/* --- dirs / config --- */
|
||||
int os_mkdir(const char *path, int mode);
|
||||
int os_mkdir_p(const char *path, int mode);
|
||||
int os_config_dir(char *out, size_t out_sz);
|
||||
int os_temp_dir(char *out, size_t out_sz);
|
||||
/* Point TMPDIR at os_temp_dir() so child processes use the same writable
|
||||
* directory. Call once at startup, before any threads. */
|
||||
void os_temp_dir_export(void);
|
||||
/* Create unique temp file; writes path into out; returns fd or -1. Caller closes. */
|
||||
int os_mkstemp(char *out, size_t out_sz, const char *prefix);
|
||||
|
||||
@@ -67,9 +100,32 @@ int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out,
|
||||
os_run_result_t *r, os_child_hook_fn child_hook,
|
||||
void *ud);
|
||||
|
||||
/* Child processes get stdin=/dev/null, their own process group, and are
|
||||
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
|
||||
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
|
||||
#ifdef _WIN32
|
||||
/* Windows: run under `token` (a primary token, e.g. the Low-IL sandbox
|
||||
* token; NULL = ours) with env_add "KEY=VALUE" entries layered on the
|
||||
* inherited environment. Every child runs in a kill-on-close Job Object, so
|
||||
* timeouts and cancels end the whole process tree. */
|
||||
int os_run_capture_token(const char *cwd, const char *const argv[],
|
||||
size_t max_out, os_run_result_t *r, void *token,
|
||||
const char *const env_add[]);
|
||||
/* os_run_shell_c under token, with env_add. */
|
||||
int os_run_shell_token(const char *cwd, const char *cmd, size_t max_out,
|
||||
os_run_result_t *r, void *token,
|
||||
const char *const env_add[]);
|
||||
/* "Windows 10.0.26100 x86_64" (the real version, not the compat shim's). */
|
||||
void os_windows_version(char *out, size_t n);
|
||||
/* Sets the mandatory-label SACL, e.g. "S:(ML;OICI;NW;;;LW)"; 0 / -1. */
|
||||
int os_win_set_label(const char *path, const char *sddl);
|
||||
/* The explicit (not inherited) label as SDDL, "S:" when there is none. */
|
||||
int os_win_get_label(const char *path, char *out, size_t n);
|
||||
/* CF_UNICODE_TEXT clipboard (the console may not do OSC 52). */
|
||||
int os_clipboard_set(const char *utf8, size_t n);
|
||||
#endif
|
||||
|
||||
/* Child processes get stdin=/dev/null and their own process group. They run
|
||||
* with no time limit unless one is asked for (CST_SHELL_TIMEOUT seconds, or
|
||||
* os_set_run_timeout()); either way a non-zero *flag cancels them.
|
||||
* Exit code 124 = killed by that timeout, 130 = cancelled. */
|
||||
void os_set_cancel_flag(volatile int *flag);
|
||||
volatile int *os_get_cancel_flag(void);
|
||||
void os_set_run_timeout(int seconds); /* this thread; 0 = default */
|
||||
@@ -80,7 +136,7 @@ int os_get_run_timeout(void);
|
||||
* the inherited environment (NULL-terminated, may be NULL). */
|
||||
typedef struct {
|
||||
#ifdef _WIN32
|
||||
void *proc, *in_w, *out_r;
|
||||
void *proc, *in_w, *out_r, *job;
|
||||
#else
|
||||
int pid, in_fd, out_fd;
|
||||
#endif
|
||||
@@ -106,10 +162,53 @@ void os_run_parallel(os_task_fn fn, void *const args[], int n);
|
||||
/* Monotonic milliseconds (arbitrary epoch). */
|
||||
double os_now_ms(void);
|
||||
|
||||
/* --- text encoding ---
|
||||
* cst-agent is UTF-8 throughout. On Windows the manifest makes UTF-8 the
|
||||
* process code page; os_console_init() also switches the console to UTF-8
|
||||
* (restored at exit), sets a UTF-8 C locale and asks Python children for
|
||||
* UTF-8. Returns -1 when the process code page is not UTF-8 (pre-1903
|
||||
* Windows); no-op returning 0 elsewhere. */
|
||||
int os_console_init(void);
|
||||
/* Child output that is not valid UTF-8, decoded from the console/OEM/ANSI
|
||||
* code page: malloc'd UTF-8, or NULL when s is already UTF-8 (or not on
|
||||
* Windows). */
|
||||
char *os_output_to_utf8(const char *s, size_t n, size_t *out_len);
|
||||
#ifdef _WIN32
|
||||
#include <wchar.h>
|
||||
/* malloc'd conversions; NULL on invalid input or allocation failure */
|
||||
wchar_t *os_utf8_to_wide(const char *s);
|
||||
char *os_wide_to_utf8(const wchar_t *w);
|
||||
#endif
|
||||
|
||||
/* --- misc --- */
|
||||
void os_localtime(const time_t *t, struct tm *out);
|
||||
int os_getpid(void);
|
||||
int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */
|
||||
/* 0600; on Windows a Medium no-read-up label, so Low-IL children (the
|
||||
* shell sandbox) cannot read the file. */
|
||||
int os_chmod_private(const char *path);
|
||||
|
||||
/* --- UTF-8 --------------------------------------------------------------
|
||||
* Encode one code point as UTF-8, independent of the C locale.
|
||||
*
|
||||
* wcrtomb() follows setlocale(). On Linux the environment normally selects a
|
||||
* UTF-8 locale, but on Windows the C runtime maps setlocale(LC_ALL, "") to the
|
||||
* ANSI code page -- 936 (GBK) on a Chinese system -- so wcrtomb() hands back
|
||||
* GBK bytes where the protocol requires UTF-8, and the model receives
|
||||
* mojibake. Windows' C runtime has no ".UTF-8" locale to switch to.
|
||||
*
|
||||
* Returns the number of bytes written (1..4), or 0 if the code point cannot be
|
||||
* encoded. Surrogate halves (as they arrive on 16-bit wchar_t platforms for
|
||||
* non-BMP code points) return 0 and are skipped. */
|
||||
int os_utf8_encode(unsigned long cp, char out[4]);
|
||||
|
||||
/* Decode one UTF-8 sequence from s (at most avail bytes).
|
||||
* Returns the number of bytes consumed (1..4) and stores the code point in
|
||||
* *cp, or 0 if the bytes are not a valid, complete, non-overlong sequence.
|
||||
*
|
||||
* The counterpart of the encoder above, and needed for the same reason:
|
||||
* mbrtowc() also follows setlocale(), so on Windows it would decode UTF-8
|
||||
* text as GBK and the screen would show mojibake. */
|
||||
int os_utf8_decode(const char *s, size_t avail, unsigned long *cp);
|
||||
|
||||
/* --- libc gaps on Windows ------------------------------------------------
|
||||
* MinGW-w64 provides neither strcasestr() (a GNU extension) nor
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"
|
||||
xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="cst-agent" version="1.0.0.0"/>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,29 @@
|
||||
#include <winver.h>
|
||||
#include "cst_version.h"
|
||||
|
||||
1 24 "cst-agent.manifest"
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION CST_VERSION_MAJOR, CST_VERSION_MINOR, CST_VERSION_PATCH, 0
|
||||
PRODUCTVERSION CST_VERSION_MAJOR, CST_VERSION_MINOR, CST_VERSION_PATCH, 0
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904B0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "WHC"
|
||||
VALUE "FileDescription", "CST agent"
|
||||
VALUE "FileVersion", CST_VERSION
|
||||
VALUE "InternalName", "cst-agent"
|
||||
VALUE "OriginalFilename", "cst-agent.exe"
|
||||
VALUE "ProductName", "CST agent"
|
||||
VALUE "ProductVersion", CST_VERSION
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# Native Windows build from an MSYS2 UCRT64 shell (https://www.msys2.org/).
|
||||
# scripts/bootstrap_msys2.sh [repo-dir]
|
||||
# Installs the UCRT64 toolchain with pacman, builds the same pinned static
|
||||
# deps as the Linux cross build (curl with Schannel, ncurses with the Windows
|
||||
# console patches, busybox-w32 Unicode), then a static cst-agent.exe with the
|
||||
# UTF-8 manifest, runs the tests and packages dist/windows-x86_64/.
|
||||
# CST_SKIP_TESTS=1 skips ctest.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${MSYSTEM:-}" != UCRT64 ]]; then
|
||||
echo "run this from the MSYS2 UCRT64 shell: the UTF-8 manifest needs the" \
|
||||
"UCRT runtime (MINGW64 links msvcrt)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
||||
DIR="${1:-}"
|
||||
if [[ -z "$DIR" ]]; then
|
||||
if [[ -f CMakeLists.txt && -d src ]]; then
|
||||
DIR="$(pwd)"
|
||||
else
|
||||
DIR="CST-agent"
|
||||
fi
|
||||
fi
|
||||
|
||||
pacman -S --needed --noconfirm \
|
||||
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake \
|
||||
mingw-w64-ucrt-x86_64-ninja make git patch tar perl zip diffutils
|
||||
|
||||
if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
|
||||
echo "==> git clone $REPO_URL -> $DIR"
|
||||
git clone "$REPO_URL" "$DIR"
|
||||
fi
|
||||
cd "$DIR"
|
||||
|
||||
# third-party script: unprefixed native tools
|
||||
export MINGW_HOST= BUILD_CC=gcc
|
||||
[[ -f third_party/prefix-mingw/lib/libcurl.a ]] || scripts/build_third_party_mingw.sh curl
|
||||
[[ -f third_party/prefix-mingw/lib/libncursesw.a ]] || scripts/build_third_party_mingw.sh ncurses
|
||||
if [[ ! -f third_party/busybox/busybox.exe ]]; then
|
||||
scripts/build_third_party_mingw.sh busybox-w32 || scripts/fetch_busybox_w32.sh
|
||||
fi
|
||||
|
||||
echo "==> cmake (MSYS2 UCRT64, static, TUI)"
|
||||
cmake -S . -B build-msys2 -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \
|
||||
-DCST_WIN_STATIC=ON -DCST_BUILD_TUI=ON
|
||||
cmake --build build-msys2
|
||||
|
||||
if [[ -z "${CST_SKIP_TESTS:-}" ]]; then
|
||||
echo "==> ctest"
|
||||
cp -f third_party/busybox/busybox.exe build-msys2/
|
||||
ctest --test-dir build-msys2 --output-on-failure
|
||||
fi
|
||||
|
||||
echo "==> package"
|
||||
CST_WIN_BUILD=build-msys2 scripts/package_windows.sh
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot on a Linux host: cross-build Windows x86_64 release (MinGW).
|
||||
# Output: dist/windows-x86_64/ — copy that folder to a Windows machine.
|
||||
# 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 $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
|
||||
|
||||
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
||||
DIR="${1:-}"
|
||||
|
||||
if [[ -z "$DIR" ]]; then
|
||||
if [[ -f CMakeLists.txt && -d src ]]; then
|
||||
DIR="$(pwd)"
|
||||
@@ -13,36 +16,37 @@ if [[ -z "$DIR" ]]; then
|
||||
DIR="CST-agent"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
|
||||
echo "==> git clone $REPO_URL -> $DIR"
|
||||
git clone "$REPO_URL" "$DIR"
|
||||
fi
|
||||
cd "$DIR"
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "missing $1 (install mingw-w64 gcc cmake git)" >&2; exit 1; }; }
|
||||
need git; need cmake; need x86_64-w64-mingw32-gcc
|
||||
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
|
||||
|
||||
echo "==> third-party (curl Schannel + PDCurses + busybox-w32)"
|
||||
chmod +x scripts/*.sh 2>/dev/null || true
|
||||
./scripts/build_third_party_mingw.sh all
|
||||
need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; }
|
||||
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
|
||||
|
||||
echo "==> cmake Windows TUI"
|
||||
cmake -B build-win-tui \
|
||||
[[ -f third_party/prefix-mingw/lib/libcurl.a ]] || scripts/build_third_party_mingw.sh curl
|
||||
[[ -f third_party/prefix-mingw/lib/libncursesw.a ]] || scripts/build_third_party_mingw.sh ncurses
|
||||
[[ -f third_party/busybox/busybox.exe ]] || scripts/build_third_party_mingw.sh busybox-w32
|
||||
|
||||
echo "==> cmake (Windows, TUI)"
|
||||
cmake -S . -B build-win-tui -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
|
||||
-DCST_BUILD_TUI=ON \
|
||||
-DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \
|
||||
-DPDCurses_ROOT="$PWD/third_party/prefix-mingw"
|
||||
cmake --build build-win-tui -j"$(nproc 2>/dev/null || echo 4)"
|
||||
-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
|
||||
|
||||
echo "==> package"
|
||||
./scripts/package_windows.sh
|
||||
|
||||
echo
|
||||
echo "OK: dist/windows-x86_64/"
|
||||
echo "Copy that folder to Windows, then:"
|
||||
echo " set OPENAI_API_KEY=sk-..."
|
||||
echo " set OPENAI_BASE_URL=https://api.openai.com/v1"
|
||||
echo " set OPENAI_MODEL=gpt-4.1-mini"
|
||||
echo " cst-agent.exe --workspace ."
|
||||
echo "See docs/env_openai.md and docs/README-windows.md"
|
||||
scripts/package_windows.sh
|
||||
|
||||
@@ -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; }
|
||||