Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea38247161 | ||
|
|
15c6c4a02a | ||
|
|
f29c1000b6 | ||
|
|
41f4cf85ee | ||
|
|
8fcbd886c3 | ||
|
|
f30b4ede48 | ||
|
|
2602806aea | ||
|
|
814a31c9c8 | ||
|
|
f00aad9e3e | ||
|
|
5917c48bd0 | ||
|
|
2ccad623e4 | ||
|
|
67b9d6683f | ||
|
|
6d2e106e26 | ||
|
|
1b0f14533a | ||
|
|
7d183d5bba | ||
|
|
6e09f79666 | ||
|
|
00288108cd | ||
|
|
c329db504d | ||
|
|
de2eaf87f7 | ||
|
|
2f96b050d6 | ||
|
|
812e3ee265 | ||
|
|
1605d74f17 | ||
|
|
5900b53642 | ||
|
|
a3e115539e | ||
|
|
e610d70c30 | ||
|
|
9c6257fc96 | ||
|
|
be8d021480 | ||
|
|
e853abac5e | ||
|
|
de9ee28773 | ||
|
|
08ca613a0e | ||
|
|
5ee82f126d | ||
|
|
dade4072cf | ||
|
|
acd6f1d998 | ||
|
|
d971cb02b0 | ||
|
|
143dba203b | ||
|
|
235652604d | ||
|
|
60047ca5ce | ||
|
|
b7034baec4 | ||
|
|
db8cdd7dd1 | ||
|
|
81aeee7d1e | ||
|
|
a99f78134a | ||
|
|
8e5bdd0c9d | ||
|
|
f8030ec201 | ||
|
|
a34c74e453 | ||
|
|
9d7ad9433e | ||
|
|
4c69ef39ab | ||
|
|
da04913071 | ||
|
|
7b2e7a3dbd | ||
|
|
4e990c8e35 | ||
|
|
dae51e06c7 | ||
|
|
042d7f6999 | ||
|
|
2ffe74b37c |
@@ -0,0 +1,22 @@
|
|||||||
|
.git
|
||||||
|
result
|
||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
dist/
|
||||||
|
third_party/
|
||||||
|
backups/
|
||||||
|
reports/
|
||||||
|
**/*.o
|
||||||
|
**/*.a
|
||||||
|
**/cst-agent
|
||||||
|
tests/test_*
|
||||||
|
!tests/test_*.c
|
||||||
|
app/
|
||||||
|
.cursor/
|
||||||
|
*.apk
|
||||||
|
*.tar.gz
|
||||||
|
.gen/
|
||||||
|
vendor/sashidown/*.o
|
||||||
|
vendor/sashidown/*.a
|
||||||
|
vendor/cjson/*.o
|
||||||
|
vendor/monocypher/*.o
|
||||||
@@ -37,14 +37,14 @@ jobs:
|
|||||||
- name: Build TinyCC
|
- name: Build TinyCC
|
||||||
run: bash scripts/ci_tcc.sh
|
run: bash scripts/ci_tcc.sh
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)"
|
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)" hermit
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
|
||||||
- name: Smoke test
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
./cst-agent --help
|
./hermit --help
|
||||||
./cst-agent --workspace "$PWD" --list-sessions
|
./hermit --workspace "$PWD" --list-sessions
|
||||||
./cst-agent --demo-ctl --workspace .
|
./hermit --demo-ctl --workspace .
|
||||||
- name: WebUI end-to-end (recorded stream, no API key)
|
- name: WebUI end-to-end (recorded stream, no API key)
|
||||||
run: sh scripts/e2e_webui.sh
|
run: sh scripts/e2e_webui.sh
|
||||||
|
|
||||||
@@ -86,20 +86,31 @@ jobs:
|
|||||||
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
if [ "${{ matrix.arch }}" = aarch64 ]; then
|
||||||
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
$SUDO apt-get install -y --no-install-recommends qemu-user-static
|
||||||
fi
|
fi
|
||||||
|
- name: Cache musl toolchain + third_party prefix
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.HOME }}/.local/toolchains
|
||||||
|
/opt/*musl*
|
||||||
|
third_party/prefix-linux-${{ matrix.arch }}
|
||||||
|
third_party/src
|
||||||
|
key: linux-${{ matrix.arch }}-tp-v1-${{ hashFiles('scripts/build_third_party_linux.sh', 'scripts/ci_linux_toolchain.sh') }}
|
||||||
|
restore-keys: |
|
||||||
|
linux-${{ matrix.arch }}-tp-v1-
|
||||||
- name: Build (static, -Werror)
|
- name: Build (static, -Werror)
|
||||||
env:
|
env:
|
||||||
CST_LINUX_CHECK: ${{ matrix.check }}
|
CST_LINUX_CHECK: ${{ matrix.check }}
|
||||||
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
run: bash scripts/package_linux.sh ${{ matrix.arch }}
|
||||||
- name: Smoke test
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
|
d=$(ls -d dist/hermit-*-linux-${{ matrix.arch }})
|
||||||
file "$d/cst-agent"
|
file "$d/hermit"
|
||||||
run=""
|
run=""
|
||||||
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
|
||||||
$run "$d/cst-agent" --version
|
$run "$d/hermit" --version
|
||||||
$run "$d/cst-agent" --help >/dev/null
|
$run "$d/hermit" --help >/dev/null
|
||||||
$run "$d/cst-agent" --workspace "$PWD" --list-sessions
|
$run "$d/hermit" --workspace "$PWD" --list-sessions
|
||||||
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256
|
cat dist/hermit-*-linux-${{ matrix.arch }}.tar.gz.sha256
|
||||||
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
|
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
|
||||||
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
|
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
|
||||||
- name: Publish Gitea Release
|
- name: Publish Gitea Release
|
||||||
@@ -110,14 +121,7 @@ jobs:
|
|||||||
CST_GITEA: ${{ github.server_url }}
|
CST_GITEA: ${{ github.server_url }}
|
||||||
CST_REPO: ${{ github.repository }}
|
CST_REPO: ${{ github.repository }}
|
||||||
GITHUB_REF: ${{ github.ref }}
|
GITHUB_REF: ${{ github.ref }}
|
||||||
CI: "true"
|
run: bash scripts/ci_publish.sh dist/hermit-*-linux-${{ matrix.arch }}.tar.gz dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
|
||||||
run: |
|
|
||||||
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
|
||||||
if [[ -z "${GITEA_TOKEN}" ]]; then
|
|
||||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
bash scripts/release.sh dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
|
|
||||||
|
|
||||||
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
|
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
|
||||||
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
|
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
|
||||||
@@ -146,6 +150,16 @@ jobs:
|
|||||||
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
|
||||||
-o /tmp/ci_checkout.sh
|
-o /tmp/ci_checkout.sh
|
||||||
bash /tmp/ci_checkout.sh
|
bash /tmp/ci_checkout.sh
|
||||||
|
- name: Cache mingw third_party
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
third_party/prefix-mingw-x86_64
|
||||||
|
third_party/src
|
||||||
|
third_party/busybox
|
||||||
|
key: win-tp-v1-${{ hashFiles('scripts/build_third_party_mingw.sh', 'scripts/bootstrap_windows.sh') }}
|
||||||
|
restore-keys: |
|
||||||
|
win-tp-v1-
|
||||||
- name: Cross-build zip and installer
|
- name: Cross-build zip and installer
|
||||||
env:
|
env:
|
||||||
CST_WIN_CFLAGS: -Wall -Wextra -Werror
|
CST_WIN_CFLAGS: -Wall -Wextra -Werror
|
||||||
@@ -164,12 +178,6 @@ jobs:
|
|||||||
CST_GITEA: ${{ github.server_url }}
|
CST_GITEA: ${{ github.server_url }}
|
||||||
CST_REPO: ${{ github.repository }}
|
CST_REPO: ${{ github.repository }}
|
||||||
GITHUB_REF: ${{ github.ref }}
|
GITHUB_REF: ${{ github.ref }}
|
||||||
CI: "true"
|
|
||||||
run: |
|
run: |
|
||||||
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
|
bash scripts/ci_publish.sh dist/cst-agent-*-windows-x86_64.zip \
|
||||||
if [[ -z "${GITEA_TOKEN}" ]]; then
|
|
||||||
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
bash scripts/release.sh dist/cst-agent-*-windows-x86_64.zip \
|
|
||||||
dist/cst-agent-*-windows-x86_64-setup.exe
|
dist/cst-agent-*-windows-x86_64-setup.exe
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
*.o
|
*.o
|
||||||
|
*.d
|
||||||
cst-agent
|
cst-agent
|
||||||
cst-agent.exe
|
cst-agent.exe
|
||||||
|
libcstcore.a
|
||||||
tests/test_*
|
tests/test_*
|
||||||
!tests/test_*.c
|
!tests/test_*.c
|
||||||
session.jsonl
|
session.jsonl
|
||||||
@@ -28,6 +30,10 @@ third_party/busybox-src/
|
|||||||
third_party/prefix-mingw/
|
third_party/prefix-mingw/
|
||||||
third_party/prefix-linux-*/
|
third_party/prefix-linux-*/
|
||||||
third_party/build-linux-*/
|
third_party/build-linux-*/
|
||||||
|
# the Windows app package (scripts/package_windows_app.sh): MSYS2's Qt and the
|
||||||
|
# host tools it is built with, both fetched/unpacked by the script
|
||||||
|
third_party/msys-qt6/
|
||||||
|
third_party/prefix-qt6-host
|
||||||
third_party/prefix-android-*/
|
third_party/prefix-android-*/
|
||||||
third_party/build-android-*/
|
third_party/build-android-*/
|
||||||
third_party/src/
|
third_party/src/
|
||||||
@@ -49,3 +55,14 @@ app/android/local.properties
|
|||||||
|
|
||||||
# the engine as a static library (app/ links it; built by `make`)
|
# the engine as a static library (app/ links it; built by `make`)
|
||||||
libcstcore.a
|
libcstcore.a
|
||||||
|
|
||||||
|
# nix-build
|
||||||
|
result
|
||||||
|
hermit
|
||||||
|
.peer_probe.out
|
||||||
|
reports/
|
||||||
|
|
||||||
|
# Tauri desktop
|
||||||
|
apps/desktop/node_modules/
|
||||||
|
apps/desktop/dist/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ set(CJSON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/cjson")
|
|||||||
|
|
||||||
set(CST_CORE_SOURCES
|
set(CST_CORE_SOURCES
|
||||||
vendor/cjson/cJSON.c
|
vendor/cjson/cJSON.c
|
||||||
|
vendor/monocypher/monocypher.c
|
||||||
|
vendor/monocypher/monocypher-ed25519.c
|
||||||
src/util.c
|
src/util.c
|
||||||
src/ctl_parse.c
|
src/ctl_parse.c
|
||||||
src/os_compat.c
|
src/os_compat.c
|
||||||
@@ -117,6 +119,14 @@ set(CST_CORE_SOURCES
|
|||||||
src/plugins.c
|
src/plugins.c
|
||||||
src/mcp.c
|
src/mcp.c
|
||||||
src/subagent.c
|
src/subagent.c
|
||||||
|
src/runtime.c
|
||||||
|
src/worktree.c
|
||||||
|
src/realm.c
|
||||||
|
src/discover.c
|
||||||
|
src/peer_auth.c
|
||||||
|
src/parliament.c
|
||||||
|
src/parliament_daemon.c
|
||||||
|
src/parliament_cli.c
|
||||||
src/attach.c
|
src/attach.c
|
||||||
src/clipboard.c
|
src/clipboard.c
|
||||||
src/pdf_text.c
|
src/pdf_text.c
|
||||||
@@ -142,6 +152,7 @@ set(CST_CORE_SOURCES
|
|||||||
src/models.c
|
src/models.c
|
||||||
src/model_registry.c
|
src/model_registry.c
|
||||||
src/metrics.c
|
src/metrics.c
|
||||||
|
src/hermit_glyph.c
|
||||||
src/net.c
|
src/net.c
|
||||||
src/http_srv.c
|
src/http_srv.c
|
||||||
src/web_md.c
|
src/web_md.c
|
||||||
@@ -216,7 +227,12 @@ target_include_directories(cstcore PUBLIC
|
|||||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||||
"${SD_DIR}/include"
|
"${SD_DIR}/include"
|
||||||
"${CJSON_DIR}"
|
"${CJSON_DIR}"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/vendor/monocypher"
|
||||||
)
|
)
|
||||||
|
set_source_files_properties(
|
||||||
|
vendor/monocypher/monocypher.c
|
||||||
|
vendor/monocypher/monocypher-ed25519.c
|
||||||
|
PROPERTIES COMPILE_OPTIONS "-w")
|
||||||
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
|
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
|
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
|
||||||
@@ -242,6 +258,7 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_executable(cst-agent ${CST_SOURCES})
|
add_executable(cst-agent ${CST_SOURCES})
|
||||||
|
set_target_properties(cst-agent PROPERTIES OUTPUT_NAME hermit)
|
||||||
add_dependencies(cst-agent sashidown)
|
add_dependencies(cst-agent sashidown)
|
||||||
cst_win_exe(cst-agent)
|
cst_win_exe(cst-agent)
|
||||||
|
|
||||||
@@ -376,7 +393,8 @@ set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
|||||||
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
test_shell test_diff test_tools_fs test_tools_ext test_registry
|
||||||
test_undo test_config test_models test_model_registry test_sandbox
|
test_undo test_config test_models test_model_registry test_sandbox
|
||||||
test_keymap test_shell_compact test_skills
|
test_keymap test_shell_compact test_skills
|
||||||
test_plugins test_mcp test_subagent test_lowil
|
test_plugins test_mcp test_subagent test_runtime test_realm test_discover
|
||||||
|
test_peer_auth test_parliament test_lowil
|
||||||
test_win_utf8 test_os_dir test_tmpdir
|
test_win_utf8 test_os_dir test_tmpdir
|
||||||
test_tool_meta test_embed)
|
test_tool_meta test_embed)
|
||||||
if(CST_BUILD_WEBUI)
|
if(CST_BUILD_WEBUI)
|
||||||
@@ -384,17 +402,13 @@ if(CST_BUILD_WEBUI)
|
|||||||
list(APPEND _CST_CORE_TESTS test_webui)
|
list(APPEND _CST_CORE_TESTS test_webui)
|
||||||
endif()
|
endif()
|
||||||
foreach(t ${_CST_CORE_TESTS})
|
foreach(t ${_CST_CORE_TESTS})
|
||||||
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
# Link the shared cstcore archive instead of recompiling every core .c.
|
||||||
# The core now contains src/web_md.c, which needs sashidown's header and
|
add_executable(${t} tests/${t}.c)
|
||||||
# archive exactly like the Makefile build does.
|
|
||||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
|
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
|
||||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||||
target_link_libraries(${t} PRIVATE
|
target_link_libraries(${t} PRIVATE cstcore)
|
||||||
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
|
|
||||||
add_dependencies(${t} sashidown)
|
add_dependencies(${t} sashidown)
|
||||||
if(NOT WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(${t} PRIVATE m)
|
|
||||||
else()
|
|
||||||
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
|
||||||
endif()
|
endif()
|
||||||
cst_win_exe(${t})
|
cst_win_exe(${t})
|
||||||
@@ -414,9 +428,22 @@ endif()
|
|||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
install(TARGETS cst-agent RUNTIME DESTINATION .)
|
install(TARGETS cst-agent RUNTIME DESTINATION .)
|
||||||
|
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf" DESTINATION .)
|
||||||
|
endif()
|
||||||
else()
|
else()
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||||
|
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit)
|
||||||
|
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/fonts/hermit)
|
||||||
|
endif()
|
||||||
|
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames")
|
||||||
|
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames/"
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit/frames)
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
|
message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# 从 CST-Agent 迁移到 Hermit
|
||||||
|
|
||||||
|
**本仓库 `WHC/CST-agent` 保留为兼容入口。** 产品名与规范克隆地址已改为:
|
||||||
|
|
||||||
|
**https://git.whcself.xyz/WHC/Hermit**
|
||||||
|
|
||||||
|
二进制、配置与环境变量自 **0.10.0** 起以 Hermit 为准;`CST-agent` 仓库与旧包名仅作过渡。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 换源码远程(推荐)
|
||||||
|
|
||||||
|
已有检出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 一键(在仓库根目录)
|
||||||
|
./scripts/migrate_remote_to_hermit.sh
|
||||||
|
|
||||||
|
# 或手动
|
||||||
|
git remote set-url origin https://git.whcself.xyz/WHC/Hermit.git
|
||||||
|
git fetch origin
|
||||||
|
git branch -u origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
新克隆:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.whcself.xyz/WHC/Hermit.git
|
||||||
|
cd Hermit
|
||||||
|
make -j$(nproc) && make install PREFIX=$HOME/.local
|
||||||
|
```
|
||||||
|
|
||||||
|
旧地址仍可拉代码一段时间:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.whcself.xyz/WHC/CST-agent.git # 兼容;请改 origin 到 Hermit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 二进制与 PATH
|
||||||
|
|
||||||
|
| 旧 | 新 |
|
||||||
|
|---|---|
|
||||||
|
| `cst-agent` | `hermit`(安装时仍提供 `cst-agent` → `hermit` 软链) |
|
||||||
|
| `cst-agent-update` | → `hermit-update`(软链;议会助手 → `hermit-service`) |
|
||||||
|
| Release `cst-agent-VERSION-linux-*.tar.gz` | `hermit-VERSION-linux-*.tar.gz`(过渡期可能双发) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermit --version # hermit 1.0.x
|
||||||
|
hermit-update # 升级;兼容名 cst-agent-update
|
||||||
|
cst-agent --version # 同二进制(软链)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 配置目录
|
||||||
|
|
||||||
|
| 旧 | 新 |
|
||||||
|
|---|---|
|
||||||
|
| `~/.config/cst-agent/` | `~/.config/hermit/` |
|
||||||
|
| Windows `%APPDATA%\cst-agent` | `%APPDATA%\hermit` |
|
||||||
|
|
||||||
|
程序若发现 **仅有** 旧目录、尚无新目录,会继续读旧路径。建议主动迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
mkdir -p ~/.config/hermit
|
||||||
|
# 若尚未迁过:
|
||||||
|
cp -a ~/.config/cst-agent/. ~/.config/hermit/ 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
工作区 `.agent/` 会话目录**不变**,无需改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 环境变量
|
||||||
|
|
||||||
|
`HERMIT_*` 优先;未设置时仍读 `CST_*`。
|
||||||
|
|
||||||
|
| 用途 | 新 | 旧(兼容) |
|
||||||
|
|---|---|---|
|
||||||
|
| Provider / Key / Model | `HERMIT_PROVIDER` 等 | `CST_PROVIDER` 等 |
|
||||||
|
| ASCII / Icons | `HERMIT_ASCII` / `HERMIT_ICONS` | `CST_ASCII` / `CST_ICONS` |
|
||||||
|
| 议会密码等 | `HERMIT_REALM_PASSWORD`(若已接双读) | `CST_REALM_PASSWORD` |
|
||||||
|
|
||||||
|
systemd:`EnvironmentFile` 已同时尝试 `~/.config/hermit/parliament.env` 与旧路径。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CI / 脚本 / 文档链接
|
||||||
|
|
||||||
|
- 安装脚本、bootstrap、文档中的规范 URL 改为 `…/WHC/Hermit`。
|
||||||
|
- 本文件与 README 顶栏是 **CST-agent 仓库上的最后一次迁移说明**;后续功能请在 **Hermit** 仓库跟进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 核对清单
|
||||||
|
|
||||||
|
- [ ] `git remote -v` 指向 `WHC/Hermit.git`
|
||||||
|
- [ ] `hermit --version` 正常;PATH 上可选保留 `cst-agent` 软链
|
||||||
|
- [ ] `~/.config/hermit/` 有 credentials / settings(或确认仍在用旧目录回退)
|
||||||
|
- [ ] 书签 / 镜像 / CI 的 clone URL 已更新
|
||||||
|
- [ ](可选)归档或只读化 `CST-agent` 仓库说明,避免双主开发
|
||||||
@@ -2,6 +2,7 @@ CC ?= gcc
|
|||||||
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
|
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
|
||||||
SD_DIR := vendor/sashidown
|
SD_DIR := vendor/sashidown
|
||||||
CJSON_DIR := vendor/cjson
|
CJSON_DIR := vendor/cjson
|
||||||
|
MONO_DIR := vendor/monocypher
|
||||||
PKG_CONFIG ?= pkg-config
|
PKG_CONFIG ?= pkg-config
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
DESTDIR ?=
|
DESTDIR ?=
|
||||||
@@ -9,6 +10,16 @@ UNAME_S := $(shell uname -s 2>/dev/null)
|
|||||||
UNAME_O := $(shell uname -o 2>/dev/null)
|
UNAME_O := $(shell uname -o 2>/dev/null)
|
||||||
comma := ,
|
comma := ,
|
||||||
|
|
||||||
|
# Optional ccache: USE_CCACHE=1 forces it; USE_CCACHE=0 disables; else auto.
|
||||||
|
USE_CCACHE ?= auto
|
||||||
|
ifeq ($(USE_CCACHE),1)
|
||||||
|
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
|
||||||
|
else ifeq ($(USE_CCACHE),auto)
|
||||||
|
ifneq ($(findstring ccache,$(CC)),ccache)
|
||||||
|
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
# Android (Termux): pthreads live in libc.
|
# Android (Termux): pthreads live in libc.
|
||||||
PTHREAD_LIBS := -lpthread
|
PTHREAD_LIBS := -lpthread
|
||||||
ifeq ($(UNAME_O),Android)
|
ifeq ($(UNAME_O),Android)
|
||||||
@@ -60,6 +71,10 @@ CST_WEBUI ?= 1
|
|||||||
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
|
||||||
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
|
||||||
|
|
||||||
|
# Upstream Monocypher: keep -Werror builds green without patching the vendor.
|
||||||
|
$(MONO_DIR)/monocypher.o $(MONO_DIR)/monocypher-ed25519.o: %.o: %.c
|
||||||
|
$(CC) $(CFLAGS) $(CPPFLAGS) -w -c -o $@ $<
|
||||||
|
|
||||||
# tcc writes no rpath of its own, so shared libs found via pkg-config -L
|
# 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.
|
# (Nix store, /opt prefixes) would not load at run time.
|
||||||
RPATH_LIBS :=
|
RPATH_LIBS :=
|
||||||
@@ -70,7 +85,8 @@ endif
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
# Keep POSIX declarations visible for shared test helpers (notably symlink()).
|
# 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)
|
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -I$(MONO_DIR) \
|
||||||
|
-D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
|
||||||
LDFLAGS += -L$(SD_DIR)
|
LDFLAGS += -L$(SD_DIR)
|
||||||
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
|
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
|
||||||
|
|
||||||
@@ -87,6 +103,8 @@ endif
|
|||||||
|
|
||||||
CORE_SRC = \
|
CORE_SRC = \
|
||||||
$(CJSON_DIR)/cJSON.c \
|
$(CJSON_DIR)/cJSON.c \
|
||||||
|
$(MONO_DIR)/monocypher.c \
|
||||||
|
$(MONO_DIR)/monocypher-ed25519.c \
|
||||||
src/util.c \
|
src/util.c \
|
||||||
src/ctl_parse.c \
|
src/ctl_parse.c \
|
||||||
src/os_compat.c \
|
src/os_compat.c \
|
||||||
@@ -97,7 +115,8 @@ CORE_SRC = \
|
|||||||
src/skills.c \
|
src/skills.c \
|
||||||
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
|
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
|
||||||
src/pdf_text.c \
|
src/pdf_text.c \
|
||||||
src/session.c \
|
src/session.c src/runtime.c src/worktree.c src/realm.c src/discover.c \
|
||||||
|
src/peer_auth.c src/parliament.c src/parliament_daemon.c src/parliament_cli.c \
|
||||||
src/tools/tools.c \
|
src/tools/tools.c \
|
||||||
src/tools/patch.c \
|
src/tools/patch.c \
|
||||||
src/tools/registry.c \
|
src/tools/registry.c \
|
||||||
@@ -117,6 +136,7 @@ CORE_SRC = \
|
|||||||
src/mm_tool.c \
|
src/mm_tool.c \
|
||||||
src/models.c src/model_registry.c \
|
src/models.c src/model_registry.c \
|
||||||
src/metrics.c \
|
src/metrics.c \
|
||||||
|
src/hermit_glyph.c \
|
||||||
$(WEB_SRC)
|
$(WEB_SRC)
|
||||||
|
|
||||||
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
|
||||||
@@ -125,8 +145,9 @@ OBJ = $(SRC:.c=.o)
|
|||||||
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
|
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
|
||||||
CORE_OBJ = $(CORE_SRC:.c=.o)
|
CORE_OBJ = $(CORE_SRC:.c=.o)
|
||||||
CST_CORE_LIB = libcstcore.a
|
CST_CORE_LIB = libcstcore.a
|
||||||
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
|
# Generated embeds; per-file .d files (from -MMD) track the rest.
|
||||||
.gen/models_builtin.inc .gen/web_assets.inc
|
GEN_INCS = .gen/models_builtin.inc .gen/web_assets.inc
|
||||||
|
DEPFILES = $(OBJ:.o=.d)
|
||||||
|
|
||||||
TESTS = \
|
TESTS = \
|
||||||
tests/test_ctl_frontier \
|
tests/test_ctl_frontier \
|
||||||
@@ -164,6 +185,12 @@ TESTS = \
|
|||||||
tests/test_plugins \
|
tests/test_plugins \
|
||||||
tests/test_mcp \
|
tests/test_mcp \
|
||||||
tests/test_subagent \
|
tests/test_subagent \
|
||||||
|
tests/test_runtime \
|
||||||
|
tests/test_prompt_contract \
|
||||||
|
tests/test_realm \
|
||||||
|
tests/test_discover \
|
||||||
|
tests/test_peer_auth \
|
||||||
|
tests/test_parliament \
|
||||||
tests/test_lowil \
|
tests/test_lowil \
|
||||||
tests/test_win_utf8 \
|
tests/test_win_utf8 \
|
||||||
tests/test_os_dir \
|
tests/test_os_dir \
|
||||||
@@ -178,35 +205,53 @@ 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
|
# 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.
|
# 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
|
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
|
||||||
|
# hermit_getenv is in os_compat.c (HERMIT_/CST_ dual env).
|
||||||
|
|
||||||
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
|
.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install \
|
||||||
|
uninstall install-service help hermit-font cst-agent
|
||||||
|
|
||||||
all: sashidown-lib cst-agent $(CST_CORE_LIB)
|
all: sashidown-lib hermit $(CST_CORE_LIB)
|
||||||
|
|
||||||
$(CST_CORE_LIB): $(CORE_OBJ) FORCE
|
# Compat alias: many scripts still say `make cst-agent`.
|
||||||
|
cst-agent: hermit
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Hermit build targets (prefer: make -j\$$(nproc))"
|
||||||
|
@echo " all / hermit incremental build (ccache auto if installed)"
|
||||||
|
@echo " check build + run unit tests"
|
||||||
|
@echo " asan AddressSanitizer rebuild + check"
|
||||||
|
@echo " hermit-font bake PNG frames + hermit-icons.ttf (Pillow+fonttools)"
|
||||||
|
@echo " install PREFIX=$(PREFIX); hermit + font + cst-agent symlink"
|
||||||
|
@echo " install-service systemd --user parliament unit"
|
||||||
|
@echo "Env: USE_CCACHE=0|1|auto STATIC=1 CST_WEBUI=0 CC=tcc"
|
||||||
|
@echo " HERMIT_ICONS=auto|mini|font HERMIT_*/CST_* env"
|
||||||
|
@echo "Release: scripts/package_linux.sh ARCH | scripts/ci_windows_cross.sh"
|
||||||
|
@echo "Fast install: scripts/bootstrap_linux.sh --release"
|
||||||
|
|
||||||
|
$(CST_CORE_LIB): $(CORE_OBJ)
|
||||||
rm -f $@
|
rm -f $@
|
||||||
$(AR) rcs $@ $(CORE_OBJ)
|
$(AR) rcs $@ $(CORE_OBJ)
|
||||||
|
|
||||||
cmake-gcc:
|
cmake-gcc:
|
||||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
||||||
cmake --build build-gcc
|
cmake --build build-gcc -j
|
||||||
ctest --test-dir build-gcc --output-on-failure
|
ctest --test-dir build-gcc --output-on-failure -j
|
||||||
|
|
||||||
cmake-clang:
|
cmake-clang:
|
||||||
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
|
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
|
||||||
cmake --build build-clang
|
cmake --build build-clang -j
|
||||||
|
|
||||||
sashidown-lib: $(SD_DIR)/libsashidown.a
|
# Always ask the sub-make; it no-ops when libsashidown.a is current.
|
||||||
|
sashidown-lib:
|
||||||
# The sub-make decides whether the archive is stale; FORCE only makes sure it
|
|
||||||
# is asked, and -j builds wait for it before linking.
|
|
||||||
$(SD_DIR)/libsashidown.a: FORCE
|
|
||||||
$(MAKE) -C $(SD_DIR) libsashidown.a
|
$(MAKE) -C $(SD_DIR) libsashidown.a
|
||||||
|
|
||||||
FORCE:
|
hermit: $(OBJ) sashidown-lib
|
||||||
|
|
||||||
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
|
||||||
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
|
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
|
||||||
|
@ln -sfr $@ cst-agent 2>/dev/null || ln -sf hermit cst-agent
|
||||||
|
|
||||||
|
hermit-font:
|
||||||
|
@python3 scripts/build_hermit_font.py || \
|
||||||
|
echo "hermit-font: skipped (need Pillow+fonttools; try: nix-shell -p python3Packages.pillow python3Packages.fonttools)"
|
||||||
|
|
||||||
# Built-in model data as a C byte list (included by model_registry.c).
|
# Built-in model data as a C byte list (included by model_registry.c).
|
||||||
.gen/models_builtin.inc: src/models_builtin.json
|
.gen/models_builtin.inc: src/models_builtin.json
|
||||||
@@ -218,11 +263,15 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
|||||||
@mkdir -p .gen
|
@mkdir -p .gen
|
||||||
@sh scripts/embed_web.sh $@ $(WEB_DIR)
|
@sh scripts/embed_web.sh $@ $(WEB_DIR)
|
||||||
|
|
||||||
%.o: %.c $(HDRS)
|
# Incremental rebuilds: -MMD tracks real includes instead of every header.
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
COMPILE_C = $(CC) $(CFLAGS) $(CPPFLAGS) -MMD -MP -c -o $@ $<
|
||||||
|
|
||||||
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h
|
%.o: %.c
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
|
$(COMPILE_C)
|
||||||
|
|
||||||
|
src/model_registry.o: .gen/models_builtin.inc
|
||||||
|
src/webui.o: .gen/web_assets.inc
|
||||||
|
src/tui.o: $(SD_DIR)/include/sashidown.h
|
||||||
|
|
||||||
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
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)
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
|
||||||
@@ -230,7 +279,7 @@ tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
|
|||||||
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
|
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)
|
$(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) $(TEST_OS_SRC)
|
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(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)
|
$(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 $(TEST_OS_SRC)
|
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
|
||||||
@@ -251,32 +300,110 @@ tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TE
|
|||||||
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
|
||||||
|
|
||||||
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
|
# Core tests reuse libcstcore.a — avoid recompiling ~35 .c files per binary.
|
||||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
# Do not depend on the phony sashidown-lib target or every test relinks each check.
|
||||||
|
tests/test_%: tests/test_%.c $(CST_CORE_LIB) $(SD_DIR)/libsashidown.a
|
||||||
|
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CST_CORE_LIB) $(TEST_LIBS)
|
||||||
|
|
||||||
$(TESTS): sashidown-lib
|
$(SD_DIR)/libsashidown.a: sashidown-lib
|
||||||
|
|
||||||
check: $(TESTS)
|
# Build test binaries with make -j; run them one-by-one for readable logs.
|
||||||
|
check: sashidown-lib $(TESTS)
|
||||||
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
||||||
|
|
||||||
# AddressSanitizer + UBSan build of the agent and all tests.
|
# AddressSanitizer + UBSan rebuild (clears objects so sanitizer flags apply).
|
||||||
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
|
|
||||||
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
|
|
||||||
asan:
|
asan:
|
||||||
$(MAKE) clean
|
$(MAKE) clean
|
||||||
$(MAKE) sashidown-lib
|
$(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" \
|
||||||
$(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" cst-agent check
|
sashidown-lib hermit check
|
||||||
|
|
||||||
demo: cst-agent
|
demo: hermit
|
||||||
./cst-agent --demo-ctl --workspace .
|
./hermit --demo-ctl --workspace .
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \
|
rm -f $(OBJ) $(DEPFILES) hermit cst-agent $(CST_CORE_LIB) $(TESTS) \
|
||||||
.gen/web_assets.inc
|
.gen/models_builtin.inc .gen/web_assets.inc
|
||||||
|
|
||||||
install: cst-agent
|
# Absolute checkout path for hermit-update (survives cd). Override with HERMIT_SRC.
|
||||||
|
HERMIT_SRC ?= $(abspath .)
|
||||||
|
|
||||||
|
install: hermit
|
||||||
install -d "$(DESTDIR)$(PREFIX)/bin"
|
install -d "$(DESTDIR)$(PREFIX)/bin"
|
||||||
install -m 0755 cst-agent "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
install -m 0755 hermit "$(DESTDIR)$(PREFIX)/bin/hermit"
|
||||||
|
ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent" 2>/dev/null || \
|
||||||
|
ln -sf hermit "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||||
|
@# hermit-update / hermit-service (cst-* compat symlinks)
|
||||||
|
@printf '%s\n' '#!/usr/bin/env bash' \
|
||||||
|
'export HERMIT_PREFIX="$${HERMIT_PREFIX:-$${CST_AGENT_PREFIX:-$(PREFIX)}}"' \
|
||||||
|
'export CST_AGENT_PREFIX="$$HERMIT_PREFIX"' \
|
||||||
|
'export HERMIT_DIR="$${HERMIT_DIR:-$${CST_AGENT_DIR:-$(HERMIT_SRC)}}"' \
|
||||||
|
'export CST_AGENT_DIR="$$HERMIT_DIR"' \
|
||||||
|
'exec bash "$$HERMIT_DIR/scripts/bootstrap_linux.sh" "$$HERMIT_DIR" "$$@"' \
|
||||||
|
> "$(DESTDIR)$(PREFIX)/bin/hermit-update"
|
||||||
|
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-update"
|
||||||
|
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update" 2>/dev/null || \
|
||||||
|
ln -sf hermit-update "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
|
||||||
|
@printf '%s\n' '#!/usr/bin/env bash' \
|
||||||
|
'set -euo pipefail' \
|
||||||
|
'BIN="$(PREFIX)/bin/hermit"' \
|
||||||
|
'cmd="$${1:-status}"' \
|
||||||
|
'case "$$cmd" in' \
|
||||||
|
'status) "$$BIN" --version; if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active hermit-parliament.service >/dev/null 2>&1; then systemctl --user status hermit-parliament.service --no-pager; elif command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then systemctl --user status cst-agent-parliament.service --no-pager; else "$$BIN" --service-stop >/dev/null 2>&1 || true; echo "parliament service: not managed by systemd --user"; fi ;;' \
|
||||||
|
'install-unit) make -C "$(HERMIT_SRC)" install-service PREFIX="$(PREFIX)" ;;' \
|
||||||
|
'start) systemctl --user start hermit-parliament.service 2>/dev/null || systemctl --user start cst-agent-parliament.service ;;' \
|
||||||
|
'stop) systemctl --user stop hermit-parliament.service 2>/dev/null || true; systemctl --user stop cst-agent-parliament.service 2>/dev/null || true; "$$BIN" --service-stop || true ;;' \
|
||||||
|
'restart) systemctl --user restart hermit-parliament.service 2>/dev/null || systemctl --user restart cst-agent-parliament.service ;;' \
|
||||||
|
'*) echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2; exit 2 ;;' \
|
||||||
|
'esac' \
|
||||||
|
> "$(DESTDIR)$(PREFIX)/bin/hermit-service"
|
||||||
|
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-service"
|
||||||
|
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service" 2>/dev/null || \
|
||||||
|
ln -sf hermit-service "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
|
||||||
|
@if [ ! -f res/hermit-icons.ttf ]; then $(MAKE) hermit-font || true; fi
|
||||||
|
@if [ -f res/hermit-icons.ttf ]; then \
|
||||||
|
install -d "$(DESTDIR)$(PREFIX)/share/hermit"; \
|
||||||
|
install -d "$(DESTDIR)$(PREFIX)/share/fonts/hermit"; \
|
||||||
|
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"; \
|
||||||
|
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
|
||||||
|
if [ -n "$(HOME)" ] && [ "$(DESTDIR)" = "" ]; then \
|
||||||
|
install -d "$(HOME)/.local/share/fonts"; \
|
||||||
|
install -m 0644 res/hermit-icons.ttf "$(HOME)/.local/share/fonts/hermit-icons.ttf"; \
|
||||||
|
command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$(HOME)/.local/share/fonts" >/dev/null 2>&1 || true; \
|
||||||
|
fi; \
|
||||||
|
echo "installed Hermit Icons → $(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
|
||||||
|
else \
|
||||||
|
echo "warning: res/hermit-icons.ttf missing (run: make hermit-font)" >&2; \
|
||||||
|
fi
|
||||||
|
@if [ -d res/hermit-frames ]; then \
|
||||||
|
install -d "$(DESTDIR)$(PREFIX)/share/hermit/frames"; \
|
||||||
|
cp -a res/hermit-frames/. "$(DESTDIR)$(PREFIX)/share/hermit/frames/"; \
|
||||||
|
fi
|
||||||
|
@echo "installed hermit-update → $(PREFIX)/bin/hermit-update"
|
||||||
|
|
||||||
|
# Optional systemd --user unit for the parliament seat service.
|
||||||
|
install-service: install
|
||||||
|
install -d "$(DESTDIR)$(HOME)/.config/systemd/user"
|
||||||
|
sed -e 's|@BIN@|$(PREFIX)/bin/hermit|g' \
|
||||||
|
scripts/hermit-parliament.service.in \
|
||||||
|
> "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service"
|
||||||
|
ln -sfr "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service" \
|
||||||
|
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service" 2>/dev/null || \
|
||||||
|
ln -sf hermit-parliament.service \
|
||||||
|
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service"
|
||||||
|
@echo "Installed user unit. Enable with:"
|
||||||
|
@echo " systemctl --user daemon-reload"
|
||||||
|
@echo " systemctl --user enable --now hermit-parliament.service"
|
||||||
|
@echo "(set HERMIT_/CST_ REALM_PASSWORD / INSTANCE_ID in the drop-in or environment)"
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent"
|
||||||
|
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
|
||||||
|
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
|
||||||
|
rm -f "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"
|
||||||
|
rm -f "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"
|
||||||
|
rm -rf "$(DESTDIR)$(PREFIX)/share/hermit/frames"
|
||||||
|
rm -f "$(HOME)/.local/share/fonts/hermit-icons.ttf"
|
||||||
|
rm -f "$(HOME)/.config/systemd/user/hermit-parliament.service"
|
||||||
|
rm -f "$(HOME)/.config/systemd/user/cst-agent-parliament.service"
|
||||||
|
|
||||||
|
-include $(DEPFILES)
|
||||||
|
|||||||
@@ -1,519 +1,94 @@
|
|||||||
# CST-Agent
|
# Hermit
|
||||||
|
|
||||||
C11 OpenCode-like carrier that extends paper **CST** (confirmation frontier / irreversible emission) from Markdown→HTML to the **agent tool loop**.
|
> **仓库已更名。** 规范地址:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。
|
||||||
|
> 本仓库 `CST-agent` 仅作迁移兼容入口。换远程:`./scripts/migrate_remote_to_hermit.sh`
|
||||||
|
> 完整步骤:[MIGRATION.md](MIGRATION.md)。
|
||||||
|
|
||||||
See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
|
本地 coding agent(原 CST-Agent):终端 TUI、可选 WebUI / 桌面 GUI,在工作区里读改文件、跑命令、管会话。
|
||||||
评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
|
C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。
|
||||||
|
|
||||||
## 能不能用来工作?
|
**适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。
|
||||||
|
**慎用**:完全无人值守的长任务;headless 默认自动批准。
|
||||||
|
|
||||||
**可以**:有监督的本地改动 / 探索仓库(TUI + 13 个内置工具 + 带 diff 的权限确认 + `/undo`)。
|
文档索引:[docs/README.md](docs/README.md)。
|
||||||
**不宜**:无人值守长任务或当 CI 主力(网关偶发空答;headless 模式默认自动批准)。
|
|
||||||
|
|
||||||
### 5 分钟试用
|
兼容:安装后提供 `cst-agent` → `hermit` 软链;环境变量 `HERMIT_*` 优先,仍接受 `CST_*`;配置目录优先 `~/.config/hermit`,若无则回退 `~/.config/cst-agent`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### Linux / WSL / macOS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin
|
# 预编译包(快;失败则回退源码)— 规范仓库 Hermit
|
||||||
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash
|
curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash -s -- --release
|
||||||
|
|
||||||
# 已有仓库目录内(装到别处:CST_AGENT_PREFIX=/usr/local)
|
# 源码增量编译 → ~/.local/bin/hermit(+ cst-agent 软链)
|
||||||
|
curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
./scripts/bootstrap_linux.sh .
|
./scripts/bootstrap_linux.sh .
|
||||||
|
hermit --version # hermit 1.0.x
|
||||||
# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull)
|
hermit-update # 升级(源码或 --release);兼容 cst-agent-update
|
||||||
cst-agent-update
|
./scripts/migrate_remote_to_hermit.sh # 已有 CST-agent 检出时改 origin
|
||||||
cst-agent --version
|
|
||||||
|
|
||||||
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
|
|
||||||
cd ~/my-project && cst-agent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
不想编译:[Release](https://git.whcself.xyz/WHC/CST-agent/releases) 里有静态链接的 Linux 单文件
|
[Release](https://git.whcself.xyz/WHC/Hermit/releases)(旧 [CST-agent releases](https://git.whcself.xyz/WHC/CST-agent/releases) 过渡期可能仍有镜像):`hermit-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`。
|
||||||
(`cst-agent-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`,musl,任何发行版都能直接跑),解压后把 `cst-agent`
|
|
||||||
放进 `PATH` 即可;每个文件旁边有 `.sha256`。
|
|
||||||
|
|
||||||
Windows 10 1903+(PowerShell,不需要管理员):
|
### Windows
|
||||||
|
|
||||||
```powershell
|
见 [docs/README-windows.md](docs/README-windows.md)(产物名逐步改为 `hermit-…`;旧 `cst-agent-…` 包过渡期可能仍有)。
|
||||||
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` 编译。
|
|
||||||
|
|
||||||
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
|
|
||||||
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
|
|
||||||
|
|
||||||
完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。
|
|
||||||
|
|
||||||
| 平台 | 一键脚本 |
|
|
||||||
|---|---|
|
|
||||||
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
|
|
||||||
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
|
|
||||||
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
|
|
||||||
| 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/` |
|
|
||||||
|
|
||||||
### 迷你验收工程(论文案例)
|
|
||||||
|
|
||||||
故意带 bug 的小计算器:[`fixtures/mini_calc/`](fixtures/mini_calc/)(任务书 `TASK.md`)。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED
|
cd ~/my-project && hermit
|
||||||
./scripts/e2e_mini_calc.sh --provider deepseek --protocol ctl # 有 key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
更多夹具(每个都有 `TASK.md` + `check.sh`):`makefile_tab`(写含 TAB 的 Makefile)、
|
---
|
||||||
`big_file`(写 5000 字节文件)、`test_fix_test`(先跑测试→修→再跑测试)、
|
|
||||||
`multi_file_refactor`(跨文件改名 + 修 bug,适合 `edit_file` / `grep`)、
|
## 构建
|
||||||
`ask_or_todo`(必须用 `todo_write` 规划并全部完成;无人回答 `ask_user`)。
|
|
||||||
后两个需要 `--tools default`(脚本默认 `minimal`,即论文用的 4 个工具)。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/e2e_fixture.sh makefile_tab --provider deepseek --protocol native-inc
|
make -j$(nproc) # 产出 hermit(并链 cst-agent)
|
||||||
scripts/e2e_fixture.sh multi_file_refactor --provider deepseek --tools default
|
make -j$(nproc) check
|
||||||
# 批量实验:协议 x 模型 x 夹具 x N 次 → results.csv + summary.md
|
make hermit-font # 烘焙 PNG 帧 + hermit-icons.ttf(Pillow + fonttools)
|
||||||
scripts/run_matrix.sh -p "native-eos native-inc ctl ctl-heredoc xml" \
|
make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts
|
||||||
-m "deepseek:deepseek-flash deepseek:deepseek-v4-pro" -n 3 -r
|
|
||||||
```
|
```
|
||||||
|
|
||||||
口径:`pass` / `fail` / `agent_error` / `timeout` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
|
桌面 GUI(Tauri 2 + React,DeepSeek Harness 风格布局):见 [`apps/desktop`](apps/desktop/) 与 [docs/desktop.md](docs/desktop.md)。旧 Qt App(`app/`)已冻结。
|
||||||
|
|
||||||
## Build
|
TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。
|
||||||
|
|
||||||
### Make(Linux 默认)
|
---
|
||||||
|
|
||||||
|
## 用法速览
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd CST-agent
|
hermit # TUI
|
||||||
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses)
|
hermit --web # http://127.0.0.1:8787/
|
||||||
make check # 单元测试 + 截断实验(含真实录制流)
|
hermit --headless --prompt '…'
|
||||||
make asan # AddressSanitizer + UBSan 构建并跑全部测试
|
|
||||||
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
|
|
||||||
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
NixOS:`nix-shell`(仓库自带 [`shell.nix`](shell.nix))后再 `make`。
|
首页品牌 **HERMIT** + 寄居蟹色块动效;色板:brand `#277F94`、crab `#F68042`、eye `#9957AD`。
|
||||||
macOS:`brew install curl ncurses pkg-config` 后直接 `make`(Makefile 会找 Homebrew 的 keg-only 库)。
|
|
||||||
各平台细节与验收清单见 [docs/compat.md](docs/compat.md)。
|
|
||||||
|
|
||||||
### CMake(推荐跨编译器 / 交叉)
|
更多:Provider / 工具 / 议会 / WebUI 见 [docs/](docs/)。
|
||||||
|
|
||||||
```bash
|
---
|
||||||
# GCC
|
|
||||||
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
|
|
||||||
cmake --build build-gcc && ctest --test-dir build-gcc
|
|
||||||
|
|
||||||
# Clang
|
## CI 与发布
|
||||||
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
|
|
||||||
cmake --build build-clang
|
|
||||||
|
|
||||||
# Headless only(无 ncurses)
|
`.gitea/workflows/ci.yml`:TinyCC check → 打 Linux/Windows 包 → `scripts/ci_publish.sh`。
|
||||||
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
|
共用逻辑:[scripts/lib_release.sh](scripts/lib_release.sh)。
|
||||||
|
|
||||||
# Windows:用一键脚本(见下方“Windows 发行包”),会先编译静态依赖
|
## 开发者
|
||||||
./scripts/bootstrap_windows.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
|
|
||||||
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
|
|
||||||
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
|
|
||||||
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
|
|
||||||
|
|
||||||
## 平台支持矩阵
|
|
||||||
|
|
||||||
| OS | Arch | 编译器 | Headless | TUI | Shell |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 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 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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin
|
|
||||||
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash
|
|
||||||
|
|
||||||
# 以后升级(重跑上面那条也一样)
|
|
||||||
cst-agent-update
|
|
||||||
|
|
||||||
# 或手动
|
|
||||||
pkg install git clang make pkg-config binutils libcurl ncurses
|
|
||||||
git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent
|
|
||||||
make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录运行 cst-agent
|
|
||||||
# 可选:旁路 Unix busybox
|
|
||||||
# ./scripts/build_third_party_mingw.sh busybox-unix
|
|
||||||
```
|
|
||||||
|
|
||||||
环境:`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)(安装、编码、沙箱、构建、已知限制)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
|
|
||||||
`dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `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` 等。
|
|
||||||
|
|
||||||
## Defaults (API)
|
|
||||||
|
|
||||||
`--provider`(或 `CST_PROVIDER`)选择预设;显式的 `--api-base` / `--model` / 环境变量优先。
|
|
||||||
|
|
||||||
| provider | API Base | 默认模型 | Key 变量 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
|
||||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
|
||||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
|
||||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
|
||||||
|
|
||||||
| 用途 | 来源(优先级从高到低) |
|
|
||||||
|---|---|
|
|
||||||
| Provider | `--provider` → `CST_PROVIDER` → 上次使用 → `minimax` |
|
|
||||||
| Key | `--api-key` → 已保存的 key(`credentials`)→ provider 的 Key 变量 → `CST_API_KEY` → TUI 询问 |
|
|
||||||
| Base(含 `/v1`) | `--api-base` → `CST_API_BASE` → `OPENAI_BASE_URL` → `OPENAI_API_BASE` → 预设 |
|
|
||||||
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
|
||||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
|
|
||||||
|
|
||||||
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
|
|
||||||
推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。
|
|
||||||
|
|
||||||
详见 [docs/env_openai.md](docs/env_openai.md)。
|
|
||||||
|
|
||||||
| 变量 | 作用(默认) |
|
|
||||||
|---|---|
|
|
||||||
| `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` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
|
|
||||||
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
|
|
||||||
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
|
|
||||||
| `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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# TUI(工作目录 = 当前目录;Assistant Markdown 经 Sashidown CST 渲染)
|
|
||||||
cst-agent
|
|
||||||
|
|
||||||
# Local CTL demo (no API)
|
|
||||||
cst-agent --demo-ctl
|
|
||||||
|
|
||||||
# 在命令行输入并保存当前 provider 的 key(不回显)
|
|
||||||
cst-agent --ask-key --provider deepseek
|
|
||||||
|
|
||||||
# Headless(默认 auto-approve shell/写文件)
|
|
||||||
cst-agent --headless --prompt 'List files'
|
|
||||||
```
|
|
||||||
|
|
||||||
会话相关命令行参数:
|
|
||||||
|
|
||||||
| 参数 | 作用 |
|
|
||||||
|---|---|
|
|
||||||
| `-c` / `--continue` | 打开上次的会话(默认行为) |
|
|
||||||
| `-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>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
|
|
||||||
|
|
||||||
### 内置工具
|
|
||||||
|
|
||||||
| 工具 | 类别 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `read_file` | 读 | 支持 `offset` / `limit` 分段读和行号 |
|
|
||||||
| `list_dir` | 读 | 目录树(`depth`),遵守 `.gitignore`,跳过 `.git` / `node_modules` 等 |
|
|
||||||
| `glob` | 读 | 按通配符找文件(支持 `**`) |
|
|
||||||
| `grep` | 读 | 正则搜索内容,可限定路径 / glob、忽略大小写、带上下文 |
|
|
||||||
| `write_file` | 写 | 整文件写入(自动建父目录) |
|
|
||||||
| `edit_file` | 写 | 精确片段替换(必须唯一,或 `replace_all`) |
|
|
||||||
| `apply_patch` | 写 | 应用 unified diff,多文件原子生效 |
|
|
||||||
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
|
|
||||||
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
|
|
||||||
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
|
|
||||||
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
|
|
||||||
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
|
|
||||||
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
|
|
||||||
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
|
|
||||||
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
|
|
||||||
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
|
|
||||||
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
|
|
||||||
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
|
|
||||||
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
|
|
||||||
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
|
|
||||||
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
|
|
||||||
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
|
|
||||||
|
|
||||||
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
|
|
||||||
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
|
|
||||||
|
|
||||||
`--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`):
|
|
||||||
| 模式 | 写文件 | run_shell | web_fetch |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ask` | 询问 | 询问 | 询问 |
|
|
||||||
| `auto`(默认) | 直接执行 | 有沙箱 → 在沙箱里直接执行;没有沙箱 → 只读白名单命令直接执行,其余询问 | 询问 |
|
|
||||||
| `yolo` | 直接执行 | 直接执行(不进沙箱) | 直接执行 |
|
|
||||||
|
|
||||||
已保存的规则(`a` / `p`)优先:匹配的命令不进沙箱直接运行。
|
|
||||||
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
|
|
||||||
- **沙箱**(C 实现,`src/sandbox_exec.c`):
|
|
||||||
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
|
|
||||||
读、执行和联网不受限。
|
|
||||||
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
|
|
||||||
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
|
|
||||||
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
|
|
||||||
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
|
|
||||||
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
|
|
||||||
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
|
|
||||||
- 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`
|
|
||||||
和 `git status/diff/log/show/branch`;可以用 `|` `&&` `||` `;` 串联,但每段都必须在白名单里。
|
|
||||||
`find -exec/-delete`、`rg --pre`、`git branch NAME`、`>` 重定向(`2>&1`、`>/dev/null` 除外)、`$(…)`、反引号、
|
|
||||||
`<(…)`、`sudo`、`rm` 一律不算安全。
|
|
||||||
- **确认弹窗**会显示命令或写入的 diff:
|
|
||||||
`y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。
|
|
||||||
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
|
|
||||||
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
|
||||||
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
|
|
||||||
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
|
|
||||||
- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
|
|
||||||
|
|
||||||
### 扩展:Skills、插件、MCP、子 agent
|
|
||||||
|
|
||||||
`--tools minimal` 下这些都不加载(论文口径保持不变)。配置写法与示例见 [docs/plugins.md](docs/plugins.md)。
|
|
||||||
|
|
||||||
- **Skills**:`<dir>/SKILL.md`(frontmatter `name` + `description`),放在工作区 `.agent/skills/`、`.claude/skills/`
|
|
||||||
或配置目录 `skills/`。system prompt 只放名字和一句描述,模型需要时调用 `skill(name)` 读全文;
|
|
||||||
`/skills` 浏览,`/skill NAME 任务` 直接带着某个 skill 开始一轮。
|
|
||||||
- **插件**:`plugins/<name>/plugin.json` 声明外部命令工具(参数以 JSON 写到 stdin,stdout 即结果,`perm` 决定审批,
|
|
||||||
默认 60 s 超时),也可以附带 skills 目录和 MCP 服务器。
|
|
||||||
- **MCP**:`mcp.json`(`{"mcpServers": {...}}`,与 Claude/Cursor 格式相同)里的 stdio 服务器在后台启动,
|
|
||||||
工具注册为 `mcp__<server>__<tool>`,默认按网络工具审批(`autoApprove` 可放行)。`/mcp` 看状态,侧栏显示连接数。
|
|
||||||
- **工作区信任**:工作区里的 `.agent/plugins/` 和 `.agent/mcp.json` 会执行命令,默认不加载;
|
|
||||||
`/plugins trust` 后才生效(记在 `settings.json`)。配置目录(`~/.config/cst-agent/`)下的始终加载。
|
|
||||||
- **子 agent**:`task(description, prompt, agent?)` 新开一个独立上下文跑完一轮,把最终报告交回主 agent。
|
|
||||||
内置 `general`(除 task 外全部工具)、`explore`(只读,可并行)、`plan`(只读,输出计划);
|
|
||||||
`.agent/agents/*.md` 或配置目录 `agents/*.md` 可自定义角色(frontmatter `name / description / model / tools /
|
|
||||||
max_steps / readonly`,正文是角色说明)。子 agent 不能再开子 agent;Esc 会一并取消;它的审批弹窗标 `[角色名]`;
|
|
||||||
文件改动并入当前轮,`/undo` 一起撤销。转录在 `.agent/sessions/<会话>/sub-N.jsonl`,`/agents` 查看。
|
|
||||||
|
|
||||||
### TUI 快捷键
|
|
||||||
|
|
||||||
默认 leader 是 **Ctrl+X**:先按 Ctrl+X,再按后面的字母。`/keys`(或 F1、Ctrl+X h)列出当前全部绑定。
|
|
||||||
|
|
||||||
| 按键 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| Enter | 发送;busy 时入队 |
|
|
||||||
| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) |
|
|
||||||
| Shift+Enter / Ctrl+Enter | 换行;需要支持 kitty 键盘协议的终端(kitty、WezTerm、foot、Ghostty、WHC Shell 等),启动时自动探测 |
|
|
||||||
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F Ctrl/Alt+← → | 移动光标(按字 / 按词,支持中文宽字符) |
|
|
||||||
| Ctrl+W / Alt+Backspace / Ctrl+Backspace / Ctrl+Delete | 向前删词 / 向后删词(Ctrl+Backspace 需要 kitty 键盘协议) |
|
|
||||||
| Ctrl+U / Ctrl+K / Delete | 删到行首 / 删到行尾 / 删后一个字符 |
|
|
||||||
| ↑ / ↓ | 提示历史(多行输入时先在行间移动) |
|
|
||||||
| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
|
|
||||||
| Shift+Tab | 循环切换审批模式 ask → auto → yolo |
|
|
||||||
| Ctrl+P | 命令面板(输入过滤,↑↓ / 鼠标选择) |
|
|
||||||
| Ctrl+O / Ctrl+R | 展开/折叠最近的 tool 输出 / 最近的 Thought |
|
|
||||||
| Ctrl+T / Ctrl+L | 开关任务面板 / 重绘 |
|
|
||||||
| PgUp / PgDn、Alt+↑ / Alt+↓、Ctrl+Home / Ctrl+End | 半页滚动、单行滚动、跳到开头 / 最新 |
|
|
||||||
| Ctrl+X n / l | 新会话 / 会话列表 |
|
|
||||||
| Ctrl+X m / p / e | 模型 / provider / 推理强度 |
|
|
||||||
| Ctrl+X u / r / c | 撤销上一轮改动 / 重问 / 压缩上下文 |
|
|
||||||
| Ctrl+X y / x | 复制上一条回复 / 导出记录 |
|
|
||||||
| Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 |
|
|
||||||
| Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 |
|
|
||||||
| Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 |
|
|
||||||
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中 |
|
|
||||||
| 鼠标拖动 / 双击 / 三击 | 在对话区选中文字(按字符 / 词 / 行),松开即通过 OSC 52 复制到剪贴板;拖到上下边缘会自动滚动。Shift+拖动或 `/mouse` 关闭后用终端原生选择 |
|
|
||||||
| Esc | 取消当前 turn(不会退出) |
|
|
||||||
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
|
|
||||||
|
|
||||||
Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中显示实时计时;`/thinking on` 改为默认展开。
|
|
||||||
|
|
||||||
**自定义按键**:`~/.config/cst-agent/keybinds.json`,键是动作名(`/keys` 里能看到),值是一个或多个按键,
|
|
||||||
`"none"` 解除绑定:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"leader": "ctrl+x",
|
|
||||||
"new_session": ["<leader> n", "f2"],
|
|
||||||
"toggle_thought": "ctrl+g",
|
|
||||||
"todo": "none"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
按键写法:`ctrl+x`、`alt+b`、`shift+tab`、`f1`…`f12`、`pgup`、`home`、`<leader> n`。
|
|
||||||
写错的条目会被跳过并在 `/keys` 里提示,其余照常生效。
|
|
||||||
|
|
||||||
### 斜杠命令
|
|
||||||
|
|
||||||
输入 `/` 弹出补全;唯一前缀即可(如 `/st` → `/status`)。标 * 的命令在模型运行中也能用。
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `/help` * | 命令与按键 |
|
|
||||||
| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 |
|
|
||||||
| `/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]` * | 权限规则、每轮步数 |
|
|
||||||
| `/sidebar` *、`/mouse` * | 开关右侧边栏(≥120 列时显示)、开关鼠标 |
|
|
||||||
| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
|
|
||||||
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
|
|
||||||
| `/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 开始一轮 |
|
|
||||||
| `/plugins [trust\|untrust\|reload]`、`/mcp [reload]` | 插件与工作区信任;MCP 服务器状态 |
|
|
||||||
| `/agents` | 子 agent 角色与本会话的子 agent 记录(可查看转录) |
|
|
||||||
| `/quit` * | 退出 |
|
|
||||||
|
|
||||||
### 协议
|
|
||||||
|
|
||||||
请求统一走 OpenAI 兼容 `POST …/chat/completions`(SSE)。工具调用的承载方式由 `--protocol` 决定:
|
|
||||||
|
|
||||||
| 协议 | 承载 | 何时执行 |
|
|
||||||
|---|---|---|
|
|
||||||
| `native-eos`(默认) | 原生 `tools[]` / SSE `tool_calls` | 整个流结束后(基线) |
|
|
||||||
| `native-inc` | 同上 | 某个 index 的参数完整(下一个 index 开始或 finish)即执行 |
|
|
||||||
| `ctl` | 正文中的 CTL 帧,长度前缀参数 | `end` 行一被确认即执行,流仍在继续 |
|
|
||||||
| `ctl-heredoc` | 正文中的 CTL 帧,`<<TAG` 定界参数 | 同上 |
|
|
||||||
| `xml` | MiniMax `<minimax:tool_call>` 文本 | 流结束后 |
|
|
||||||
|
|
||||||
文本协议(ctl / ctl-heredoc / xml)不发送 `tools[]`,工具结果以
|
|
||||||
`result <id> exit=<n> <nbytes>` … `end` 帧放在下一条 user 消息里。
|
|
||||||
同一会话可以随时切换协议:历史中的调用会按当前协议重新渲染。
|
|
||||||
|
|
||||||
## CTL tool language
|
|
||||||
|
|
||||||
```text
|
|
||||||
call <id> <name>
|
|
||||||
arg <key> <nbytes>
|
|
||||||
<raw bytes exactly nbytes>
|
|
||||||
arg <key> <<TAG
|
|
||||||
<lines, verbatim>
|
|
||||||
TAG
|
|
||||||
arg <key> short inline value
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`); no prefix of
|
|
||||||
a call is ever executable. See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
|
|
||||||
|
|
||||||
## Starvation experiment
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make check
|
make check
|
||||||
./scripts/replay_starvation.sh
|
scripts/prompt_smoke.sh
|
||||||
./tests/test_replay_real # 真实 DeepSeek 录制流上的逐字节截断(实验 C)
|
scripts/e2e_webui.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A / C;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。
|
`icons/agent-motion/` 为 SVG 资产;`make hermit-font` 生成 `res/hermit-icons.ttf`(sbix 彩色位图,96 帧)与 `res/hermit-frames/`。安装脚本会把字体装进系统字体目录。
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
|
|||||||
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
|
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
|
||||||
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
|
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
|
||||||
#
|
#
|
||||||
|
# Desktop install (the window, its launcher entry and the icon; the CLI has
|
||||||
|
# its own installer) -- default.nix installs exactly this component:
|
||||||
|
# cmake --install build --component app --prefix ~/.local
|
||||||
|
#
|
||||||
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
|
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
|
||||||
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
|
||||||
|
|
||||||
@@ -39,8 +43,10 @@ if(NOT TARGET cstcore)
|
|||||||
list(REMOVE_ITEM CST_APP_CORE_SRC
|
list(REMOVE_ITEM CST_APP_CORE_SRC
|
||||||
"${CST_APP_ROOT}/src/net.c"
|
"${CST_APP_ROOT}/src/net.c"
|
||||||
"${CST_APP_ROOT}/src/http_srv.c"
|
"${CST_APP_ROOT}/src/http_srv.c"
|
||||||
"${CST_APP_ROOT}/src/webui.c"
|
"${CST_APP_ROOT}/src/webui.c")
|
||||||
"${CST_APP_ROOT}/src/web_md.c")
|
# web_md.c stays in: the window renders markdown with it (AgentCore::
|
||||||
|
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
|
||||||
|
# is left out.
|
||||||
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
|
||||||
include_directories("${CST_APP_ROOT}/.gen")
|
include_directories("${CST_APP_ROOT}/.gen")
|
||||||
endif()
|
endif()
|
||||||
@@ -85,10 +91,12 @@ if(CST_APP_GUI)
|
|||||||
qt_policy(SET QTP0004 NEW)
|
qt_policy(SET QTP0004 NEW)
|
||||||
# The front end's platform tools: one file per platform, the same split
|
# The front end's platform tools: one file per platform, the same split
|
||||||
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
|
||||||
# "app:android"); the other platforms still use the Linux file, whose
|
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
|
||||||
# registration entry point is simply not called there.
|
# the rest use the Linux file.
|
||||||
if(ANDROID)
|
if(ANDROID)
|
||||||
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
|
||||||
|
elseif(WIN32)
|
||||||
|
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
|
||||||
else()
|
else()
|
||||||
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
|
||||||
endif()
|
endif()
|
||||||
@@ -96,9 +104,11 @@ if(CST_APP_GUI)
|
|||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/core/AgentCore.cpp
|
src/core/AgentCore.cpp
|
||||||
src/core/ToolProvider.cpp
|
src/core/ToolProvider.cpp
|
||||||
|
src/core/PdfTools.cpp
|
||||||
src/ui/UiTest.cpp
|
src/ui/UiTest.cpp
|
||||||
src/ui/ThemeIcons.cpp
|
src/ui/ThemeIcons.cpp
|
||||||
src/ui/SessionImages.cpp
|
src/ui/SessionImages.cpp
|
||||||
|
src/ui/BackKey.cpp
|
||||||
${CST_APP_PLATFORM_SRC})
|
${CST_APP_PLATFORM_SRC})
|
||||||
target_include_directories(cst-agent-app PRIVATE
|
target_include_directories(cst-agent-app PRIVATE
|
||||||
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
|
||||||
@@ -119,6 +129,23 @@ if(CST_APP_GUI)
|
|||||||
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# A desktop install. The icon goes where the theme layout says, so the
|
||||||
|
# entry's Icon= name finds it, and the entry's file name is the app_id Qt
|
||||||
|
# sets (QGuiApplication::applicationName) -- rename one and the launcher
|
||||||
|
# loses the icon. All of it in the "app" component: the CLI keeps coming
|
||||||
|
# from scripts/bootstrap_linux.sh, and two `cst-agent` in PATH is how a
|
||||||
|
# version nobody meant to run gets run.
|
||||||
|
if(NOT ANDROID)
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
install(TARGETS cst-agent-app
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app)
|
||||||
|
install(FILES linux/cst-agent-app.desktop
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app)
|
||||||
|
install(FILES linux/cst-agent-app.svg
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps
|
||||||
|
COMPONENT app)
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- Android (APK) -----------------------------------------------------
|
# --- Android (APK) -----------------------------------------------------
|
||||||
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
|
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
|
||||||
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
|
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
|
||||||
@@ -173,7 +200,7 @@ if(CST_APP_GUI)
|
|||||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||||
# requires absolute URLs" on every start).
|
# requires absolute URLs" on every start).
|
||||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||||
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog
|
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
|
||||||
ThemeIcon Theme ImageStrip)
|
ThemeIcon Theme ImageStrip)
|
||||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||||
endforeach()
|
endforeach()
|
||||||
@@ -191,6 +218,7 @@ if(CST_APP_GUI)
|
|||||||
qml/NoteLine.qml
|
qml/NoteLine.qml
|
||||||
qml/TodoPanel.qml
|
qml/TodoPanel.qml
|
||||||
qml/Composer.qml
|
qml/Composer.qml
|
||||||
|
qml/TaskPanel.qml
|
||||||
qml/ConsentDialog.qml
|
qml/ConsentDialog.qml
|
||||||
qml/AskDialog.qml
|
qml/AskDialog.qml
|
||||||
qml/SettingsDialog.qml
|
qml/SettingsDialog.qml
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Deprecated: Qt front end
|
||||||
|
|
||||||
|
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`).
|
||||||
|
|
||||||
|
The supported desktop shell is **`apps/desktop`** (Tauri 2 + React), with a
|
||||||
|
DeepSeek-Harness-inspired layout and an in-process C engine bridge.
|
||||||
|
|
||||||
|
Do not add features here. Once Tauri reaches feature parity, this tree will be
|
||||||
|
moved to `archive/qt-app` or removed. Android APK scripts that target this tree
|
||||||
|
are transitional.
|
||||||
@@ -45,6 +45,31 @@ navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
|
|||||||
change. Point the app at a Kirigami outside the Qt prefix with
|
change. Point the app at a Kirigami outside the Qt prefix with
|
||||||
`-DCST_APP_QML_IMPORT=/path/to/qml`.
|
`-DCST_APP_QML_IMPORT=/path/to/qml`.
|
||||||
|
|
||||||
|
## Install (Linux desktop)
|
||||||
|
|
||||||
|
The window installs itself: the binary, a launcher entry and the icon the
|
||||||
|
launcher draws for it. All three are one CMake component (`app`), so the CLI
|
||||||
|
keeps coming from `scripts/bootstrap_linux.sh` and the profile never holds two
|
||||||
|
`cst-agent` of different vintages; the files live in `app/linux/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# NixOS / nix users: PATH + menu entry + icon. The Qt apps hook bakes the
|
||||||
|
# plugin and QML module paths into the wrapper, so the app runs outside a
|
||||||
|
# nix-shell and nix-collect-garbage cannot pull Qt out from under it.
|
||||||
|
nix-build # ./result/bin/cst-agent-app, run it in place
|
||||||
|
nix profile add --file . # or: ./result/bin/cst-agent-app
|
||||||
|
nix profile remove cst-agent # uninstall
|
||||||
|
|
||||||
|
# anywhere with Qt 6 (Quick/Qml/QuickControls2) and libcurl:
|
||||||
|
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build-app -j
|
||||||
|
cmake --install build-app --component app --prefix ~/.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Launched from a menu, the workspace is the directory the app starts in
|
||||||
|
(`$HOME` there), so sessions land in `~/.agent/sessions`; pass
|
||||||
|
`--workspace <dir>` to pin another one.
|
||||||
|
|
||||||
## Why in one process
|
## Why in one process
|
||||||
|
|
||||||
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
|
||||||
@@ -125,6 +150,13 @@ creates and deletes sessions. On a machine without a screen,
|
|||||||
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
||||||
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
||||||
|
|
||||||
|
Every line the run prints also goes to `uitest.log` in the workspace, and the
|
||||||
|
verdict is the last line of it. That is how a run *on a phone* is read back:
|
||||||
|
stdout reaches nobody there, an app started from adb cannot be handed
|
||||||
|
environment variables, and logcat does not carry this build's Qt messages --
|
||||||
|
so `--uitest --workspace /sdcard/cst-uitest` plus
|
||||||
|
`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip.
|
||||||
|
|
||||||
## Provider, model, key
|
## Provider, model, key
|
||||||
|
|
||||||
The sidebar shows where the engine points (`provider · model`); the gear in the
|
The sidebar shows where the engine points (`provider · model`); the gear in the
|
||||||
@@ -271,6 +303,26 @@ and two buttons.
|
|||||||
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
buttons, clicks Open (the header must say `opened report.md`), clicks Share
|
||||||
and checks a name the session does not own is refused.
|
and checks a name the session does not own is refused.
|
||||||
|
|
||||||
|
### Making one: `md_to_pdf`
|
||||||
|
|
||||||
|
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
|
||||||
|
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
|
||||||
|
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
|
||||||
|
margins in the workspace, and goes through the same
|
||||||
|
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
|
||||||
|
file card the person can open, print or share, and its bytes never enter a
|
||||||
|
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
|
||||||
|
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
|
||||||
|
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
|
||||||
|
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
|
||||||
|
phone has no print subsystem. The tool runs on the turn's worker thread, and
|
||||||
|
`--uitest` runs it through the registry and checks the file starts with
|
||||||
|
`%PDF-`.
|
||||||
|
|
||||||
|
The card row on the phone is the same one a picture uses: the store keeps the
|
||||||
|
PDF next to the session, the image provider refuses it (it is not an image),
|
||||||
|
and the file card carries it.
|
||||||
|
|
||||||
## Transcript, tool calls, streaming text
|
## Transcript, tool calls, streaming text
|
||||||
|
|
||||||
- A tool call is a card of its own, and the call **id** is what pairs it with
|
- A tool call is a card of its own, and the call **id** is what pairs it with
|
||||||
@@ -408,6 +460,7 @@ so nothing about it is visible when the app is idle.
|
|||||||
file card's open/share through the app's FileProvider
|
file card's open/share through the app's FileProvider
|
||||||
- [x] a turn keeps running with the app off screen (Android foreground service
|
- [x] a turn keeps running with the app off screen (Android foreground service
|
||||||
+ wake lock; nothing to do on a desktop)
|
+ wake lock; nothing to do on a desktop)
|
||||||
|
- [x] desktop install: launcher entry + icon, `nix profile add --file .`
|
||||||
- [ ] a real turn driven through the window (needs a provider key)
|
- [ ] a real turn driven through the window (needs a provider key)
|
||||||
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
|
||||||
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=CST-agent
|
||||||
|
GenericName=Local coding agent
|
||||||
|
Comment=The engine in a window: workspace is the directory it is started in
|
||||||
|
Exec=cst-agent-app
|
||||||
|
TryExec=cst-agent-app
|
||||||
|
Icon=cst-agent-app
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
# Qt puts the application name in the Wayland app_id / WM_CLASS, so this has to
|
||||||
|
# stay the file's own name or the launcher draws a generic icon.
|
||||||
|
StartupWMClass=cst-agent-app
|
||||||
|
Categories=Development;Utility;
|
||||||
|
Keywords=agent;ai;assistant;code;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- The launcher icon: the prompt the CLI shows, on a tile in the window's own
|
||||||
|
palette (app/qml/Theme.qml). Installed under hicolor/scalable, so the
|
||||||
|
desktops that want raster sizes render it themselves. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<rect x="6" y="6" width="116" height="116" rx="26" ry="26"
|
||||||
|
fill="#15181a" stroke="#343b41" stroke-width="5"/>
|
||||||
|
<path d="M36 42 L56 64 L36 86" fill="none" stroke="#3daee9" stroke-width="12"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M69 88 H94" fill="none" stroke="#eff0f1" stroke-width="12"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 712 B |
@@ -22,7 +22,7 @@ Rectangle {
|
|||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
const t = input.text.trim()
|
const t = input.text.trim()
|
||||||
if (!t.length || running)
|
if (!t.length)
|
||||||
return
|
return
|
||||||
input.text = ""
|
input.text = ""
|
||||||
submitted(t)
|
submitted(t)
|
||||||
@@ -142,7 +142,7 @@ Rectangle {
|
|||||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||||
Math.max(56, contentHeight + 12))
|
Math.max(56, contentHeight + 12))
|
||||||
placeholderText: root.running
|
placeholderText: root.running
|
||||||
? "the turn is running — Esc stops it"
|
? "Send a correction now — Esc pauses execution"
|
||||||
: (root.compact
|
: (root.compact
|
||||||
? "Ask for something…"
|
? "Ask for something…"
|
||||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||||
@@ -155,7 +155,7 @@ Rectangle {
|
|||||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||||
Qt.ImhNoAutoUppercase
|
Qt.ImhNoAutoUppercase
|
||||||
background: Rectangle { color: "transparent" }
|
background: Rectangle { color: "transparent" }
|
||||||
enabled: !root.running
|
enabled: true
|
||||||
|
|
||||||
Keys.onPressed: function (ev) {
|
Keys.onPressed: function (ev) {
|
||||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||||
@@ -206,9 +206,9 @@ Rectangle {
|
|||||||
Layout.alignment: Qt.AlignBottom
|
Layout.alignment: Qt.AlignBottom
|
||||||
implicitWidth: root.compact ? 68 : 88
|
implicitWidth: root.compact ? 68 : 88
|
||||||
implicitHeight: Theme.touchTarget + 4
|
implicitHeight: Theme.touchTarget + 4
|
||||||
text: root.running ? "Stop" : "Send"
|
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
|
||||||
enabled: root.running || input.text.trim().length > 0
|
enabled: root.running || input.text.trim().length > 0
|
||||||
onClicked: root.running ? agent.cancel() : root.submit()
|
onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
color: !action.enabled ? Theme.surfaceAlt
|
color: !action.enabled ? Theme.surfaceAlt
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ ApplicationWindow {
|
|||||||
property string statusText: ""
|
property string statusText: ""
|
||||||
property var sessionsView: [] // the store, for the sidebar
|
property var sessionsView: [] // the store, for the sidebar
|
||||||
property bool navOpen: false // the sliding sidebar, compact only
|
property bool navOpen: false // the sliding sidebar, compact only
|
||||||
|
TaskPanel { id: taskPanel; anchors.centerIn: parent }
|
||||||
property int sidebarWidth: 0 // 0 = follow the window width
|
property int sidebarWidth: 0 // 0 = follow the window width
|
||||||
readonly property bool compact: width < Theme.compactBreakpoint
|
readonly property bool compact: width < Theme.compactBreakpoint
|
||||||
readonly property int sidebarWide: sidebarWidth > 0
|
readonly property int sidebarWide: sidebarWidth > 0
|
||||||
@@ -201,18 +202,25 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- the shell -------------------------------------------------------
|
// ---- the shell -------------------------------------------------------
|
||||||
header: ToolBar {
|
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
|
||||||
|
* to a style-owned contentItem, and on this phone that item sat 35 px below
|
||||||
|
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
|
||||||
|
* was drawn, and clicked, outside the header, over the sidebar. A
|
||||||
|
* Rectangle puts its children where it is (checked with --uitest on the
|
||||||
|
* device and on both styles at home). */
|
||||||
|
header: Rectangle {
|
||||||
id: appBar
|
id: appBar
|
||||||
|
objectName: "appBar"
|
||||||
implicitHeight: Theme.headerHeight
|
implicitHeight: Theme.headerHeight
|
||||||
background: Rectangle {
|
color: Theme.surface
|
||||||
color: Theme.surface
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: 1
|
height: 1
|
||||||
color: Theme.border
|
color: Theme.border
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: Theme.pad
|
anchors.leftMargin: Theme.pad
|
||||||
@@ -311,6 +319,12 @@ ApplicationWindow {
|
|||||||
win.statusText = ""
|
win.statusText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ToolButton {
|
||||||
|
objectName: "taskPanelButton"
|
||||||
|
text: "Tasks"
|
||||||
|
implicitHeight: Theme.touchTarget
|
||||||
|
onClicked: taskPanel.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +381,7 @@ ApplicationWindow {
|
|||||||
|
|
||||||
ChatView {
|
ChatView {
|
||||||
id: chat
|
id: chat
|
||||||
|
objectName: "chatView"
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
model: transcript
|
model: transcript
|
||||||
@@ -499,6 +514,42 @@ ApplicationWindow {
|
|||||||
win.statusText = ""
|
win.statusText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* The back key a phone has instead of a mouse: it closes what is open, one
|
||||||
|
* layer at a time -- the dialog on top, then the sliding sidebar. With
|
||||||
|
* nothing open the key goes on to the platform, and a phone leaves the
|
||||||
|
* window (Qt finishes the activity), which is right for the last layer.
|
||||||
|
*
|
||||||
|
* Which layer that is, and whether there is one, lives in one place: the
|
||||||
|
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
|
||||||
|
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
|
||||||
|
* lets through. --uitest reads the same two functions. */
|
||||||
|
function backLayer() {
|
||||||
|
if (settings.visible)
|
||||||
|
return "settings"
|
||||||
|
if (ask.visible)
|
||||||
|
return "ask"
|
||||||
|
if (consent.visible)
|
||||||
|
return "consent"
|
||||||
|
if (win.navOpen)
|
||||||
|
return "drawer"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One layer back. The dialogs are closed on the next turn of the event
|
||||||
|
* loop: closing one from inside the key event leaves its modal overlay up
|
||||||
|
* in this Qt (the dialog says it is closed, the overlay still eats every
|
||||||
|
* click after it), which --uitest walks straight into. */
|
||||||
|
function stepBack() {
|
||||||
|
if (settings.visible)
|
||||||
|
Qt.callLater(settings.close)
|
||||||
|
else if (ask.visible)
|
||||||
|
Qt.callLater(ask.close)
|
||||||
|
else if (consent.visible)
|
||||||
|
Qt.callLater(consent.close)
|
||||||
|
else if (win.navOpen)
|
||||||
|
win.navOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- engine -> UI ----------------------------------------------------
|
// ---- engine -> UI ----------------------------------------------------
|
||||||
/* The signal handlers only forward: every one of them has a function
|
/* The signal handlers only forward: every one of them has a function
|
||||||
@@ -650,6 +701,12 @@ ApplicationWindow {
|
|||||||
ask.open()
|
ask.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Nobody answered (the app was in the background, or the dialog sat
|
||||||
|
* there too long): the turn went on without it, so put the dialog
|
||||||
|
* away instead of leaving a question that is no longer being asked. */
|
||||||
|
function onPermissionExpired(tool) { consent.close() }
|
||||||
|
function onQuestionExpired() { ask.close() }
|
||||||
|
|
||||||
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||||
|
|
||||||
function onRunningChanged() {
|
function onRunningChanged() {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: root
|
||||||
|
objectName: "taskControlDialog"
|
||||||
|
title: "Task control and jobs"
|
||||||
|
modal: true
|
||||||
|
standardButtons: Dialog.Close
|
||||||
|
width: Math.min(760, parent ? parent.width - 24 : 760)
|
||||||
|
height: Math.min(640, parent ? parent.height - 24 : 640)
|
||||||
|
property var state: ({})
|
||||||
|
function refresh() {
|
||||||
|
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
|
||||||
|
}
|
||||||
|
onOpened: { refresh(); config.text = agent.workerConfig() }
|
||||||
|
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
TabBar {
|
||||||
|
id: tabs
|
||||||
|
Layout.fillWidth: true
|
||||||
|
TabButton { text: "Task / Jobs" }
|
||||||
|
TabButton { text: "Models" }
|
||||||
|
TabButton { text: "Integration" }
|
||||||
|
}
|
||||||
|
StackLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
currentIndex: tabs.currentIndex
|
||||||
|
ColumnLayout {
|
||||||
|
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
|
||||||
|
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
|
||||||
|
RowLayout {
|
||||||
|
CheckBox {
|
||||||
|
id: readonly
|
||||||
|
objectName: "taskReadOnly"
|
||||||
|
text: "Read only"
|
||||||
|
checked: !!root.state.read_only
|
||||||
|
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
|
||||||
|
}
|
||||||
|
CheckBox {
|
||||||
|
id: direct
|
||||||
|
objectName: "taskMainOnly"
|
||||||
|
text: "Main agent only"
|
||||||
|
checked: root.state.delegation === false
|
||||||
|
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
|
||||||
|
}
|
||||||
|
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
|
||||||
|
}
|
||||||
|
ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
ColumnLayout {
|
||||||
|
width: parent.width
|
||||||
|
Repeater {
|
||||||
|
model: root.state.jobs || []
|
||||||
|
Frame {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
|
||||||
|
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
|
||||||
|
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
|
||||||
|
RowLayout {
|
||||||
|
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
|
||||||
|
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
|
||||||
|
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ColumnLayout {
|
||||||
|
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||||
|
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||||
|
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
|
||||||
|
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
|
||||||
|
}
|
||||||
|
ColumnLayout {
|
||||||
|
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
|
||||||
|
RowLayout {
|
||||||
|
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
|
||||||
|
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
|
||||||
|
}
|
||||||
|
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,55 @@ Rectangle {
|
|||||||
: state === "unfinished" ? "no result"
|
: state === "unfinished" ? "no result"
|
||||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||||
|
|
||||||
|
/* How long this call has been going. A tool that takes minutes -- a
|
||||||
|
* build, a wide search, a command nobody can predict -- must not read as
|
||||||
|
* a window that has hung: while the card runs, its chip counts. */
|
||||||
|
property double startedAt: 0
|
||||||
|
property int runningFor: 0
|
||||||
|
|
||||||
|
function elapsedText() {
|
||||||
|
const s = runningFor
|
||||||
|
if (s < 60)
|
||||||
|
return s + "s"
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m < 60)
|
||||||
|
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
|
||||||
|
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The clock starts when the card starts running -- and a card that is
|
||||||
|
* already running when it appears (a replayed transcript) starts one too,
|
||||||
|
* hence Component.onCompleted. */
|
||||||
|
function noteRunning() {
|
||||||
|
if (running && startedAt === 0)
|
||||||
|
startedAt = Date.now()
|
||||||
|
else if (!running)
|
||||||
|
startedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onRunningChanged: noteRunning()
|
||||||
|
Component.onCompleted: noteRunning()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: card.running && card.startedAt > 0
|
||||||
|
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the chip says: the state, how long it has been running (from five
|
||||||
|
* seconds on -- before that it is noise), or the exit code once there is
|
||||||
|
* a result. "no result" is not an outcome, so no exit code belongs on
|
||||||
|
* it. */
|
||||||
|
function chipText() {
|
||||||
|
if (running)
|
||||||
|
return runningFor >= 5
|
||||||
|
? stateText + " · " + elapsedText()
|
||||||
|
: stateText
|
||||||
|
return stateText + (hasResult && state !== "unfinished"
|
||||||
|
? " · exit " + exitCode : "")
|
||||||
|
}
|
||||||
|
|
||||||
function lines() {
|
function lines() {
|
||||||
const all = output.split("\n")
|
const all = output.split("\n")
|
||||||
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
||||||
@@ -92,10 +141,9 @@ Rectangle {
|
|||||||
implicitHeight: chip.implicitHeight + 2
|
implicitHeight: chip.implicitHeight + 2
|
||||||
Text {
|
Text {
|
||||||
id: chip
|
id: chip
|
||||||
|
objectName: "toolChip"
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
/* "no result" is not an outcome: no exit code belongs on it. */
|
text: card.chipText()
|
||||||
text: card.stateText + (card.hasResult && card.state !== "unfinished"
|
|
||||||
? " · exit " + card.exitCode : "")
|
|
||||||
color: card.stateColor
|
color: card.stateColor
|
||||||
font.pixelSize: Theme.smallSize
|
font.pixelSize: Theme.smallSize
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
#include "cst_attach.h"
|
#include "cst_attach.h"
|
||||||
#include "cst_models.h"
|
#include "cst_models.h"
|
||||||
|
#include "cst_runtime.h"
|
||||||
|
#include "cst_subagent.h"
|
||||||
|
#include "cst_worktree.h"
|
||||||
#include "cst_provider_switch.h"
|
#include "cst_provider_switch.h"
|
||||||
#include "cst_web_md.h"
|
#include "cst_web_md.h"
|
||||||
|
|
||||||
@@ -45,6 +48,24 @@ class Turn : public QThread {
|
|||||||
QByteArray m_prompt;
|
QByteArray m_prompt;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* The engine asks for a tool only when its own confirm_shell / confirm_write /
|
||||||
|
* confirm_net flag says so (`needs_confirm()`, src/agent_loop.c) -- the
|
||||||
|
* approval mode on top of that only *relaxes* them (AUTO writes a file
|
||||||
|
* without asking, YOLO everything). The CLI fills those three in
|
||||||
|
* (src/main.c: parse_confirm), the TUI and the WebUI do too; the window never
|
||||||
|
* did: AgentCore::open() zeroed the whole config and cst_agent_create() keeps
|
||||||
|
* what it is given, so every tool ran without asking while the sidebar said
|
||||||
|
* "ask". One mapping, used by both open() and setApproval(). */
|
||||||
|
namespace {
|
||||||
|
void applyApproval(cst_agent_config_t *cfg, int approval) {
|
||||||
|
const int ask = (approval == CST_APPROVAL_ASK);
|
||||||
|
cfg->approval = approval;
|
||||||
|
cfg->confirm_shell = ask;
|
||||||
|
cfg->confirm_write = ask;
|
||||||
|
cfg->confirm_net = ask;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
/* --- lifetime ------------------------------------------------------------ */
|
/* --- lifetime ------------------------------------------------------------ */
|
||||||
|
|
||||||
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
|
AgentCore::AgentCore(QObject *parent) : QObject(parent) {
|
||||||
@@ -122,7 +143,9 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
|||||||
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
|
||||||
char key[512] = "";
|
char key[512] = "";
|
||||||
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
|
||||||
const char *env = getenv("CST_API_KEY");
|
const char *env = getenv("HERMIT_API_KEY");
|
||||||
|
if (!env || !env[0])
|
||||||
|
env = getenv("CST_API_KEY");
|
||||||
if (env && env[0])
|
if (env && env[0])
|
||||||
snprintf(key, sizeof(key), "%s", env);
|
snprintf(key, sizeof(key), "%s", env);
|
||||||
}
|
}
|
||||||
@@ -141,6 +164,14 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
|||||||
/* The GUI answers itself: never let the engine decide behind its back. */
|
/* The GUI answers itself: never let the engine decide behind its back. */
|
||||||
m_cfg.auto_approve = 0;
|
m_cfg.auto_approve = 0;
|
||||||
m_cfg.headless = 0;
|
m_cfg.headless = 0;
|
||||||
|
/* What the person chose last -- the CLI and the TUI remember it the same
|
||||||
|
* way and the TUI writes the same file -- else "ask", which is what the
|
||||||
|
* window's default label promises and the only mode where its consent
|
||||||
|
* dialog is ever reached. */
|
||||||
|
int approval = CST_APPROVAL_ASK;
|
||||||
|
if (have_settings && st.approval[0])
|
||||||
|
cst_approval_parse(st.approval, &approval);
|
||||||
|
applyApproval(&m_cfg, approval);
|
||||||
if (!m_cfg.mode[0])
|
if (!m_cfg.mode[0])
|
||||||
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
|
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
|
||||||
m_agent = cst_agent_create(&m_cfg);
|
m_agent = cst_agent_create(&m_cfg);
|
||||||
@@ -189,6 +220,12 @@ void AgentCore::close() {
|
|||||||
/* --- turns --------------------------------------------------------------- */
|
/* --- turns --------------------------------------------------------------- */
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
/* How long the worker waits for a person. Long enough to read a file diff and
|
||||||
|
* decide, short enough that a missed dialog (or a screen that went dark) does
|
||||||
|
* not leave the turn waiting for good. */
|
||||||
|
constexpr int kConsentTimeoutMs = 2 * 60 * 1000;
|
||||||
|
constexpr int kQuestionTimeoutMs = 5 * 60 * 1000;
|
||||||
|
|
||||||
/* The first line of a prompt, cut short: the line the phone's "working"
|
/* The first line of a prompt, cut short: the line the phone's "working"
|
||||||
* notification carries while the turn runs (app::holdForeground). */
|
* notification carries while the turn runs (app::holdForeground). */
|
||||||
QString firstLine(const QString &s) {
|
QString firstLine(const QString &s) {
|
||||||
@@ -201,8 +238,13 @@ QString firstLine(const QString &s) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void AgentCore::send(const QString &prompt) {
|
void AgentCore::send(const QString &prompt) {
|
||||||
if (!m_agent || m_running || prompt.trimmed().isEmpty())
|
if (!m_agent || prompt.trimmed().isEmpty())
|
||||||
return;
|
return;
|
||||||
|
if (m_running) {
|
||||||
|
cst_agent_steer(m_agent, prompt.toUtf8().constData(), -1, -1);
|
||||||
|
emit statusChanged(QStringLiteral("Applying latest instruction…"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
/* The prompt takes the pending images with it: the view shows them on the
|
/* The prompt takes the pending images with it: the view shows them on the
|
||||||
* message the composer just appended, before the turn can say anything. */
|
* message the composer just appended, before the turn can say anything. */
|
||||||
const QString sending = pendingImages();
|
const QString sending = pendingImages();
|
||||||
@@ -223,10 +265,59 @@ void AgentCore::cancel() {
|
|||||||
if (m_agent)
|
if (m_agent)
|
||||||
cst_agent_request_cancel(m_agent);
|
cst_agent_request_cancel(m_agent);
|
||||||
}
|
}
|
||||||
|
QString AgentCore::taskState() const {
|
||||||
|
if (!m_agent) return QStringLiteral("{}");
|
||||||
|
char *s = cst_runtime_json(cst_agent_config(m_agent)->runtime);
|
||||||
|
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||||
|
}
|
||||||
|
void AgentCore::taskControl(bool ro, bool delegation, bool paused) {
|
||||||
|
if (m_agent) cst_agent_control(m_agent, ro, delegation, paused);
|
||||||
|
}
|
||||||
|
void AgentCore::cancelJob(const QString &id, bool force) {
|
||||||
|
if (m_agent) cst_job_cancel(cst_agent_config(m_agent)->runtime, id.toUtf8().constData(), force, "user");
|
||||||
|
}
|
||||||
|
QString AgentCore::workerConfig() const {
|
||||||
|
if (!m_agent) return QStringLiteral("{}");
|
||||||
|
char *s = cst_workers_config_get(cst_agent_config(m_agent)->workspace_root);
|
||||||
|
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
|
||||||
|
}
|
||||||
|
QString AgentCore::saveWorkerConfig(const QString &json) {
|
||||||
|
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before changing model profiles.");
|
||||||
|
char err[256];
|
||||||
|
return cst_workers_config_set(cst_agent_config(m_agent)->workspace_root, json.toUtf8().constData(), err, sizeof(err)) ? QString::fromUtf8(err) : QString();
|
||||||
|
}
|
||||||
|
QString AgentCore::worktreeDiff(const QString &path, bool integrate) {
|
||||||
|
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before reviewing or integrating changes.");
|
||||||
|
const auto *cfg = cst_agent_config(m_agent);
|
||||||
|
if (cst_runtime_jobs_pending(cfg->runtime) || (integrate && !cst_runtime_check(cfg->runtime, 0, 1, 0)))
|
||||||
|
return QStringLiteral("Integration is blocked by active jobs or task controls.");
|
||||||
|
int code;
|
||||||
|
char *s = cst_worktree_diff(cst_agent_config(m_agent)->workspace_root, path.toUtf8().constData(), integrate, &code);
|
||||||
|
QString result = QString::fromUtf8(s ? s : "error"); free(s); return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Qt suspends the application when the phone puts it away, and a desktop
|
||||||
|
* window can be gone too (--uitest runs headless). Either way a consent
|
||||||
|
* dialog would be invisible. */
|
||||||
|
bool AgentCore::backgrounded() const {
|
||||||
|
QGuiApplication *gui =
|
||||||
|
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||||
|
if (!gui)
|
||||||
|
return false;
|
||||||
|
if (gui->applicationState() == Qt::ApplicationSuspended)
|
||||||
|
return true;
|
||||||
|
return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
void AgentCore::finishTurn(int rc, const QString &err) {
|
void AgentCore::finishTurn(int rc, const QString &err) {
|
||||||
Turn *t = static_cast<Turn *>(sender());
|
Turn *t = static_cast<Turn *>(sender());
|
||||||
|
if (t) {
|
||||||
|
/* run() is the caller: waiting for the thread here would be waiting for
|
||||||
|
* this very call to return. Let the event loop reap it instead. */
|
||||||
|
connect(t, &QThread::finished, t, &QObject::deleteLater);
|
||||||
|
}
|
||||||
post([this, rc, err] {
|
post([this, rc, err] {
|
||||||
|
m_turn = nullptr;
|
||||||
setRunning(false);
|
setRunning(false);
|
||||||
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
||||||
/* The prompt carried whatever was pending; the engine dropped it. */
|
/* The prompt carried whatever was pending; the engine dropped it. */
|
||||||
@@ -234,11 +325,6 @@ void AgentCore::finishTurn(int rc, const QString &err) {
|
|||||||
emit pendingChanged();
|
emit pendingChanged();
|
||||||
emit turnFinished(rc, err);
|
emit turnFinished(rc, err);
|
||||||
});
|
});
|
||||||
if (t) {
|
|
||||||
t->wait();
|
|
||||||
t->deleteLater();
|
|
||||||
}
|
|
||||||
m_turn = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::setRunning(bool on) {
|
void AgentCore::setRunning(bool on) {
|
||||||
@@ -275,16 +361,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail,
|
|||||||
return CST_PERM_DENY;
|
return CST_PERM_DENY;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return CST_PERM_ONCE;
|
return CST_PERM_ONCE;
|
||||||
|
const QString t = QString::fromUtf8(tool ? tool : "");
|
||||||
|
/* The turn survives the app leaving the screen (v0.9.4), so a question
|
||||||
|
* asked there would block the worker on a dialog nobody can see -- which is
|
||||||
|
* what "the app hangs while the agent works" looks like. In the background
|
||||||
|
* the answer is no, with a line the person finds in the transcript. */
|
||||||
|
if (self->backgrounded()) {
|
||||||
|
self->post([self, t] {
|
||||||
|
emit self->permissionExpired(t);
|
||||||
|
emit self->statusChanged(
|
||||||
|
QStringLiteral("%1: refused — the app was in the background").arg(t));
|
||||||
|
});
|
||||||
|
return CST_PERM_DENY;
|
||||||
|
}
|
||||||
QMutexLocker lock(&self->m_askMu);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_permAnswer = CST_PERM_DENY;
|
self->m_permAnswer = CST_PERM_DENY;
|
||||||
self->m_permPending = true;
|
self->m_permPending = true;
|
||||||
const QString t = QString::fromUtf8(tool ? tool : "");
|
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||||
const QString p = QString::fromUtf8(preview ? preview : "");
|
const QString p = QString::fromUtf8(preview ? preview : "");
|
||||||
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
||||||
while (self->m_permPending && !self->m_closing)
|
/* And even in the foreground nobody may hang forever: a dialog can be
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
* missed, a screen can go dark. Two minutes, then the answer is no. */
|
||||||
|
bool expired = false;
|
||||||
|
while (self->m_permPending && !self->m_closing) {
|
||||||
|
if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) {
|
||||||
|
expired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
self->m_permPending = false;
|
self->m_permPending = false;
|
||||||
|
if (expired) {
|
||||||
|
self->post([self, t] {
|
||||||
|
emit self->permissionExpired(t);
|
||||||
|
emit self->statusChanged(
|
||||||
|
QStringLiteral("%1: no answer for two minutes — refused").arg(t));
|
||||||
|
});
|
||||||
|
return CST_PERM_DENY;
|
||||||
|
}
|
||||||
return self->m_permAnswer;
|
return self->m_permAnswer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,16 +408,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
|
|||||||
return -1;
|
return -1;
|
||||||
if (self->m_autoApprove)
|
if (self->m_autoApprove)
|
||||||
return -1;
|
return -1;
|
||||||
|
if (self->backgrounded()) {
|
||||||
|
self->post([self] {
|
||||||
|
emit self->questionExpired();
|
||||||
|
emit self->statusChanged(
|
||||||
|
QStringLiteral("the question was dropped — the app was in the "
|
||||||
|
"background"));
|
||||||
|
});
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
QMutexLocker lock(&self->m_askMu);
|
QMutexLocker lock(&self->m_askMu);
|
||||||
self->m_questionPending = true;
|
self->m_questionPending = true;
|
||||||
self->m_answer.clear();
|
self->m_answer.clear();
|
||||||
const QString q = QString::fromUtf8(question ? question : "");
|
const QString q = QString::fromUtf8(question ? question : "");
|
||||||
const QString o = QString::fromUtf8(options ? options : "");
|
const QString o = QString::fromUtf8(options ? options : "");
|
||||||
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
||||||
while (self->m_questionPending && !self->m_closing)
|
bool expired = false;
|
||||||
self->m_askCv.wait(&self->m_askMu);
|
while (self->m_questionPending && !self->m_closing) {
|
||||||
|
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
|
||||||
|
expired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (self->m_closing)
|
if (self->m_closing)
|
||||||
return -1;
|
return -1;
|
||||||
|
self->m_questionPending = false;
|
||||||
|
if (expired) {
|
||||||
|
self->post([self] {
|
||||||
|
emit self->questionExpired();
|
||||||
|
emit self->statusChanged(
|
||||||
|
QStringLiteral("no answer for five minutes — the question was "
|
||||||
|
"dropped"));
|
||||||
|
});
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -324,9 +461,12 @@ void AgentCore::setMode(const QString &mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::setApproval(int approval) {
|
void AgentCore::setApproval(int approval) {
|
||||||
m_cfg.approval = approval;
|
applyApproval(&m_cfg, approval);
|
||||||
if (m_agent)
|
if (m_agent)
|
||||||
cst_agent_config_mut(m_agent)->approval = approval;
|
applyApproval(cst_agent_config_mut(m_agent), approval);
|
||||||
|
/* Remembered like the TUI's /approval: the next start of the window (or of
|
||||||
|
* the CLI) comes up the way it was left. */
|
||||||
|
cst_settings_remember_approval(approval);
|
||||||
emit approvalChanged();
|
emit approvalChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,29 +670,56 @@ QString AgentCore::shareArtifact(const QString &rel) {
|
|||||||
|
|
||||||
QVariantList AgentCore::sessions() const {
|
QVariantList AgentCore::sessions() const {
|
||||||
QVariantList out;
|
QVariantList out;
|
||||||
if (m_workspace.isEmpty())
|
if (m_workspace.isEmpty()) {
|
||||||
|
m_sessionCache.clear();
|
||||||
return out;
|
return out;
|
||||||
|
}
|
||||||
const QByteArray ws = m_workspace.toUtf8();
|
const QByteArray ws = m_workspace.toUtf8();
|
||||||
const int kMax = 200;
|
const int kMax = 200;
|
||||||
char(*ids)[128] = new char[kMax][128];
|
char(*ids)[128] = new char[kMax][128];
|
||||||
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
||||||
const QString current = sessionId();
|
const QString current = sessionId();
|
||||||
|
QHash<QString, SessionCache> fresh;
|
||||||
|
fresh.reserve(n);
|
||||||
for (int i = 0; i < n; i++) {
|
for (int i = 0; i < n; i++) {
|
||||||
cst_session_info_t in;
|
const QString id = utf8(ids[i]);
|
||||||
memset(&in, 0, sizeof(in));
|
/* Filling one row walks that session's whole transcript, and the sidebar
|
||||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
* asks for the list on every store change: the transcript's own stat is
|
||||||
continue;
|
* what tells "nothing happened to this one" from "it grew", so an
|
||||||
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
* unchanged session costs a stat instead of a read. */
|
||||||
QVariantMap m;
|
char path[4096] = "";
|
||||||
m["id"] = id;
|
const bool have_path = cst_agent_store_path_for_id(
|
||||||
m["title"] = utf8(in.title);
|
ws.constData(), ids[i], path, sizeof(path)) == 0;
|
||||||
m["firstUser"] = utf8(in.first_user);
|
const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString());
|
||||||
m["messages"] = in.messages;
|
const qint64 mtime = fi.lastModified().toSecsSinceEpoch();
|
||||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
const qint64 size = fi.size();
|
||||||
|
SessionCache entry;
|
||||||
|
const auto cached = m_sessionCache.constFind(id);
|
||||||
|
if (cached != m_sessionCache.constEnd() && cached->mtime == mtime &&
|
||||||
|
cached->size == size) {
|
||||||
|
entry = *cached;
|
||||||
|
} else {
|
||||||
|
cst_session_info_t in;
|
||||||
|
memset(&in, 0, sizeof(in));
|
||||||
|
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||||
|
continue;
|
||||||
|
entry.mtime = mtime;
|
||||||
|
entry.size = size;
|
||||||
|
QVariantMap m;
|
||||||
|
m["id"] = id;
|
||||||
|
m["title"] = utf8(in.title);
|
||||||
|
m["firstUser"] = utf8(in.first_user);
|
||||||
|
m["messages"] = in.messages;
|
||||||
|
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||||
|
entry.row = m;
|
||||||
|
}
|
||||||
|
QVariantMap m = entry.row;
|
||||||
m["current"] = (id == current);
|
m["current"] = (id == current);
|
||||||
out.append(m);
|
out.append(m);
|
||||||
|
fresh.insert(id, entry);
|
||||||
}
|
}
|
||||||
delete[] ids;
|
delete[] ids;
|
||||||
|
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +877,9 @@ bool AgentCore::deleteSession(const QString &id) {
|
|||||||
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
||||||
if (id.isEmpty() || m_workspace.isEmpty())
|
if (id.isEmpty() || m_workspace.isEmpty())
|
||||||
return false;
|
return false;
|
||||||
|
/* A title is not in the transcript, so the cached rows would not notice the
|
||||||
|
* rename: drop them and let the next list read them again. */
|
||||||
|
m_sessionCache.clear();
|
||||||
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
||||||
t = title.toUtf8();
|
t = title.toUtf8();
|
||||||
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
||||||
@@ -1138,7 +1308,20 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
|||||||
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||||
const QString n = QString::fromUtf8(call ? call->name : "");
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||||
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); });
|
self->post([self, p, id, n, d] {
|
||||||
|
emit self->toolEvent(p, id, n, d);
|
||||||
|
/* The header says what the turn is doing right now ("shell: make -j8"),
|
||||||
|
* not just that something is happening: a long turn is otherwise
|
||||||
|
* indistinguishable from a stuck one. */
|
||||||
|
if (p == QLatin1String("done")) {
|
||||||
|
emit self->statusChanged(QString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QString what = d;
|
||||||
|
if (what.size() > 80)
|
||||||
|
what = what.left(79) + QStringLiteral("…");
|
||||||
|
emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#define CST_APP_AGENTCORE_H
|
#define CST_APP_AGENTCORE_H
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
@@ -156,6 +157,12 @@ class AgentCore : public QObject {
|
|||||||
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
/* Ask a question; emits the streaming signals, then turnFinished(). */
|
||||||
Q_INVOKABLE void send(const QString &prompt);
|
Q_INVOKABLE void send(const QString &prompt);
|
||||||
Q_INVOKABLE void cancel();
|
Q_INVOKABLE void cancel();
|
||||||
|
Q_INVOKABLE QString taskState() const;
|
||||||
|
Q_INVOKABLE void taskControl(bool readOnly, bool delegation, bool paused);
|
||||||
|
Q_INVOKABLE void cancelJob(const QString &id, bool force);
|
||||||
|
Q_INVOKABLE QString workerConfig() const;
|
||||||
|
Q_INVOKABLE QString saveWorkerConfig(const QString &json);
|
||||||
|
Q_INVOKABLE QString worktreeDiff(const QString &path, bool integrate);
|
||||||
|
|
||||||
/* Consent: answers map to CST_PERM_*. */
|
/* Consent: answers map to CST_PERM_*. */
|
||||||
Q_INVOKABLE void answerPermission(int choice);
|
Q_INVOKABLE void answerPermission(int choice);
|
||||||
@@ -204,6 +211,11 @@ class AgentCore : public QObject {
|
|||||||
void permissionRequested(const QString &tool, const QString &detail,
|
void permissionRequested(const QString &tool, const QString &detail,
|
||||||
const QString &preview);
|
const QString &preview);
|
||||||
void questionAsked(const QString &question, const QString &options);
|
void questionAsked(const QString &question, const QString &options);
|
||||||
|
/* The worker asked and nobody answered in time (or the app was in the
|
||||||
|
* background, where the dialog cannot be seen): the turn went on with a
|
||||||
|
* refusal, and the window puts its dialog away again. */
|
||||||
|
void permissionExpired(const QString &tool);
|
||||||
|
void questionExpired();
|
||||||
void turnFinished(int rc, const QString &err);
|
void turnFinished(int rc, const QString &err);
|
||||||
void runningChanged();
|
void runningChanged();
|
||||||
void workspaceChanged();
|
void workspaceChanged();
|
||||||
@@ -218,6 +230,10 @@ class AgentCore : public QObject {
|
|||||||
void syncConfig();
|
void syncConfig();
|
||||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||||
void afterSessionChange();
|
void afterSessionChange();
|
||||||
|
/* True when the app is not on screen: a phone in the background (Qt
|
||||||
|
* suspends it), or a window that is not there at all. A dialog opened now
|
||||||
|
* would be invisible, so consent is refused instead of waited for. */
|
||||||
|
bool backgrounded() const;
|
||||||
|
|
||||||
/* Hook trampolines (C, run on the worker thread). */
|
/* Hook trampolines (C, run on the worker thread). */
|
||||||
static void hookStatus(const char *msg, void *ud);
|
static void hookStatus(const char *msg, void *ud);
|
||||||
@@ -255,6 +271,15 @@ class AgentCore : public QObject {
|
|||||||
bool m_permPending = false;
|
bool m_permPending = false;
|
||||||
QString m_question;
|
QString m_question;
|
||||||
QString m_todo;
|
QString m_todo;
|
||||||
|
/* The session list rebuilt only when a transcript moved: filling one row
|
||||||
|
* walks the whole file, and the sidebar asks for the list on every store
|
||||||
|
* change. Keyed by id, dropped when the id is gone. */
|
||||||
|
struct SessionCache {
|
||||||
|
qint64 mtime = 0;
|
||||||
|
qint64 size = 0;
|
||||||
|
QVariantMap row;
|
||||||
|
};
|
||||||
|
mutable QHash<QString, SessionCache> m_sessionCache;
|
||||||
/* Names of the files this window attached, in the engine's pending order:
|
/* Names of the files this window attached, in the engine's pending order:
|
||||||
* the engine's own description says what it is, not where it came from. */
|
* the engine's own description says what it is, not where it came from. */
|
||||||
QStringList m_attachNames;
|
QStringList m_attachNames;
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/* The window's own tools, in Qt rather than in the engine: a document the
|
||||||
|
* person asked for, laid out as a PDF.
|
||||||
|
*
|
||||||
|
* Why here and not in the C engine: laying text out on pages is exactly what
|
||||||
|
* Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already
|
||||||
|
* links), while the engine has no layout code at all -- and md_to_html, its
|
||||||
|
* sibling, lives in the engine because rendering markdown is a shared
|
||||||
|
* capability. This one is a front-end capability: it is registered under
|
||||||
|
* owner "app:qt" and is available on every platform the app runs on.
|
||||||
|
*
|
||||||
|
* The path of a document is the one md_to_html walks: markdown (or a
|
||||||
|
* workspace .md/.html file) -> cst_web_md_document() -> a file inside the
|
||||||
|
* workspace -> cst_attach_import_artifact(), which is what turns it into a
|
||||||
|
* file card the person can open or share. The bytes never reach the model.
|
||||||
|
*
|
||||||
|
* Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only
|
||||||
|
* module (a phone has no print subsystem), and a round of
|
||||||
|
* pageSize + translate + drawContents per page is all a paginated PDF needs.
|
||||||
|
* The tool runs on the turn's worker thread, so a long document does not
|
||||||
|
* freeze the window. */
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QMarginsF>
|
||||||
|
#include <QPageLayout>
|
||||||
|
#include <QPageSize>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPdfWriter>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QString>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "core/AgentCore.h"
|
||||||
|
#include "core/ToolProvider.h"
|
||||||
|
#include "cst_agent.h"
|
||||||
|
#include "cst_attach.h"
|
||||||
|
#include "cst_tools.h"
|
||||||
|
#include "cst_web_md.h"
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/* md_to_html caps its input the same way: a document nobody could read is
|
||||||
|
* not worth laying out. */
|
||||||
|
constexpr int kMaxInputBytes = 8 * 1024 * 1024;
|
||||||
|
/* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the
|
||||||
|
* default 300 (a 40-page document is laid out and written in a moment). */
|
||||||
|
constexpr int kPdfDpi = 150;
|
||||||
|
|
||||||
|
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||||
|
for (int i = 0; i < call->nargs; i++)
|
||||||
|
if (strcmp(call->args[i].key, key) == 0)
|
||||||
|
return call->args[i].value;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int setOut(cst_tool_result_t *r, const QString &text, int rc) {
|
||||||
|
const QByteArray b = text.toUtf8();
|
||||||
|
r->out = strdup(b.constData());
|
||||||
|
r->out_len = r->out ? strlen(r->out) : 0;
|
||||||
|
r->exit_code = rc;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "docs/notes.md" -> "notes". */
|
||||||
|
QString stemOf(const QString &path) {
|
||||||
|
QString base = path;
|
||||||
|
const int slash = qMax(base.lastIndexOf(QLatin1Char('/')),
|
||||||
|
base.lastIndexOf(QLatin1Char('\\')));
|
||||||
|
if (slash >= 0)
|
||||||
|
base = base.mid(slash + 1);
|
||||||
|
const int dot = base.lastIndexOf(QLatin1Char('.'));
|
||||||
|
if (dot > 0)
|
||||||
|
base = base.left(dot);
|
||||||
|
return base.isEmpty() ? QStringLiteral("document") : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What the file is called: `out` when given (a directory takes the source's
|
||||||
|
* name inside it), otherwise the source's stem, or the title. */
|
||||||
|
QString outPathOf(const ctl_tool_call_t *c, const QString &stem) {
|
||||||
|
const char *out = argOf(c, "out");
|
||||||
|
QString name = out && out[0] ? QString::fromUtf8(out) : QString();
|
||||||
|
if (name.isEmpty())
|
||||||
|
return stem + QStringLiteral(".pdf");
|
||||||
|
if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\')))
|
||||||
|
return name + stem + QStringLiteral(".pdf");
|
||||||
|
if (!QFileInfo(name).fileName().contains(QLatin1Char('.')))
|
||||||
|
name += QStringLiteral(".pdf");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The HTML that goes into the page: an .html file is already a document, a
|
||||||
|
* .md file (or the `markdown` argument) goes through the engine's renderer,
|
||||||
|
* which is the same one the WebUI and md_to_html use. */
|
||||||
|
bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
QString *html, QString *out_name, QString *err) {
|
||||||
|
const char *md = argOf(c, "markdown");
|
||||||
|
const char *file = argOf(c, "file");
|
||||||
|
const char *given = argOf(c, "title");
|
||||||
|
QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||||
|
QByteArray text;
|
||||||
|
QString stem;
|
||||||
|
if (file && file[0]) {
|
||||||
|
char rel[4096] = "";
|
||||||
|
if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) {
|
||||||
|
*err = QStringLiteral("error: path outside the workspace or missing "
|
||||||
|
"parent: %1")
|
||||||
|
.arg(QString::fromUtf8(file));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QFile in(QString::fromUtf8(rel));
|
||||||
|
if (!in.open(QIODevice::ReadOnly)) {
|
||||||
|
*err = QStringLiteral("error: cannot read %1")
|
||||||
|
.arg(QString::fromUtf8(file));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (in.size() > kMaxInputBytes) {
|
||||||
|
*err = QStringLiteral("error: %1 is larger than %2 MB")
|
||||||
|
.arg(QString::fromUtf8(file))
|
||||||
|
.arg(kMaxInputBytes >> 20);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
text = in.readAll();
|
||||||
|
stem = stemOf(QString::fromUtf8(file));
|
||||||
|
const QString lower = QString::fromUtf8(file).toLower();
|
||||||
|
if (lower.endsWith(QLatin1String(".html")) ||
|
||||||
|
lower.endsWith(QLatin1String(".htm"))) {
|
||||||
|
/* already a document: what md_to_html wrote, or anything else */
|
||||||
|
*html = QString::fromUtf8(text);
|
||||||
|
if (title.isEmpty())
|
||||||
|
title = stem;
|
||||||
|
*out_name = outPathOf(c, stem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (md && md[0]) {
|
||||||
|
text = QByteArray(md);
|
||||||
|
if (stem.isEmpty())
|
||||||
|
stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title);
|
||||||
|
} else {
|
||||||
|
*err = QStringLiteral("error: give markdown, or a workspace .md or .html "
|
||||||
|
"file to render");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (title.isEmpty())
|
||||||
|
title = stem;
|
||||||
|
char *doc =
|
||||||
|
cst_web_md_document(text.constData(), static_cast<size_t>(text.size()),
|
||||||
|
title.toUtf8().constData());
|
||||||
|
if (!doc) {
|
||||||
|
*err = QStringLiteral("error: cannot render the markdown");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*html = QString::fromUtf8(doc);
|
||||||
|
free(doc);
|
||||||
|
*out_name = outPathOf(c, stem);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect,
|
||||||
|
* translated down per page, and the document is asked for exactly that much
|
||||||
|
* text at a time -- which is what makes Qt lay out pages instead of one long
|
||||||
|
* strip. */
|
||||||
|
bool writePdf(const QString &html, const QString &path, const QString &title,
|
||||||
|
QString *err) {
|
||||||
|
std::unique_ptr<QTextDocument> doc(paperDocument(html));
|
||||||
|
if (!title.isEmpty())
|
||||||
|
doc->setMetaInformation(QTextDocument::DocumentTitle, title);
|
||||||
|
|
||||||
|
QPdfWriter writer(path);
|
||||||
|
writer.setPageSize(QPageSize(QPageSize::A4));
|
||||||
|
writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter);
|
||||||
|
writer.setResolution(kPdfDpi);
|
||||||
|
if (!title.isEmpty())
|
||||||
|
writer.setTitle(title);
|
||||||
|
|
||||||
|
QPainter painter(&writer);
|
||||||
|
if (!painter.isActive()) {
|
||||||
|
*err = QStringLiteral("error: cannot draw into %1").arg(path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution());
|
||||||
|
doc->setPageSize(page.size());
|
||||||
|
const int pages = qMax(1, doc->pageCount());
|
||||||
|
for (int i = 0; i < pages; i++) {
|
||||||
|
if (i)
|
||||||
|
writer.newPage();
|
||||||
|
painter.save();
|
||||||
|
painter.translate(page.topLeft());
|
||||||
|
painter.translate(0, -i * page.height());
|
||||||
|
doc->drawContents(&painter,
|
||||||
|
QRectF(0, 0, page.width(), page.height())
|
||||||
|
.translated(0, i * page.height()));
|
||||||
|
painter.restore();
|
||||||
|
}
|
||||||
|
painter.end();
|
||||||
|
/* The painter says nothing about the file: the only proof the PDF landed is
|
||||||
|
* that there is one with bytes in it. */
|
||||||
|
if (QFileInfo(path).size() <= 0) {
|
||||||
|
*err = QStringLiteral("error: nothing was written to %1").arg(path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
char **out) {
|
||||||
|
(void)ctx;
|
||||||
|
const char *md = argOf(c, "markdown");
|
||||||
|
const char *file = argOf(c, "file");
|
||||||
|
if ((!md || !md[0]) && (!file || !file[0])) {
|
||||||
|
*out = strdup("error: give markdown, or a .md or .html file to render");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const QString stem =
|
||||||
|
file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document");
|
||||||
|
const QString name = outPathOf(c, stem);
|
||||||
|
QByteArray line;
|
||||||
|
if (file && file[0])
|
||||||
|
line = QStringLiteral("lay out %1 → %2 as a PDF\n")
|
||||||
|
.arg(QString::fromUtf8(file), name)
|
||||||
|
.toUtf8();
|
||||||
|
else
|
||||||
|
line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n")
|
||||||
|
.arg(md ? qstrlen(md) : 0)
|
||||||
|
.arg(name)
|
||||||
|
.toUtf8();
|
||||||
|
*out = strdup(line.constData());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
|
cst_tool_result_t *r, void *ud) {
|
||||||
|
(void)ud;
|
||||||
|
QString html, out_name, err;
|
||||||
|
if (!sourceHtml(ctx, c, &html, &out_name, &err))
|
||||||
|
return setOut(r, err, 1);
|
||||||
|
|
||||||
|
char full[4096] = "";
|
||||||
|
if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(),
|
||||||
|
full, sizeof(full)) != 0)
|
||||||
|
return setOut(r, QStringLiteral("error: path outside the workspace: %1")
|
||||||
|
.arg(out_name),
|
||||||
|
1);
|
||||||
|
const QString target = QString::fromUtf8(full);
|
||||||
|
/* A directory in the way of the name is what the engine's write tools call
|
||||||
|
* "missing parent": make it, the same way md_to_html does. */
|
||||||
|
QDir().mkpath(QFileInfo(target).absolutePath());
|
||||||
|
|
||||||
|
const char *given = argOf(c, "title");
|
||||||
|
const QString title = given && given[0] ? QString::fromUtf8(given) : QString();
|
||||||
|
if (!writePdf(html, target, title, &err))
|
||||||
|
return setOut(r, err, 1);
|
||||||
|
|
||||||
|
const qint64 bytes = QFileInfo(target).size();
|
||||||
|
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr;
|
||||||
|
cst_attachment_t *a = nullptr;
|
||||||
|
char aerr[256] = "";
|
||||||
|
if (session && session[0])
|
||||||
|
a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr));
|
||||||
|
|
||||||
|
QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n")
|
||||||
|
.arg(out_name)
|
||||||
|
.arg(bytes / 1024);
|
||||||
|
if (a)
|
||||||
|
text += QStringLiteral("(the user sees it as a file card they can open or "
|
||||||
|
"share; you will not be shown its bytes again)\n");
|
||||||
|
else if (aerr[0])
|
||||||
|
text += QStringLiteral("(the file is in the workspace; handing it over "
|
||||||
|
"failed: %1)\n")
|
||||||
|
.arg(QString::fromUtf8(aerr));
|
||||||
|
const int rc = setOut(r, text, 0);
|
||||||
|
if (a) {
|
||||||
|
r->attach = a;
|
||||||
|
r->nattach = 1;
|
||||||
|
}
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/* The document writePdf lays out, with a colour filled in for text that names
|
||||||
|
* none of its own.
|
||||||
|
*
|
||||||
|
* Paper is white and has no theme, but a QTextDocument takes its default text
|
||||||
|
* colour from the application palette: in a window running a dark palette --
|
||||||
|
* which is what a phone in dark mode hands Qt -- every plain markdown
|
||||||
|
* paragraph came out #eeeeee on a white page, i.e. a PDF that looks blank
|
||||||
|
* when printed, and reads as "the font is oddly faint" on screen. Naming the
|
||||||
|
* colour makes the page depend on the paper rather than on the window. A
|
||||||
|
* colour the document does name still wins (an .html file somebody handed us
|
||||||
|
* keeps its own), so this only fills the holes. */
|
||||||
|
QTextDocument *paperDocument(const QString &html) {
|
||||||
|
auto *doc = new QTextDocument;
|
||||||
|
doc->setDefaultStyleSheet(QStringLiteral("body { color: #000000; }"));
|
||||||
|
doc->setHtml(html);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int registerQtTools(AgentCore *core) {
|
||||||
|
if (!core)
|
||||||
|
return 0;
|
||||||
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||||
|
{"markdown", "string", 0, "markdown text to render"},
|
||||||
|
{"file", "string", 0,
|
||||||
|
"workspace .md file to render, or an .html one to lay out as it is"},
|
||||||
|
{"out", "string", 0,
|
||||||
|
"output path in the workspace (default: the source's name + .pdf)"},
|
||||||
|
{"title", "string", 0, "document title (default: the file name)"},
|
||||||
|
{nullptr, nullptr, 0, nullptr},
|
||||||
|
{nullptr, nullptr, 0, nullptr}};
|
||||||
|
static cst_tool_def_t def;
|
||||||
|
memset(&def, 0, sizeof(def));
|
||||||
|
setMeta(&def, "md_to_pdf",
|
||||||
|
"Write a document as a PDF: markdown (or a workspace .md/.html "
|
||||||
|
"file) in, a laid-out A4 PDF out, saved in the workspace and "
|
||||||
|
"handed to the person as a card they can open, print or share. "
|
||||||
|
"Use it when the answer should be something to keep or send.",
|
||||||
|
CST_TP_WRITE, "pdf", nullptr, nullptr);
|
||||||
|
memcpy(def.params, params, sizeof(params));
|
||||||
|
def.run_ex = mdToPdfRun;
|
||||||
|
def.preview = mdToPdfPreview;
|
||||||
|
def.owner = "app:qt";
|
||||||
|
return core->registerTool(&def) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -22,6 +22,8 @@ int registerPlatformTools(AgentCore *core) {
|
|||||||
#elif defined(__linux__)
|
#elif defined(__linux__)
|
||||||
n += platformRegisterLinux(core);
|
n += platformRegisterLinux(core);
|
||||||
#endif
|
#endif
|
||||||
|
/* Qt is not a platform API: these exist wherever the window does. */
|
||||||
|
n += registerQtTools(core);
|
||||||
/* A service may have appeared or gone since the last probe (a desktop
|
/* A service may have appeared or gone since the last probe (a desktop
|
||||||
* session starting, the notification daemon coming up). */
|
* session starting, the notification daemon coming up). */
|
||||||
cst_tool_meta_reload();
|
cst_tool_meta_reload();
|
||||||
@@ -35,6 +37,7 @@ void unregisterPlatformTools(AgentCore *core) {
|
|||||||
core->unregisterOwner(QStringLiteral("app:windows"));
|
core->unregisterOwner(QStringLiteral("app:windows"));
|
||||||
core->unregisterOwner(QStringLiteral("app:macos"));
|
core->unregisterOwner(QStringLiteral("app:macos"));
|
||||||
core->unregisterOwner(QStringLiteral("app:android"));
|
core->unregisterOwner(QStringLiteral("app:android"));
|
||||||
|
core->unregisterOwner(QStringLiteral("app:qt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
|
||||||
* travels through this header, so the class stays incomplete here. */
|
* travels through this header, so the class stays incomplete here. */
|
||||||
class AgentCore;
|
class AgentCore;
|
||||||
|
/* Same for the document md_to_pdf lays out (PdfTools.cpp): the caller owns
|
||||||
|
* it, and the test in ui/UiTest.cpp inspects how it draws. */
|
||||||
|
class QTextDocument;
|
||||||
|
|
||||||
namespace app {
|
namespace app {
|
||||||
|
|
||||||
@@ -44,6 +47,14 @@ extern const char *const kDesktopOnly[];
|
|||||||
/* The platform tools this app provides. Called once the engine exists;
|
/* The platform tools this app provides. Called once the engine exists;
|
||||||
* returns how many were registered. */
|
* returns how many were registered. */
|
||||||
int registerPlatformTools(AgentCore *core);
|
int registerPlatformTools(AgentCore *core);
|
||||||
|
/* The tools this front end provides that are the same on every platform
|
||||||
|
* (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt".
|
||||||
|
* Called next to registerPlatformTools(). */
|
||||||
|
int registerQtTools(AgentCore *core);
|
||||||
|
/* The document md_to_pdf lays out from `html`: the page's own defaults
|
||||||
|
* applied (text with no colour of its own is black, not the window's palette
|
||||||
|
* colour). The caller owns the result. */
|
||||||
|
QTextDocument *paperDocument(const QString &html);
|
||||||
/* Drop them again (they belong to the owners "app:*"). */
|
/* Drop them again (they belong to the owners "app:*"). */
|
||||||
void unregisterPlatformTools(AgentCore *core);
|
void unregisterPlatformTools(AgentCore *core);
|
||||||
|
|
||||||
@@ -93,6 +104,22 @@ bool hasAllFilesAccess();
|
|||||||
* dialog for it). A desktop, a phone that already has it or one too old to
|
* dialog for it). A desktop, a phone that already has it or one too old to
|
||||||
* know about it returns "" and nothing opens. */
|
* know about it returns "" and nothing opens. */
|
||||||
QString requestAllFilesAccess();
|
QString requestAllFilesAccess();
|
||||||
|
/* Take the whole screen. A phone's status bar -- and a tablet's taskbar, which
|
||||||
|
* is a status bar with icons in it -- is drawn *over* the window, and the
|
||||||
|
* window here draws from its own first pixel, so the bars sit on the app's top
|
||||||
|
* row and the controls there cannot be touched. Hiding them is what "full
|
||||||
|
* screen" means on a phone; a desktop has no such bars to ask about and
|
||||||
|
* returns "". Asking is repeatable and cheap: the bars come back with a swipe
|
||||||
|
* from the edge or a switch to another app, so the front end asks again every
|
||||||
|
* time the window comes to the front. Returns "" once they are gone, a reason
|
||||||
|
* otherwise (which the front end logs). */
|
||||||
|
QString requestFullscreen();
|
||||||
|
/* The arguments the Android activity was started with (the
|
||||||
|
* "applicationArguments" intent extra): the way
|
||||||
|
* `cst-agent-app --uitest --workspace DIR` is reachable on a device, since
|
||||||
|
* Qt's own plumbing for that extra does not reach this build. Empty
|
||||||
|
* everywhere else, and empty on a device that was not started with any. */
|
||||||
|
QStringList activityArguments();
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQuickStyle>
|
#include <QQuickStyle>
|
||||||
|
#include <QQuickWindow>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
#include "core/AgentCore.h"
|
#include "core/AgentCore.h"
|
||||||
#include "core/ToolProvider.h"
|
#include "core/ToolProvider.h"
|
||||||
#include "ui/UiTest.h"
|
#include "ui/UiTest.h"
|
||||||
|
#include "ui/BackKey.h"
|
||||||
#include "ui/SessionImages.h"
|
#include "ui/SessionImages.h"
|
||||||
#include "ui/ThemeIcons.h"
|
#include "ui/ThemeIcons.h"
|
||||||
#include "cst_version.h"
|
#include "cst_version.h"
|
||||||
@@ -193,13 +195,30 @@ int main(int argc, char **argv) {
|
|||||||
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
QCommandLineOption sizeOpt(QStringLiteral("size"),
|
||||||
QStringLiteral("initial window size, e.g. 480x900"),
|
QStringLiteral("initial window size, e.g. 480x900"),
|
||||||
QStringLiteral("WxH"));
|
QStringLiteral("WxH"));
|
||||||
|
/* A device run cannot be handed an environment (see activityArguments()), so
|
||||||
|
* what a desktop run asks for with CST_APP_SHOT=shot.png has to be an
|
||||||
|
* argument here. */
|
||||||
|
QCommandLineOption shotsOpt(
|
||||||
|
QStringLiteral("shots"),
|
||||||
|
QStringLiteral("--uitest: save a picture of the window at the interesting "
|
||||||
|
"points, in the workspace"));
|
||||||
parser.addOption(wsOpt);
|
parser.addOption(wsOpt);
|
||||||
parser.addOption(providerOpt);
|
parser.addOption(providerOpt);
|
||||||
parser.addOption(modelOpt);
|
parser.addOption(modelOpt);
|
||||||
parser.addOption(planOpt);
|
parser.addOption(planOpt);
|
||||||
parser.addOption(uitestOpt);
|
parser.addOption(uitestOpt);
|
||||||
parser.addOption(sizeOpt);
|
parser.addOption(sizeOpt);
|
||||||
parser.process(app);
|
parser.addOption(shotsOpt);
|
||||||
|
/* What the process was started with, plus -- on a phone -- what the
|
||||||
|
* activity was started with: Qt's own applicationArguments plumbing does
|
||||||
|
* not deliver the extra on the NX789J (see activityArguments()), and
|
||||||
|
* without it there is no way to hand this window a workspace or a --uitest
|
||||||
|
* run on a device that has no keyboard. */
|
||||||
|
QStringList args = QCoreApplication::arguments();
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
args += app::activityArguments();
|
||||||
|
#endif
|
||||||
|
parser.process(args);
|
||||||
|
|
||||||
QString ws = parser.value(wsOpt);
|
QString ws = parser.value(wsOpt);
|
||||||
if (ws.isEmpty()) {
|
if (ws.isEmpty()) {
|
||||||
@@ -226,6 +245,12 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Before the window exists: maybeShot() reads this at the point a picture is
|
||||||
|
* wanted (the settings dialog, the end of the run). */
|
||||||
|
if (parser.isSet(shotsOpt))
|
||||||
|
qputenv("CST_APP_SHOT",
|
||||||
|
(ws + QStringLiteral("/uitest-shot.png")).toUtf8());
|
||||||
|
|
||||||
#if defined(Q_OS_ANDROID)
|
#if defined(Q_OS_ANDROID)
|
||||||
/* Before the engine is created: its settings, credentials and session store
|
/* Before the engine is created: its settings, credentials and session store
|
||||||
* all resolve their paths from the environment. */
|
* all resolve their paths from the environment. */
|
||||||
@@ -326,5 +351,28 @@ int main(int argc, char **argv) {
|
|||||||
engine.load(url);
|
engine.load(url);
|
||||||
if (engine.rootObjects().isEmpty())
|
if (engine.rootObjects().isEmpty())
|
||||||
return 1;
|
return 1;
|
||||||
|
/* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */
|
||||||
|
app::installBackKey(engine.rootObjects().value(0),
|
||||||
|
engine.rootObjects().value(0));
|
||||||
|
/* The system bars are drawn over the window (see requestFullscreen): ask for
|
||||||
|
* the whole screen, and ask again whenever the app comes back to the front,
|
||||||
|
* because a swipe from the edge or a switch to another app brings them back.
|
||||||
|
* A refusal is not fatal -- it is logged and the window keeps working with
|
||||||
|
* the bars over its top row. */
|
||||||
|
{
|
||||||
|
const QString why = app::requestFullscreen();
|
||||||
|
if (!why.isEmpty())
|
||||||
|
qWarning("fullscreen: %s", qUtf8Printable(why));
|
||||||
|
QQuickWindow *qwin =
|
||||||
|
qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
|
||||||
|
if (qwin)
|
||||||
|
QObject::connect(qwin, &QQuickWindow::sceneGraphInitialized, qwin,
|
||||||
|
[] { app::requestFullscreen(); });
|
||||||
|
QObject::connect(&app, &QGuiApplication::applicationStateChanged, &app,
|
||||||
|
[](Qt::ApplicationState state) {
|
||||||
|
if (state == Qt::ApplicationActive)
|
||||||
|
app::requestFullscreen();
|
||||||
|
});
|
||||||
|
}
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFuture>
|
#include <QFuture>
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
|
#include <QJniEnvironment>
|
||||||
#include <QJniObject>
|
#include <QJniObject>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
|
#include <QSemaphore>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/AgentCore.h"
|
#include "core/AgentCore.h"
|
||||||
@@ -59,16 +62,34 @@ void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
|
|||||||
r->exit_code = rc;
|
r->exit_code = rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* How long the worker is willing to wait for the GUI thread to pick its work
|
||||||
|
* up. The hop itself is always waited for to the end (the closure writes into
|
||||||
|
* the caller's stack), so what this bounds is the case that makes a turn look
|
||||||
|
* stuck: a GUI thread that is not getting to the event loop at all. */
|
||||||
|
constexpr int kGuiStallMs = 3000;
|
||||||
|
|
||||||
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
|
||||||
* run on the GUI thread; the turn's worker thread waits for it. */
|
* run on the GUI thread; the turn's worker thread waits for it.
|
||||||
|
*
|
||||||
|
* The wait is bounded: a blocking queued call to a GUI thread that is busy
|
||||||
|
* (the session list reading transcripts used to do exactly that) would park
|
||||||
|
* the worker for as long as it lasts. A cheap ping first tells whether the
|
||||||
|
* GUI thread is there at all; only then is the real work handed over and
|
||||||
|
* waited for, which keeps the closure valid for its whole life. */
|
||||||
template <typename Fn>
|
template <typename Fn>
|
||||||
void onGuiThread(Fn &&fn) {
|
bool onGuiThread(Fn &&fn) {
|
||||||
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
if (!qApp || QThread::currentThread() == qApp->thread()) {
|
||||||
fn();
|
fn();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
auto pinged = std::make_shared<QSemaphore>(0);
|
||||||
|
QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); },
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
if (!pinged->tryAcquire(1, kGuiStallMs))
|
||||||
|
return false; /* a GUI thread that is not answering: do not wait on it */
|
||||||
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
|
||||||
Qt::BlockingQueuedConnection);
|
Qt::BlockingQueuedConnection);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
jint sdkInt() {
|
jint sdkInt() {
|
||||||
@@ -81,6 +102,80 @@ QJniObject appContext() {
|
|||||||
return QNativeInterface::QAndroidApplication::context();
|
return QNativeInterface::QAndroidApplication::context();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The strings the activity was started with. Qt is meant to hand the
|
||||||
|
* "applicationArguments" extra to QCoreApplication::arguments() by itself; on
|
||||||
|
* the NX789J it does not -- `am start ... --esa applicationArguments
|
||||||
|
* "--workspace,/sdcard/x"` left the window on its default workspace, and so
|
||||||
|
* did the same extra as a plain string. So the intent is asked directly, which
|
||||||
|
* is what makes `cst-agent-app --uitest --workspace DIR` (or any other
|
||||||
|
* argument) reachable on a device with no keyboard attached.
|
||||||
|
*
|
||||||
|
* Everything here degrades to "no arguments": an invalid object, a missing
|
||||||
|
* method, or a context that is not an activity all come back empty. */
|
||||||
|
QStringList intentArguments() {
|
||||||
|
QStringList out;
|
||||||
|
QJniObject activity = appContext();
|
||||||
|
if (!activity.isValid())
|
||||||
|
return out;
|
||||||
|
QJniObject intent =
|
||||||
|
activity.callObjectMethod("getIntent", "()Landroid/content/Intent;");
|
||||||
|
if (!intent.isValid())
|
||||||
|
return out;
|
||||||
|
const QJniObject key =
|
||||||
|
QJniObject::fromString(QStringLiteral("applicationArguments"));
|
||||||
|
/* Three shapes for the same extra, and which one arrives depends on who
|
||||||
|
* sent it: `am start --esa applicationArguments "--uitest,--workspace,/x"`
|
||||||
|
* stores a String[] (an empty launch, arguments and all, is what that cost
|
||||||
|
* us -- getStringArrayListExtra answers null for an array), a launcher or
|
||||||
|
* Qt's own loader uses putStringArrayListExtra, and `--es` puts one
|
||||||
|
* comma-separated string there. Try each, in that order. */
|
||||||
|
const QJniObject array = intent.callObjectMethod(
|
||||||
|
"getStringArrayExtra", "(Ljava/lang/String;)[Ljava/lang/String;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (array.isValid()) {
|
||||||
|
QJniEnvironment env;
|
||||||
|
const jobjectArray a = reinterpret_cast<jobjectArray>(array.object());
|
||||||
|
const jsize n = env->GetArrayLength(a);
|
||||||
|
for (jsize i = 0; i < n; i++) {
|
||||||
|
const jstring item =
|
||||||
|
reinterpret_cast<jstring>(env->GetObjectArrayElement(a, i));
|
||||||
|
if (!item)
|
||||||
|
continue;
|
||||||
|
const char *c = env->GetStringUTFChars(item, nullptr);
|
||||||
|
if (c) {
|
||||||
|
out << QString::fromUtf8(c);
|
||||||
|
env->ReleaseStringUTFChars(item, c);
|
||||||
|
}
|
||||||
|
env->DeleteLocalRef(item);
|
||||||
|
}
|
||||||
|
if (!out.isEmpty())
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
QJniObject list = intent.callObjectMethod(
|
||||||
|
"getStringArrayListExtra", "(Ljava/lang/String;)Ljava/util/ArrayList;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (list.isValid()) {
|
||||||
|
const jint n = list.callMethod<jint>("size", "()I");
|
||||||
|
for (jint i = 0; i < n; i++) {
|
||||||
|
QJniObject item =
|
||||||
|
list.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
|
||||||
|
if (item.isValid())
|
||||||
|
out << item.toString();
|
||||||
|
}
|
||||||
|
if (!out.isEmpty())
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const QJniObject one = intent.callObjectMethod(
|
||||||
|
"getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;",
|
||||||
|
key.object<jstring>());
|
||||||
|
if (one.isValid()) {
|
||||||
|
const QStringList parts =
|
||||||
|
one.toString().split(QLatin1Char(','), Qt::SkipEmptyParts);
|
||||||
|
out << parts;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- notification_send --------------------------------------------------- */
|
/* --- notification_send --------------------------------------------------- */
|
||||||
|
|
||||||
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
/* Android 13 (API 33) put notifications behind a runtime permission; before
|
||||||
@@ -496,6 +591,11 @@ cst_tool_def_t g_share;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
/* What main() asks for (ToolProvider.h): the reading itself is the JNI helper
|
||||||
|
* above, next to the Android context it needs. It sits outside the anonymous
|
||||||
|
* namespace so main.cpp can link to it. */
|
||||||
|
QStringList activityArguments() { return intentArguments(); }
|
||||||
|
|
||||||
/* A file card's "open": whatever app the phone has for the type, with the read
|
/* A file card's "open": whatever app the phone has for the type, with the read
|
||||||
* permission granted to the app that picks it up. */
|
* permission granted to the app that picks it up. */
|
||||||
QString openLocalFile(const QString &path, const QString &title,
|
QString openLocalFile(const QString &path, const QString &title,
|
||||||
@@ -746,6 +846,65 @@ QString requestAllFilesAccess() {
|
|||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The status bar -- and on a tablet in desktop mode the taskbar, which is a
|
||||||
|
* status bar full of icons -- is drawn over the window, and this window draws
|
||||||
|
* from its own first pixel, so the bars land on the app's top row and the
|
||||||
|
* controls there cannot be touched. Which call hides them depends on the API
|
||||||
|
* level: WindowInsetsController from Android 11 (API 30) on, the older
|
||||||
|
* systemUiVisibility flags before it. Measured on the tablet: the app's own
|
||||||
|
* title row was under the taskbar, this puts the window on the whole screen
|
||||||
|
* and the row is touchable again.
|
||||||
|
*
|
||||||
|
* Nothing here is fatal: an invalid context, a missing class or a null insets
|
||||||
|
* controller comes back as a reason (which main.cpp logs) and the window keeps
|
||||||
|
* working with the bars over it. */
|
||||||
|
QString requestFullscreen() {
|
||||||
|
QString err;
|
||||||
|
onGuiThread([&] {
|
||||||
|
QJniObject activity = appContext();
|
||||||
|
if (!activity.isValid()) {
|
||||||
|
err = QStringLiteral("no activity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QJniObject window =
|
||||||
|
activity.callObjectMethod("getWindow", "()Landroid/view/Window;");
|
||||||
|
if (!window.isValid()) {
|
||||||
|
err = QStringLiteral("no window");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sdkInt() >= 30) {
|
||||||
|
/* Edge to edge plus the bars hidden: the window is told to fill the
|
||||||
|
* screen itself rather than lay out inside the bars. */
|
||||||
|
window.callMethod<void>("setDecorFitsSystemWindows", "(Z)V", false);
|
||||||
|
QJniObject control = window.callObjectMethod(
|
||||||
|
"getInsetsController", "()Landroid/view/WindowInsetsController;");
|
||||||
|
if (!control.isValid()) {
|
||||||
|
err = QStringLiteral("no insets controller");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jint bars = QJniObject::callStaticMethod<jint>(
|
||||||
|
"android/view/WindowInsets$Type", "systemBars", "()I");
|
||||||
|
control.callMethod<void>("hide", "(I)V", bars);
|
||||||
|
/* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the edge brings
|
||||||
|
* the bars back for a moment and they leave again by themselves. */
|
||||||
|
control.callMethod<void>("setSystemBarsBehavior", "(I)V", 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QJniObject decor =
|
||||||
|
window.callObjectMethod("getDecorView", "()Landroid/view/View;");
|
||||||
|
if (!decor.isValid()) {
|
||||||
|
err = QStringLiteral("no decor view");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* SYSTEM_UI_FLAG_HIDE_NAVIGATION | FULLSCREEN | LAYOUT_STABLE |
|
||||||
|
* LAYOUT_HIDE_NAVIGATION | LAYOUT_FULLSCREEN | IMMERSIVE_STICKY */
|
||||||
|
const jint flags = 0x00000002 | 0x00000004 | 0x00000100 | 0x00000200 |
|
||||||
|
0x00000400 | 0x00001000;
|
||||||
|
decor.callMethod<void>("setSystemUiVisibility", "(I)V", flags);
|
||||||
|
});
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
int platformRegisterAndroid(AgentCore *core) {
|
int platformRegisterAndroid(AgentCore *core) {
|
||||||
if (!core)
|
if (!core)
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
|||||||
bool hasAllFilesAccess() { return true; }
|
bool hasAllFilesAccess() { return true; }
|
||||||
QString requestAllFilesAccess() { return QString(); }
|
QString requestAllFilesAccess() { return QString(); }
|
||||||
|
|
||||||
|
/* A desktop window is not drawn under anything of the desktop's own: the bars
|
||||||
|
* a panel draws are the panel's, and nothing here can be asked for them. */
|
||||||
|
QString requestFullscreen() { return QString(); }
|
||||||
|
|
||||||
int platformRegisterLinux(AgentCore *core) {
|
int platformRegisterLinux(AgentCore *core) {
|
||||||
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||||
{"message", "string", 1, "text of the notification"},
|
{"message", "string", 1, "text of the notification"},
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/* Platform tools on Windows, implemented by the app (not by the engine): they
|
||||||
|
* exist exactly where the desktop service does, and the engine only ever sees
|
||||||
|
* the capability -- platforms + probe decide whether the tool is offered.
|
||||||
|
*
|
||||||
|
* The Linux file next door is the same shape; what differs is the service
|
||||||
|
* behind each call. A notification is the scripting host's popup (Wscript.Shell
|
||||||
|
* through PowerShell) because Windows has no notify-send and a toast needs the
|
||||||
|
* WinRT API; it carries the same two arguments and answers the same way. */
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QDesktopServices>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "core/AgentCore.h"
|
||||||
|
#include "core/ToolProvider.h"
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/* whereis-style check without a shell: PATH is split on the Windows separator
|
||||||
|
* and a name may need one of the executable suffixes. */
|
||||||
|
bool haveProgram(const char *name) {
|
||||||
|
const QByteArray path = qgetenv("PATH");
|
||||||
|
const QList<QByteArray> dirs = path.split(';');
|
||||||
|
const QList<QByteArray> exts = {QByteArray(), QByteArray(".exe"),
|
||||||
|
QByteArray(".cmd"), QByteArray(".bat")};
|
||||||
|
for (const QByteArray &d : dirs) {
|
||||||
|
if (d.isEmpty())
|
||||||
|
continue;
|
||||||
|
for (const QByteArray &ext : exts) {
|
||||||
|
const QString cand = QFileInfo(QString::fromLocal8Bit(d + "\\" + name +
|
||||||
|
ext))
|
||||||
|
.absoluteFilePath();
|
||||||
|
if (QFileInfo::exists(cand) && QFileInfo(cand).isExecutable())
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 0 when notifications can be shown; -1 with the reason otherwise. */
|
||||||
|
int notifyProbe(char *why, size_t why_sz) {
|
||||||
|
if (haveProgram("powershell") || haveProgram("pwsh"))
|
||||||
|
return 0;
|
||||||
|
snprintf(why, why_sz, "no powershell on PATH to show a popup with");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *argOf(const ctl_tool_call_t *call, const char *key) {
|
||||||
|
for (int i = 0; i < call->nargs; i++)
|
||||||
|
if (strcmp(call->args[i].key, key) == 0)
|
||||||
|
return call->args[i].value;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Runs on the turn's worker thread: no GUI objects here. The popup is started
|
||||||
|
* detached -- it waits for its own timeout, and the turn must not. */
|
||||||
|
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
|
||||||
|
cst_tool_result_t *r, void *ud) {
|
||||||
|
(void)ctx;
|
||||||
|
(void)ud;
|
||||||
|
const char *title = argOf(call, "title");
|
||||||
|
const char *message = argOf(call, "message");
|
||||||
|
if (!message || !message[0]) {
|
||||||
|
const char *msg = "error: notification_send needs a message";
|
||||||
|
r->out = strdup(msg);
|
||||||
|
r->out_len = r->out ? strlen(msg) : 0;
|
||||||
|
r->exit_code = 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const QString shown =
|
||||||
|
title && title[0] ? QString::fromUtf8(title) : QStringLiteral("CST-agent");
|
||||||
|
/* The popup's own text is single-quoted for PowerShell; a quote inside it is
|
||||||
|
* doubled, which is how PowerShell escapes one. */
|
||||||
|
QString text = QString::fromUtf8(message);
|
||||||
|
text.replace(QLatin1Char('\''), QLatin1String("''"));
|
||||||
|
QString heading = shown;
|
||||||
|
heading.replace(QLatin1Char('\''), QLatin1String("''"));
|
||||||
|
const QString script =
|
||||||
|
QStringLiteral("$w = New-Object -ComObject Wscript.Shell; "
|
||||||
|
"$w.Popup('%1', 8, '%2', 64) | Out-Null")
|
||||||
|
.arg(text, heading);
|
||||||
|
const QString exe = haveProgram("pwsh") ? QStringLiteral("pwsh")
|
||||||
|
: QStringLiteral("powershell");
|
||||||
|
QStringList args;
|
||||||
|
args << QStringLiteral("-NoProfile") << QStringLiteral("-NonInteractive")
|
||||||
|
<< QStringLiteral("-Command") << script;
|
||||||
|
const bool started = QProcess::startDetached(exe, args);
|
||||||
|
const QByteArray out = started
|
||||||
|
? QByteArrayLiteral("notification shown")
|
||||||
|
: QByteArrayLiteral("could not start ") +
|
||||||
|
exe.toLocal8Bit();
|
||||||
|
r->out = strdup(out.constData());
|
||||||
|
r->out_len = r->out ? strlen(r->out) : 0;
|
||||||
|
r->exit_code = started ? 0 : 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Registered once; the engine keeps a copy until the owner is unregistered. */
|
||||||
|
cst_tool_def_t g_notify;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/* A file card's "open": ShellExecute through QDesktopServices, so the file's
|
||||||
|
* own application comes up. */
|
||||||
|
QString openLocalFile(const QString &path, const QString &title,
|
||||||
|
QString *status) {
|
||||||
|
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||||
|
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||||
|
if (status)
|
||||||
|
*status = QStringLiteral("opened %1 (test mode)").arg(name);
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
if (!QFileInfo::exists(path))
|
||||||
|
return QStringLiteral("the file is gone: %1").arg(name);
|
||||||
|
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
|
||||||
|
return QStringLiteral("no application on this machine opens %1").arg(name);
|
||||||
|
if (status)
|
||||||
|
*status = QStringLiteral("opened %1").arg(name);
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No share sheet here: the path is what another program needs, so it goes to
|
||||||
|
* the clipboard. */
|
||||||
|
QString shareLocalFile(const QString &path, const QString &title,
|
||||||
|
QString *status) {
|
||||||
|
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
|
||||||
|
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
|
||||||
|
if (status)
|
||||||
|
*status = QStringLiteral("shared %1 (test mode)").arg(name);
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
if (!QFileInfo::exists(path))
|
||||||
|
return QStringLiteral("the file is gone: %1").arg(name);
|
||||||
|
QGuiApplication::clipboard()->setText(path);
|
||||||
|
if (status)
|
||||||
|
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Windows does not freeze a process whose window is open, and the app's own
|
||||||
|
* window is what keeps it alive: nothing to hold. */
|
||||||
|
QString holdForeground(const QString &text) {
|
||||||
|
(void)text;
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
void releaseForeground() {}
|
||||||
|
|
||||||
|
/* A native file dialog hands over a path, so there is nothing to turn into
|
||||||
|
* one: a file:// URL loses its scheme and anything else is taken as a path. */
|
||||||
|
QString materializeAttachment(const QString &pathOrUri, QString *err) {
|
||||||
|
(void)err;
|
||||||
|
QString s = pathOrUri;
|
||||||
|
if (s.startsWith(QLatin1String("file://")))
|
||||||
|
s = s.mid(7);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every process on Windows reads the files its user can. */
|
||||||
|
bool hasAllFilesAccess() { return true; }
|
||||||
|
QString requestAllFilesAccess() { return QString(); }
|
||||||
|
|
||||||
|
/* A Windows window has its own frame; there is no system bar drawn over it. */
|
||||||
|
QString requestFullscreen() { return QString(); }
|
||||||
|
|
||||||
|
int platformRegisterWindows(AgentCore *core) {
|
||||||
|
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
|
||||||
|
{"message", "string", 1, "text of the notification"},
|
||||||
|
{"title", "string", 0, "title line (default: CST-agent)"},
|
||||||
|
{nullptr, nullptr, 0, nullptr},
|
||||||
|
{nullptr, nullptr, 0, nullptr},
|
||||||
|
{nullptr, nullptr, 0, nullptr},
|
||||||
|
{nullptr, nullptr, 0, nullptr}};
|
||||||
|
memset(&g_notify, 0, sizeof(g_notify));
|
||||||
|
setMeta(&g_notify, "notification_send",
|
||||||
|
"Show a notification outside the window (a popup that goes away by "
|
||||||
|
"itself).",
|
||||||
|
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
|
||||||
|
memcpy(g_notify.params, params, sizeof(params));
|
||||||
|
g_notify.run = nullptr;
|
||||||
|
g_notify.run_ex = notifyRun;
|
||||||
|
g_notify.ud = nullptr;
|
||||||
|
g_notify.owner = "app:windows";
|
||||||
|
return core->registerTool(&g_notify) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#include "ui/BackKey.h"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVariant>
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class BackKeyFilter : public QObject {
|
||||||
|
public:
|
||||||
|
BackKeyFilter(QObject *window, QObject *root)
|
||||||
|
: QObject(window), m_window(window), m_root(root) {}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool eventFilter(QObject *watched, QEvent *event) override {
|
||||||
|
if (!m_root || watched != m_window ||
|
||||||
|
(event->type() != QEvent::KeyPress &&
|
||||||
|
event->type() != QEvent::KeyRelease))
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
QKeyEvent *key = static_cast<QKeyEvent *>(event);
|
||||||
|
if (key->key() != Qt::Key_Back)
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
/* The key is down and up: a press that was taken is followed by a release
|
||||||
|
* that goes with it, so the platform never sees half of a key it did not
|
||||||
|
* get. */
|
||||||
|
if (event->type() == QEvent::KeyRelease)
|
||||||
|
return m_tookPress ? (m_tookPress = false, true)
|
||||||
|
: QObject::eventFilter(watched, event);
|
||||||
|
QVariant layer;
|
||||||
|
if (!QMetaObject::invokeMethod(m_root, "backLayer",
|
||||||
|
Q_RETURN_ARG(QVariant, layer)))
|
||||||
|
return QObject::eventFilter(watched, event);
|
||||||
|
if (layer.toString().isEmpty())
|
||||||
|
return QObject::eventFilter(watched, event); /* nothing open: not ours */
|
||||||
|
/* One layer back, on the next turn of the event loop (the dialogs insist,
|
||||||
|
* see Main.qml's stepBack). The key is spent: the window stays. */
|
||||||
|
QMetaObject::invokeMethod(m_root, "stepBack");
|
||||||
|
m_tookPress = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QObject *m_window; /* whose keys are read; outlives this filter */
|
||||||
|
QObject *m_root; /* the window's QML root */
|
||||||
|
bool m_tookPress = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void installBackKey(QObject *window, QObject *root) {
|
||||||
|
if (!window || !root)
|
||||||
|
return;
|
||||||
|
/* On the application, not on the window: a key press arrives here as an
|
||||||
|
* event sent to the window, but a filter installed on the window itself
|
||||||
|
* never sees one (measured -- a window-installed filter stayed silent while
|
||||||
|
* the same key closed the drawer through a QML Shortcut). The filter keeps
|
||||||
|
* to `window`'s own events, so nothing else in the process is touched. */
|
||||||
|
QCoreApplication::instance()->installEventFilter(
|
||||||
|
new BackKeyFilter(window, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* The phone's back key, read before anything else sees the event.
|
||||||
|
*
|
||||||
|
* Android hands its back button to the window as a Qt::Key_Back key press, and
|
||||||
|
* the platform leaves the activity (the window goes away) when nothing takes
|
||||||
|
* the key. What takes it here is one layer at a time -- the dialog on top,
|
||||||
|
* then the sliding sidebar -- and only while there is a layer: the window's
|
||||||
|
* own QML answers which (Main.qml's backLayer()) and closes it (stepBack()).
|
||||||
|
*
|
||||||
|
* This lives in C++ rather than in a QML Shortcut because a window-level
|
||||||
|
* shortcut never sees the key while a modal popup is up: the dialog keeps it
|
||||||
|
* (a desktop run that presses Back with the settings dialog open shows the
|
||||||
|
* shortcut does not fire, while the drawer -- no popup -- closes fine). A
|
||||||
|
* filter on the window runs before the shortcut map, the popup and the focused
|
||||||
|
* item, so the same key does the same thing on every platform; with nothing
|
||||||
|
* open the key is passed on untouched, which is what leaves the window on a
|
||||||
|
* phone and does nothing on a desktop.
|
||||||
|
*
|
||||||
|
* A window must not be given a second one: installBackKey() installs once. */
|
||||||
|
#ifndef CST_APP_BACK_KEY_H
|
||||||
|
#define CST_APP_BACK_KEY_H
|
||||||
|
|
||||||
|
class QObject;
|
||||||
|
|
||||||
|
namespace app {
|
||||||
|
|
||||||
|
/* `window` is the QQuickWindow (the object key events are sent to), `root` the
|
||||||
|
* QML root object of that window (the one carrying backLayer()/stepBack()).
|
||||||
|
* The filter is owned by the window. */
|
||||||
|
void installBackKey(QObject *window, QObject *root);
|
||||||
|
|
||||||
|
} // namespace app
|
||||||
|
|
||||||
|
#endif // CST_APP_BACK_KEY_H
|
||||||
@@ -11,25 +11,35 @@
|
|||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QBuffer>
|
#include <QBuffer>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
|
#include <QPalette>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QPointingDevice>
|
#include <QPointingDevice>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
|
#include <QQuickStyle>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
#include <QQmlContext>
|
#include <QQmlContext>
|
||||||
#include <QQuickWindow>
|
#include <QQuickWindow>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
|
#include <QTextDocument>
|
||||||
|
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
#include "cst_attach.h"
|
#include "cst_attach.h"
|
||||||
|
#include "core/ToolProvider.h"
|
||||||
|
#include "ui/BackKey.h"
|
||||||
#include "ui/SessionImages.h"
|
#include "ui/SessionImages.h"
|
||||||
#include "ui/ThemeIcons.h"
|
#include "ui/ThemeIcons.h"
|
||||||
|
|
||||||
@@ -38,13 +48,61 @@ namespace {
|
|||||||
|
|
||||||
int g_fail;
|
int g_fail;
|
||||||
|
|
||||||
|
/* The run's own log file, opened once the workspace is known (see the top of
|
||||||
|
* the run below): every line stdout gets is written here as well. On a phone
|
||||||
|
* stdout reaches nobody, and an app started from adb cannot be handed
|
||||||
|
* CST_APP_SHOT or any other environment -- so `--uitest --workspace DIR` plus
|
||||||
|
* `adb pull DIR/uitest.log` is how a device run is read back. Empty when the
|
||||||
|
* file could not be made, in which case the run only prints. */
|
||||||
|
QFile g_runLog;
|
||||||
|
|
||||||
|
/* Qt's own messages go into the same log as the checks: this is where QML
|
||||||
|
* puts "TypeError: Cannot read property ... of undefined" and every other
|
||||||
|
* reason a step did nothing, and a device run has nowhere else to show it
|
||||||
|
* (logcat does not carry this build's Qt messages -- a plain qInfo from the
|
||||||
|
* app is not in the buffer). Messages before the log is opened still print. */
|
||||||
|
void logQtMessage(QtMsgType type, const QMessageLogContext &, const QString &msg) {
|
||||||
|
const char *kind = type == QtDebugMsg ? "debug"
|
||||||
|
: type == QtInfoMsg ? "info"
|
||||||
|
: type == QtWarningMsg ? "warn"
|
||||||
|
: type == QtCriticalMsg ? "crit"
|
||||||
|
: "fatal";
|
||||||
|
const QByteArray line = QByteArray(kind) + " " + msg.toUtf8() + "\n";
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write(line);
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
|
fwrite(line.constData(), 1, static_cast<size_t>(line.size()), stderr);
|
||||||
|
if (type == QtFatalMsg)
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
|
||||||
void check(bool ok, const QString &what) {
|
void check(bool ok, const QString &what) {
|
||||||
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
|
||||||
std::fflush(stdout);
|
std::fflush(stdout);
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write(ok ? "ok " : "FAIL");
|
||||||
|
g_runLog.write(what.toUtf8());
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
if (!ok)
|
if (!ok)
|
||||||
g_fail++;
|
g_fail++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A line in the run's log that is not a check: what the window looked like at
|
||||||
|
* a step, for the cases where "it did nothing" is all the checks can say. */
|
||||||
|
void note(const QString &what) {
|
||||||
|
std::printf("note %s\n", qPrintable(what));
|
||||||
|
std::fflush(stdout);
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write("note ");
|
||||||
|
g_runLog.write(what.toUtf8());
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Every item with this objectName in the visual tree. A view that has just
|
/* Every item with this objectName in the visual tree. A view that has just
|
||||||
* been replayed can still hold the delegates it is about to drop, so a name
|
* been replayed can still hold the delegates it is about to drop, so a name
|
||||||
* can match more than one item for a moment. */
|
* can match more than one item for a moment. */
|
||||||
@@ -72,11 +130,20 @@ int countNamed(QQuickItem *root, const QString &prefix) {
|
|||||||
/* An item by objectName. The QObject tree is tried first (it holds the named
|
/* An item by objectName. The QObject tree is tried first (it holds the named
|
||||||
* controls); a view's delegates can be owned by the model instead, so the
|
* controls); a view's delegates can be owned by the model instead, so the
|
||||||
* visual tree -- what a finger can actually reach -- is walked as well. */
|
* visual tree -- what a finger can actually reach -- is walked as well. */
|
||||||
|
bool onScreen(QQuickItem *it); /* defined below: isVisible() is not enough */
|
||||||
|
|
||||||
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
||||||
QList<QQuickItem *> hits;
|
QList<QQuickItem *> hits;
|
||||||
collectNamed(win->contentItem(), name, &hits);
|
collectNamed(win->contentItem(), name, &hits);
|
||||||
/* What a finger can reach is the one the test means: a delegate on its way
|
/* What a finger can reach is the one the test means. Two things say
|
||||||
* out is still in the tree but invisible. */
|
* "visible" without being reachable: a delegate on its way out, and a
|
||||||
|
* second copy of a control that lives in a closed drawer (the sidebar is
|
||||||
|
* drawn from the window and, when narrow, from a drawer off to the left).
|
||||||
|
* Which copy comes first in the tree is not the same on every platform, so
|
||||||
|
* the one whose centre is really on screen wins. */
|
||||||
|
for (QQuickItem *it : hits)
|
||||||
|
if (onScreen(it))
|
||||||
|
return it;
|
||||||
for (QQuickItem *it : hits)
|
for (QQuickItem *it : hits)
|
||||||
if (it->isVisible())
|
if (it->isVisible())
|
||||||
return it;
|
return it;
|
||||||
@@ -91,12 +158,87 @@ QPoint centre(QQuickItem *it) {
|
|||||||
return p.toPoint();
|
return p.toPoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Is this item's centre really reachable? isVisible() answers whether hiding
|
||||||
|
* was asked for, not whether a clipping ancestor (a scrolling view) has moved
|
||||||
|
* it out of sight -- and an event goes to whatever is at that point instead. */
|
||||||
|
bool onScreen(QQuickItem *it) {
|
||||||
|
if (!it || !it->isVisible() || it->width() <= 0 || it->height() <= 0)
|
||||||
|
return false;
|
||||||
|
QQuickWindow *win = it->window();
|
||||||
|
if (!win)
|
||||||
|
return false;
|
||||||
|
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
|
||||||
|
if (!QRectF(0, 0, win->width(), win->height()).contains(p))
|
||||||
|
return false;
|
||||||
|
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
|
||||||
|
if (!a->clip())
|
||||||
|
continue;
|
||||||
|
if (!a->mapRectToScene(QRectF(0, 0, a->width(), a->height())).contains(p))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An event lands on whatever is at that point, and isVisible() does not know
|
||||||
|
* about a scrolling ancestor's clipping (see onScreen): an item the view has
|
||||||
|
* scrolled past still says it is visible, and the click goes to the row that
|
||||||
|
* happens to be at that pixel -- which is a different row on a window whose
|
||||||
|
* font makes the rows taller (another platform, another Qt). So a click
|
||||||
|
* scrolls its target in first, which is what a person does. The nearest
|
||||||
|
* ancestor that both clips and scrolls (a ListView's own item) is the one to
|
||||||
|
* move; anchored to its middle, so a row at either edge still gets its whole
|
||||||
|
* centre inside the viewport. */
|
||||||
|
void scrollIntoView(QQuickItem *it, bool middle = false) {
|
||||||
|
if (!it || (!middle && onScreen(it)))
|
||||||
|
return;
|
||||||
|
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
|
||||||
|
const QVariant contentY = a->property("contentY");
|
||||||
|
if (!a->clip() || !contentY.isValid() ||
|
||||||
|
!a->property("contentHeight").isValid())
|
||||||
|
continue;
|
||||||
|
const double y = it->mapToItem(a, QPointF(0, 0)).y() + contentY.toDouble();
|
||||||
|
const double lowest = a->property("contentHeight").toDouble() - a->height();
|
||||||
|
note(QStringLiteral("%1 is out of the viewport (contentY %2, at %3 of %4): "
|
||||||
|
"scrolling to it")
|
||||||
|
.arg(it->objectName())
|
||||||
|
.arg(contentY.toDouble())
|
||||||
|
.arg(y)
|
||||||
|
.arg(a->property("contentHeight").toDouble()));
|
||||||
|
a->setProperty("contentY", qBound(0.0, y - a->height() / 2, lowest));
|
||||||
|
QTest::qWait(150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One rendered frame: items are laid out, and a view's delegates are created,
|
||||||
|
* in the render loop's polish step. A platform whose window is never exposed
|
||||||
|
* -- offscreen on Windows, which is how this is run on a machine that has no
|
||||||
|
* Windows -- only runs that step when something asks for a picture, so
|
||||||
|
* without this the window can be a whole pass behind: a row still 0 wide, a
|
||||||
|
* card not created yet. A person looking at the window would see it settled,
|
||||||
|
* which is what the checks below are about. */
|
||||||
|
void frame(QQuickWindow *win) {
|
||||||
|
if (win)
|
||||||
|
win->grabWindow();
|
||||||
|
QTest::qWait(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clickItem(QQuickWindow *win, QQuickItem *it); /* defined below */
|
||||||
|
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it);
|
||||||
|
|
||||||
void clickMouse(QQuickWindow *win, const QString &name) {
|
void clickMouse(QQuickWindow *win, const QString &name) {
|
||||||
QQuickItem *it = item(win, name);
|
QQuickItem *it = item(win, name);
|
||||||
if (!it || !it->isVisible()) {
|
if (!it || !it->isVisible()) {
|
||||||
check(false, QStringLiteral("mouse click %1: not there").arg(name));
|
check(false, QStringLiteral("mouse click %1: not there").arg(name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clickItem(win, it);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same click, on an item the caller already has: a name can match more
|
||||||
|
* than one item (a view keeps a delegate from the session before), and the
|
||||||
|
* one the test means is not always the first in the tree. */
|
||||||
|
void clickItem(QQuickWindow *win, QQuickItem *it) {
|
||||||
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
|
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +248,10 @@ void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
|
|||||||
check(false, QStringLiteral("touch tap %1: not there").arg(name));
|
check(false, QStringLiteral("touch tap %1: not there").arg(name));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tapItem(win, dev, it);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) {
|
||||||
const QPoint p = centre(it);
|
const QPoint p = centre(it);
|
||||||
QTest::touchEvent(win, dev).press(0, p, win);
|
QTest::touchEvent(win, dev).press(0, p, win);
|
||||||
QTest::touchEvent(win, dev).release(0, p, win);
|
QTest::touchEvent(win, dev).release(0, p, win);
|
||||||
@@ -239,6 +385,11 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
/* A file card hands its file to the system: in a test run nothing may open
|
/* A file card hands its file to the system: in a test run nothing may open
|
||||||
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
||||||
qputenv("CST_APP_NO_OPEN", "1");
|
qputenv("CST_APP_NO_OPEN", "1");
|
||||||
|
/* From here on the run leaves a log in the workspace next to the files it
|
||||||
|
* makes, so a device run can be read back without logcat (see g_runLog). */
|
||||||
|
g_runLog.setFileName(core.workspace() + QStringLiteral("/uitest.log"));
|
||||||
|
g_runLog.open(QIODevice::WriteOnly | QIODevice::Truncate);
|
||||||
|
qInstallMessageHandler(logQtMessage);
|
||||||
const QString resumed =
|
const QString resumed =
|
||||||
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
||||||
check(!resumed.isEmpty() && core.switchSession(resumed),
|
check(!resumed.isEmpty() && core.switchSession(resumed),
|
||||||
@@ -265,6 +416,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
std::printf("FAIL the root object is not a window\n");
|
std::printf("FAIL the root object is not a window\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
/* The same handler main() installs: every Back key check below goes through
|
||||||
|
* the code the phone runs, not through a Shortcut that only the test has. */
|
||||||
|
installBackKey(win, engine.rootObjects().value(0));
|
||||||
QTest::qWait(250);
|
QTest::qWait(250);
|
||||||
|
|
||||||
/* --- the window opens on the session the engine already had ----------- */
|
/* --- the window opens on the session the engine already had ----------- */
|
||||||
@@ -307,6 +461,22 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
clickMouse(win, QStringLiteral("planSwitch"));
|
clickMouse(win, QStringLiteral("planSwitch"));
|
||||||
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
|
check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
|
||||||
|
|
||||||
|
/* Task controls must reach the same runtime as the model and HTTP API. */
|
||||||
|
clickMouse(win, QStringLiteral("taskPanelButton"));
|
||||||
|
QTest::qWait(150);
|
||||||
|
QObject *taskPanel = win->findChild<QObject *>(QStringLiteral("taskControlDialog"));
|
||||||
|
check(taskPanel && taskPanel->property("visible").toBool(),
|
||||||
|
QStringLiteral("Tasks opens the runtime control panel"));
|
||||||
|
clickMouse(win, QStringLiteral("taskReadOnly"));
|
||||||
|
clickMouse(win, QStringLiteral("taskMainOnly"));
|
||||||
|
QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object();
|
||||||
|
check(taskState.value(QStringLiteral("read_only")).toBool() &&
|
||||||
|
!taskState.value(QStringLiteral("delegation")).toBool(),
|
||||||
|
QStringLiteral("task panel enforces read-only and main-agent-only controls"));
|
||||||
|
core.taskControl(false, true, false);
|
||||||
|
if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close");
|
||||||
|
QTest::qWait(150);
|
||||||
|
|
||||||
/* --- keyboard: typing in the composer arms Send --- */
|
/* --- keyboard: typing in the composer arms Send --- */
|
||||||
QQuickItem *send = item(win, QStringLiteral("sendButton"));
|
QQuickItem *send = item(win, QStringLiteral("sendButton"));
|
||||||
if (composer && send) {
|
if (composer && send) {
|
||||||
@@ -332,6 +502,10 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("a session fixture with a tool call is in the store"));
|
QStringLiteral("a session fixture with a tool call is in the store"));
|
||||||
QMetaObject::invokeMethod(win, "reloadSessions");
|
QMetaObject::invokeMethod(win, "reloadSessions");
|
||||||
QTest::qWait(150);
|
QTest::qWait(150);
|
||||||
|
/* A row is not clickable until the layout pass that gives it its width has
|
||||||
|
* run (see frame()): a click on a row that is still 0 wide goes to whatever
|
||||||
|
* is behind it. */
|
||||||
|
frame(win);
|
||||||
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
|
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
|
||||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
||||||
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
||||||
@@ -377,14 +551,98 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
item(win, QStringLiteral("fileShare0")) != nullptr,
|
item(win, QStringLiteral("fileShare0")) != nullptr,
|
||||||
QStringLiteral("the card carries open and share"));
|
QStringLiteral("the card carries open and share"));
|
||||||
win->setProperty("statusText", QString());
|
win->setProperty("statusText", QString());
|
||||||
clickMouse(win, QStringLiteral("fileOpen0"));
|
/* A click goes to whatever is at that point, and the transcript replays
|
||||||
|
* from its bottom: on a phone-sized window the card can be above the
|
||||||
|
* visible part, where isVisible() still says yes (it does not know about
|
||||||
|
* the view's clipping). scrollIntoView puts it in the middle of the view --
|
||||||
|
* and stop the view following its tail (ChatView.stickBottom) first, or
|
||||||
|
* the scroll is undone by the next content change. */
|
||||||
|
if (list) {
|
||||||
|
/* Stop the view following its tail (ChatView.stickBottom) first: the
|
||||||
|
* scroll to the card below would be undone by the next content
|
||||||
|
* change. */
|
||||||
|
if (QQuickItem *chat = item(win, QStringLiteral("chatView")))
|
||||||
|
chat->setProperty("stickBottom", false);
|
||||||
|
}
|
||||||
|
QStringList rels;
|
||||||
|
QString cardSays;
|
||||||
|
QList<QQuickItem *> strips;
|
||||||
|
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &strips);
|
||||||
|
/* Which card is the session's own. The tree still holds the delegate of
|
||||||
|
* the session before -- a view keeps a row or two around -- and its card
|
||||||
|
* names a file of *that* session, so the first "fileOpen0" in the tree is
|
||||||
|
* not necessarily the one the check means. A rel this session can open is
|
||||||
|
* one that is really in its attachment directory. */
|
||||||
|
QQuickItem *openButton = nullptr;
|
||||||
|
QQuickItem *shareButton = nullptr;
|
||||||
|
const QString attachDir = core.attachmentDir();
|
||||||
|
for (QQuickItem *s : strips) {
|
||||||
|
const QVariantList files = s->property("files").toList();
|
||||||
|
for (int i = 0; i < files.size(); i++) {
|
||||||
|
const QVariant &f = files.at(i);
|
||||||
|
rels << f.toMap().value(QStringLiteral("rel")).toString();
|
||||||
|
if (openButton) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString rel =
|
||||||
|
f.toMap().value(QStringLiteral("rel")).toString();
|
||||||
|
if (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || rel.isEmpty() ||
|
||||||
|
!QFileInfo::exists(attachDir + QLatin1Char('/') + rel))
|
||||||
|
continue;
|
||||||
|
QList<QQuickItem *> hits;
|
||||||
|
collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits);
|
||||||
|
if (!hits.isEmpty()) {
|
||||||
|
openButton = hits.first();
|
||||||
|
hits.clear();
|
||||||
|
collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits);
|
||||||
|
shareButton = hits.isEmpty() ? nullptr : hits.first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cardSays.isEmpty())
|
||||||
|
cardSays = s->property("errorText").toString();
|
||||||
|
}
|
||||||
|
if (!openButton)
|
||||||
|
openButton = item(win, QStringLiteral("fileOpen0"));
|
||||||
|
if (!shareButton)
|
||||||
|
shareButton = item(win, QStringLiteral("fileShare0"));
|
||||||
|
/* What the card itself hands the engine ("rel" is the name the store keeps
|
||||||
|
* the file under), and whether it is really on screen: a card outside the
|
||||||
|
* viewport takes the events meant for it, and the card's own line is where
|
||||||
|
* the engine puts its refusal. */
|
||||||
|
if (openButton) {
|
||||||
|
scrollIntoView(openButton, true);
|
||||||
|
frame(win);
|
||||||
|
clickItem(win, openButton);
|
||||||
|
} else {
|
||||||
|
check(false, QStringLiteral("the delivered file has a card to open"));
|
||||||
|
}
|
||||||
|
const bool byMouse = !win->property("statusText").toString().isEmpty();
|
||||||
|
if (!byMouse && openButton)
|
||||||
|
tapItem(win, touch, openButton);
|
||||||
const QString opened = win->property("statusText").toString();
|
const QString opened = win->property("statusText").toString();
|
||||||
check(opened.startsWith(QLatin1String("opened ")),
|
check(opened.startsWith(QLatin1String("opened ")),
|
||||||
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', "
|
||||||
clickMouse(win, QStringLiteral("fileShare0"));
|
"%4, card says '%5')")
|
||||||
|
.arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"),
|
||||||
|
rels.join(QLatin1String(", ")),
|
||||||
|
onScreen(openButton)
|
||||||
|
? QStringLiteral("on screen")
|
||||||
|
: QStringLiteral("out of the viewport"),
|
||||||
|
cardSays));
|
||||||
|
win->setProperty("statusText", QString());
|
||||||
|
if (shareButton) {
|
||||||
|
scrollIntoView(shareButton, true);
|
||||||
|
frame(win);
|
||||||
|
clickItem(win, shareButton);
|
||||||
|
}
|
||||||
|
const bool shareByMouse = !win->property("statusText").toString().isEmpty();
|
||||||
|
if (!shareByMouse && shareButton)
|
||||||
|
tapItem(win, touch, shareButton);
|
||||||
const QString shared = win->property("statusText").toString();
|
const QString shared = win->property("statusText").toString();
|
||||||
check(shared.contains(QLatin1String("report.md")),
|
check(shared.contains(QLatin1String("report.md")),
|
||||||
QStringLiteral("Share names the file (%1)").arg(shared));
|
QStringLiteral("Share names the file (%1%2; card says '%3')")
|
||||||
|
.arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap"),
|
||||||
|
cardSays));
|
||||||
const QString refused =
|
const QString refused =
|
||||||
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
||||||
check(refused.contains(QLatin1String("no such file")),
|
check(refused.contains(QLatin1String("no such file")),
|
||||||
@@ -416,6 +674,136 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("and the line is where the card is"));
|
QStringLiteral("and the line is where the card is"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The approval switch is not decoration: the engine asks for a tool only
|
||||||
|
* when its own confirm_* flags say so (needs_confirm() in agent_loop.c),
|
||||||
|
* and every front end that has a consent dialog has to set them -- the
|
||||||
|
* window used to leave all three at zero, which is why "ask" never asked. */
|
||||||
|
{
|
||||||
|
const int was = core.approval();
|
||||||
|
core.setApproval(CST_APPROVAL_ASK);
|
||||||
|
const cst_agent_config_t *live = cst_agent_config(core.agent());
|
||||||
|
check(live && live->approval == CST_APPROVAL_ASK && live->confirm_shell &&
|
||||||
|
live->confirm_write && live->confirm_net,
|
||||||
|
QStringLiteral("'ask' reaches the engine as the three confirm flags"));
|
||||||
|
core.setApproval(CST_APPROVAL_YOLO);
|
||||||
|
live = cst_agent_config(core.agent());
|
||||||
|
check(live && live->approval == CST_APPROVAL_YOLO && !live->confirm_shell &&
|
||||||
|
!live->confirm_write && !live->confirm_net,
|
||||||
|
QStringLiteral("and 'yolo' takes them all away"));
|
||||||
|
core.setApproval(was); /* leave the machine as it was found */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- md_to_pdf: the front end's own document writer ------------------- */
|
||||||
|
{
|
||||||
|
const cst_tool_def_t *def = cst_tool_find("md_to_pdf");
|
||||||
|
check(def != nullptr && def->owner != nullptr &&
|
||||||
|
strncmp(def->owner, "app:qt", 6) == 0,
|
||||||
|
QStringLiteral("md_to_pdf is registered by the front end"));
|
||||||
|
if (def && def->run_ex) {
|
||||||
|
/* Run it the way the agent's turn would: through the registry, with a
|
||||||
|
* call and the engine's context. */
|
||||||
|
const QByteArray ws = core.workspace().toUtf8();
|
||||||
|
cst_tool_ctx_t ctx;
|
||||||
|
memset(&ctx, 0, sizeof(ctx));
|
||||||
|
ctx.workspace_root = ws.constData();
|
||||||
|
ctx.agent = core.agent();
|
||||||
|
ctl_tool_call_t call;
|
||||||
|
memset(&call, 0, sizeof(call));
|
||||||
|
snprintf(call.id, sizeof(call.id), "pdf1");
|
||||||
|
snprintf(call.name, sizeof(call.name), "md_to_pdf");
|
||||||
|
static const char *kMd =
|
||||||
|
"# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n"
|
||||||
|
"- 项目一\n- 项目二\n\n```\ncode block\n```\n";
|
||||||
|
static const char *kOut = "selftest.pdf";
|
||||||
|
snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown");
|
||||||
|
call.args[0].value = const_cast<char *>(kMd);
|
||||||
|
call.args[0].value_len = strlen(kMd);
|
||||||
|
snprintf(call.args[1].key, sizeof(call.args[1].key), "out");
|
||||||
|
call.args[1].value = const_cast<char *>(kOut);
|
||||||
|
call.args[1].value_len = strlen(kOut);
|
||||||
|
call.nargs = 2;
|
||||||
|
cst_tool_result_t res;
|
||||||
|
memset(&res, 0, sizeof(res));
|
||||||
|
const int rc = cst_tool_run(&ctx, &call, &res);
|
||||||
|
check(res.exit_code == 0,
|
||||||
|
QStringLiteral("md_to_pdf writes the document (%1)")
|
||||||
|
.arg(QString::fromUtf8(res.out ? res.out : "")));
|
||||||
|
check(rc == 0 && res.nattach == 1,
|
||||||
|
QStringLiteral("and hands it over as a file for the user"));
|
||||||
|
QFile f(core.workspace() + QStringLiteral("/selftest.pdf"));
|
||||||
|
const bool opened = f.open(QIODevice::ReadOnly);
|
||||||
|
const QByteArray head = opened ? f.read(5) : QByteArray();
|
||||||
|
check(head == QByteArrayLiteral("%PDF-"),
|
||||||
|
QStringLiteral("the bytes are a PDF (%1)")
|
||||||
|
.arg(QString::fromLatin1(head)));
|
||||||
|
cst_tool_result_free(&res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper is white whatever the window's palette says: markdown carries no
|
||||||
|
* colour, so a dark window turned every plain paragraph #eeeeee -- a PDF
|
||||||
|
* that looked blank. Draw it on a white image and look for ink. */
|
||||||
|
{
|
||||||
|
const QPalette was = QGuiApplication::palette();
|
||||||
|
QPalette darkPalette = was;
|
||||||
|
darkPalette.setColor(QPalette::Text, QColor(0xee, 0xee, 0xee));
|
||||||
|
darkPalette.setColor(QPalette::WindowText, QColor(0xee, 0xee, 0xee));
|
||||||
|
QGuiApplication::setPalette(darkPalette);
|
||||||
|
std::unique_ptr<QTextDocument> page(app::paperDocument(
|
||||||
|
QStringLiteral("<html><body>text that names no colour of its own"
|
||||||
|
"</body></html>")));
|
||||||
|
QImage paper(320, 60, QImage::Format_RGB32);
|
||||||
|
paper.fill(0xffffffffu);
|
||||||
|
QPainter ink(&paper);
|
||||||
|
page->drawContents(&ink);
|
||||||
|
ink.end();
|
||||||
|
int darkest = 255;
|
||||||
|
for (int y = 0; y < paper.height(); y++) {
|
||||||
|
const QRgb *row = reinterpret_cast<const QRgb *>(paper.constScanLine(y));
|
||||||
|
for (int x = 0; x < paper.width(); x++)
|
||||||
|
darkest = qMin(darkest, qGray(row[x]));
|
||||||
|
}
|
||||||
|
check(darkest < 64,
|
||||||
|
QStringLiteral("a dark window still lays out black ink on paper "
|
||||||
|
"(darkest pixel %1)")
|
||||||
|
.arg(darkest));
|
||||||
|
QGuiApplication::setPalette(was);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tool that takes minutes -- a build, a wide search -- must not read as a
|
||||||
|
* window that has hung: while a card runs, its chip counts. */
|
||||||
|
{
|
||||||
|
QList<QQuickItem *> cards;
|
||||||
|
collectNamed(win->contentItem(), QStringLiteral("toolCard"), &cards);
|
||||||
|
check(!cards.isEmpty(),
|
||||||
|
QStringLiteral("the fixture session has a tool card to watch"));
|
||||||
|
if (!cards.isEmpty()) {
|
||||||
|
QQuickItem *card = cards.first();
|
||||||
|
card->setProperty("state", "running");
|
||||||
|
/* four minutes eighteen in, the point where "running" alone stopped
|
||||||
|
* being an answer */
|
||||||
|
card->setProperty(
|
||||||
|
"startedAt",
|
||||||
|
static_cast<double>(QDateTime::currentMSecsSinceEpoch()) - 258000.0);
|
||||||
|
card->setProperty("runningFor", 258);
|
||||||
|
QList<QQuickItem *> chips;
|
||||||
|
collectNamed(card, QStringLiteral("toolChip"), &chips);
|
||||||
|
const QString counting = chips.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: chips.first()->property("text").toString();
|
||||||
|
check(counting.contains(QLatin1String("4:18")),
|
||||||
|
QStringLiteral("a running card says how long it has been going (%1)")
|
||||||
|
.arg(counting));
|
||||||
|
card->setProperty("state", "done");
|
||||||
|
const QString settled = chips.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: chips.first()->property("text").toString();
|
||||||
|
check(!settled.contains(QLatin1String("4:18")),
|
||||||
|
QStringLiteral("and a finished card stops counting (%1)")
|
||||||
|
.arg(settled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* new sessions: the engine moves, the view empties, the store grows */
|
/* new sessions: the engine moves, the view empties, the store grows */
|
||||||
const int before = core.sessions().size();
|
const int before = core.sessions().size();
|
||||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||||
@@ -439,27 +827,46 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
|
|
||||||
/* and back to the fixture, which still has its six rows */
|
/* and back to the fixture, which still has its six rows */
|
||||||
QTest::qWait(150);
|
QTest::qWait(150);
|
||||||
|
/* The sidebar holds two more sessions by now, so the row may sit below the
|
||||||
|
* fold: brought into view first, or the click goes to the row that happens
|
||||||
|
* to be there (see scrollIntoView). */
|
||||||
|
if (QQuickItem *row = item(win, QStringLiteral("sessionRow:") + fixture)) {
|
||||||
|
scrollIntoView(row);
|
||||||
|
frame(win);
|
||||||
|
}
|
||||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||||
check(core.sessionId() == fixture && list &&
|
check(core.sessionId() == fixture && list &&
|
||||||
list->property("count").toInt() == 6,
|
list->property("count").toInt() == 6,
|
||||||
QStringLiteral("switching back replays it again"));
|
QStringLiteral("switching back replays it again"));
|
||||||
{
|
{
|
||||||
bool promptImage = false;
|
bool promptImage = false;
|
||||||
|
int promptImageRow = -1;
|
||||||
const QVariantList rows = core.history();
|
const QVariantList rows = core.history();
|
||||||
for (const QVariant &v : rows) {
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
const QVariant &v = rows.at(i);
|
||||||
const QVariantMap r = v.toMap();
|
const QVariantMap r = v.toMap();
|
||||||
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
|
||||||
r.value(QStringLiteral("images")).toString().contains(
|
r.value(QStringLiteral("images")).toString().contains(
|
||||||
QLatin1String("|")))
|
QLatin1String("|"))) {
|
||||||
promptImage = true;
|
promptImage = true;
|
||||||
|
if (promptImageRow < 0)
|
||||||
|
promptImageRow = i;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
check(promptImage,
|
check(promptImage,
|
||||||
QStringLiteral("a stored prompt image comes back with its row"));
|
QStringLiteral("a stored prompt image comes back with its row"));
|
||||||
|
/* The view follows the tail (ChatView.stickBottom) and the picture is in
|
||||||
|
* the first prompt: on a window that fits fewer rows than this one it is
|
||||||
|
* above the visible part, where a delegate is not created at all. Put it
|
||||||
|
* on screen, which is what a person does before looking at it. */
|
||||||
|
if (list && promptImageRow >= 0)
|
||||||
|
QMetaObject::invokeMethod(list, "positionViewAtIndex",
|
||||||
|
Q_ARG(int, promptImageRow), Q_ARG(int, 0));
|
||||||
/* The rows are in the model by now; the cards -- and the thumbnails in
|
/* The rows are in the model by now; the cards -- and the thumbnails in
|
||||||
* them -- are built on the view's next layout pass. A frame forces it,
|
* them -- are built on the view's next layout pass. A frame forces it,
|
||||||
* and that frame is also the picture a person looks at. */
|
* and that frame is also the picture a person looks at. */
|
||||||
maybeShot(win, QStringLiteral("-images"));
|
maybeShot(win, QStringLiteral("-images"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
|
||||||
QVariant v;
|
QVariant v;
|
||||||
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
|
||||||
@@ -489,6 +896,52 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
clickMouse(win, QStringLiteral("settingsButton"));
|
clickMouse(win, QStringLiteral("settingsButton"));
|
||||||
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
|
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
|
||||||
|
/* Where the pieces actually are, whether or not that worked: a click goes to
|
||||||
|
* whatever is on top at that point, and a style lays a header out its own
|
||||||
|
* way -- the phone runs a different style from a desktop (Universal against
|
||||||
|
* Basic), which is where the header's content ended up 35 px below the
|
||||||
|
* header itself. */
|
||||||
|
{
|
||||||
|
QQuickItem *bar = item(win, QStringLiteral("appBar"));
|
||||||
|
const QMargins safe = win->property("safeAreaMargins").value<QMargins>();
|
||||||
|
note(QStringLiteral("style %1, window %2x%3, header y %4 h %5, "
|
||||||
|
"gear centre y %6, safe area %7/%8/%9/%10")
|
||||||
|
.arg(QQuickStyle::name())
|
||||||
|
.arg(win->width())
|
||||||
|
.arg(win->height())
|
||||||
|
.arg(bar ? bar->y() : -1)
|
||||||
|
.arg(bar ? bar->height() : -1)
|
||||||
|
.arg(item(win, QStringLiteral("settingsButton"))
|
||||||
|
? centre(item(win, QStringLiteral("settingsButton"))).y()
|
||||||
|
: -1)
|
||||||
|
.arg(safe.left())
|
||||||
|
.arg(safe.top())
|
||||||
|
.arg(safe.right())
|
||||||
|
.arg(safe.bottom()));
|
||||||
|
}
|
||||||
|
/* item() answers with an item that exists even when nothing is visible (the
|
||||||
|
* QObject-tree fallback), so "the dialog is not open" is the invisible case
|
||||||
|
* too. */
|
||||||
|
if (!panel || !panel->isVisible()) {
|
||||||
|
/* Two ways to be wrong -- a click that never lands, and a handler that
|
||||||
|
* does nothing -- and from the outside they look the same (this failed
|
||||||
|
* only on the phone, with no QML error at all). Say which, and go on with
|
||||||
|
* the dialog either way, so the checks below still say something. */
|
||||||
|
QQuickItem *gear = item(win, QStringLiteral("settingsButton"));
|
||||||
|
check(gear != nullptr && gear->isVisible(),
|
||||||
|
QStringLiteral("... the gear is %1, centre %2,%3 of %4x%5")
|
||||||
|
.arg(gear && gear->isVisible() ? QStringLiteral("reachable")
|
||||||
|
: QStringLiteral("not there"))
|
||||||
|
.arg(gear ? centre(gear).x() : -1)
|
||||||
|
.arg(gear ? centre(gear).y() : -1)
|
||||||
|
.arg(win->width())
|
||||||
|
.arg(win->height()));
|
||||||
|
QMetaObject::invokeMethod(win, "openSettings");
|
||||||
|
QTest::qWait(120);
|
||||||
|
panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
|
check(panel && panel->isVisible(),
|
||||||
|
QStringLiteral("... and openSettings() opens it when called directly"));
|
||||||
|
}
|
||||||
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
|
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
|
||||||
QStringLiteral("the dialog lists the providers"));
|
QStringLiteral("the dialog lists the providers"));
|
||||||
maybeShot(win, QStringLiteral("-settings"));
|
maybeShot(win, QStringLiteral("-settings"));
|
||||||
@@ -641,6 +1094,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
|
|
||||||
/* the paste path: an image on the clipboard becomes an attachment */
|
/* the paste path: an image on the clipboard becomes an attachment */
|
||||||
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||||
|
#if !defined(Q_OS_ANDROID)
|
||||||
if (gui && gui->clipboard()) {
|
if (gui && gui->clipboard()) {
|
||||||
QImage img(8, 8, QImage::Format_RGB32);
|
QImage img(8, 8, QImage::Format_RGB32);
|
||||||
img.fill(Qt::red);
|
img.fill(Qt::red);
|
||||||
@@ -653,6 +1107,13 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
} else {
|
} else {
|
||||||
check(false, QStringLiteral("there is a clipboard to paste from"));
|
check(false, QStringLiteral("there is a clipboard to paste from"));
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
/* Android's clipboard carries text and URIs, not images: setting one there
|
||||||
|
* does nothing, and a phone that "fails" this is not a phone that is
|
||||||
|
* broken. The paste path itself is the same code the desktop runs. */
|
||||||
|
(void)gui;
|
||||||
|
note(QStringLiteral("clipboard image checks skipped (Android)"));
|
||||||
|
#endif
|
||||||
|
|
||||||
/* --- tool calls: two calls of one tool are two cards ------------------ */
|
/* --- tool calls: two calls of one tool are two cards ------------------ */
|
||||||
const int rows0 = list ? list->property("count").toInt() : -1;
|
const int rows0 = list ? list->property("count").toInt() : -1;
|
||||||
@@ -733,9 +1194,13 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
/* --- the image provider: only the session's own files ----------------- */
|
/* --- the image provider: only the session's own files ----------------- */
|
||||||
{
|
{
|
||||||
const QString dir = core.attachmentDir();
|
const QString dir = core.attachmentDir();
|
||||||
/* one directory per session since v0.9.5: <sessions>/<id>/attachments */
|
/* one directory per session since v0.9.5: <sessions>/<id>/attachments.
|
||||||
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) &&
|
* The engine builds the path with the separator of the platform it runs
|
||||||
dir.contains(QLatin1String("/sessions/")),
|
* on, so what arrives here is "…/sessions\<id>\attachments" on Windows:
|
||||||
|
* asked with forward slashes, which is what the check means. */
|
||||||
|
const QString shown = QDir::fromNativeSeparators(dir);
|
||||||
|
check(!shown.isEmpty() && shown.endsWith(QLatin1String("/attachments")) &&
|
||||||
|
shown.contains(QLatin1String("/sessions/")),
|
||||||
QStringLiteral("the agent names its image directory (%1)").arg(dir));
|
QStringLiteral("the agent names its image directory (%1)").arg(dir));
|
||||||
QImage pic(32, 16, QImage::Format_RGB32);
|
QImage pic(32, 16, QImage::Format_RGB32);
|
||||||
pic.fill(Qt::darkGreen);
|
pic.fill(Qt::darkGreen);
|
||||||
@@ -776,6 +1241,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
const int before = list->property("count").toInt();
|
const int before = list->property("count").toInt();
|
||||||
const int thumbsBefore =
|
const int thumbsBefore =
|
||||||
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
|
countNamed(win->contentItem(), QStringLiteral("imageThumb"));
|
||||||
|
if (qEnvironmentVariableIsSet("CST_APP_DUMP") && list)
|
||||||
|
std::printf("note: tool card: model %d, contentY %.1f of %.1f (view %.1f), "
|
||||||
|
"%d strips, %d thumbs, %d cards\n", before,
|
||||||
|
list->property("contentY").toDouble(),
|
||||||
|
list->property("contentHeight").toDouble(), list->height(),
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
|
||||||
|
thumbsBefore,
|
||||||
|
countNamed(win->contentItem(), QStringLiteral("card:")));
|
||||||
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
|
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
|
||||||
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
|
||||||
const QString imgs =
|
const QString imgs =
|
||||||
@@ -784,7 +1257,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
Q_ARG(QVariant, QStringLiteral("call-img")),
|
Q_ARG(QVariant, QStringLiteral("call-img")),
|
||||||
Q_ARG(QVariant, imgs)),
|
Q_ARG(QVariant, imgs)),
|
||||||
QStringLiteral("images a tool made reach the view"));
|
QStringLiteral("images a tool made reach the view"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
const QVariantMap card = rowAt(before);
|
const QVariantMap card = rowAt(before);
|
||||||
check(card.value(QStringLiteral("images"))
|
check(card.value(QStringLiteral("images"))
|
||||||
.toString()
|
.toString()
|
||||||
@@ -794,7 +1267,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
QStringLiteral("the card of that call carries the image"));
|
QStringLiteral("the card of that call carries the image"));
|
||||||
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
|
||||||
thumbsBefore,
|
thumbsBefore,
|
||||||
QStringLiteral("its thumbnail is in the card"));
|
QStringLiteral("its thumbnail is in the card (%1 -> %2)").arg(thumbsBefore)
|
||||||
|
.arg(countNamed(win->contentItem(),
|
||||||
|
QStringLiteral("imageThumb"))));
|
||||||
maybeShot(win, QStringLiteral("-toolimage"));
|
maybeShot(win, QStringLiteral("-toolimage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,7 +1295,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
Q_ARG(QVariant, QStringLiteral("call-file")),
|
Q_ARG(QVariant, QStringLiteral("call-file")),
|
||||||
Q_ARG(QVariant, files)),
|
Q_ARG(QVariant, files)),
|
||||||
QStringLiteral("the files a tool delivered reach the view"));
|
QStringLiteral("the files a tool delivered reach the view"));
|
||||||
QTest::qWait(150);
|
frame(win);
|
||||||
const QVariantMap card = rowAt(before);
|
const QVariantMap card = rowAt(before);
|
||||||
check(card.value(QStringLiteral("images"))
|
check(card.value(QStringLiteral("images"))
|
||||||
.toString()
|
.toString()
|
||||||
@@ -842,6 +1317,14 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
clickMouse(win, QStringLiteral("navButton"));
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
check(navPanel && navPanel->isVisible(),
|
check(navPanel && navPanel->isVisible(),
|
||||||
QStringLiteral("drawer opens over the transcript"));
|
QStringLiteral("drawer opens over the transcript"));
|
||||||
|
/* A phone closes the drawer with the back key, not with a second tap. */
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(120);
|
||||||
|
check(navPanel && !navPanel->isVisible(),
|
||||||
|
QStringLiteral("back closes the drawer"));
|
||||||
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
|
check(navPanel && navPanel->isVisible(),
|
||||||
|
QStringLiteral("and the drawer button opens it again"));
|
||||||
clickMouse(win, QStringLiteral("navButton"));
|
clickMouse(win, QStringLiteral("navButton"));
|
||||||
check(navPanel && !navPanel->isVisible(),
|
check(navPanel && !navPanel->isVisible(),
|
||||||
QStringLiteral("drawer closes again"));
|
QStringLiteral("drawer closes again"));
|
||||||
@@ -850,10 +1333,66 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
check(docked && docked->isVisible(),
|
check(docked && docked->isVisible(),
|
||||||
QStringLiteral("widening again docks the sidebar back"));
|
QStringLiteral("widening again docks the sidebar back"));
|
||||||
|
|
||||||
|
/* --- the phone's back key closes what is open -------------------------- */
|
||||||
|
/* This one is last on purpose: on the offscreen platform a popup closed by a
|
||||||
|
* key leaves its modal overlay over the window -- the dialog says it is
|
||||||
|
* closed while the overlay still eats clicks. Nothing that clicks may come
|
||||||
|
* after it. */
|
||||||
|
{
|
||||||
|
QMetaObject::invokeMethod(win, "openSettings");
|
||||||
|
QTest::qWait(300);
|
||||||
|
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
|
||||||
|
check(panel && panel->isVisible(),
|
||||||
|
QStringLiteral("back: the settings dialog is open to close"));
|
||||||
|
/* Which layer is open is asked of QML, not of an item's `visible`: the
|
||||||
|
* dialog's content item lives on after a close and still reports visible
|
||||||
|
* while it fades. backLayer() is what the Back key reads, so the test asks
|
||||||
|
* for exactly that. */
|
||||||
|
QVariant before;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before));
|
||||||
|
check(before.toString() == QStringLiteral("settings"),
|
||||||
|
QStringLiteral("back: the settings layer is the one open"));
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QVariant after;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after));
|
||||||
|
note(QStringLiteral("after back: layer \"%1\"").arg(after.toString()));
|
||||||
|
check(after.toString().isEmpty(),
|
||||||
|
QStringLiteral("back closes the dialog instead of the window"));
|
||||||
|
check(win->isVisible(), QStringLiteral("and the window is still there"));
|
||||||
|
/* With nothing open the key belongs to the platform: a phone leaves the
|
||||||
|
* window with it, so this side must not take it. On a desktop nothing
|
||||||
|
* happens and the window has to still be there; on a phone the window
|
||||||
|
* going away *is* the hand-over, so what is asked is that no layer was
|
||||||
|
* touched (whether the activity is already gone is the platform's own
|
||||||
|
* timing). This is the last thing the run does on a phone: the activity
|
||||||
|
* leaving takes the process with it, so the log can end on the check below
|
||||||
|
* without the closing line -- every line is flushed as it is written. */
|
||||||
|
QTest::keyClick(win, Qt::Key_Back);
|
||||||
|
QTest::qWait(120);
|
||||||
|
QVariant idle;
|
||||||
|
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle));
|
||||||
|
#if defined(Q_OS_ANDROID)
|
||||||
|
note(QStringLiteral("after the last back: window visible %1")
|
||||||
|
.arg(win->isVisible() ? 1 : 0));
|
||||||
|
check(idle.toString().isEmpty(),
|
||||||
|
QStringLiteral("back with nothing open is left to the platform"));
|
||||||
|
#else
|
||||||
|
check(idle.toString().isEmpty() && win->isVisible(),
|
||||||
|
QStringLiteral("back with nothing open is left to the platform"));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/* A picture of the last state on request (see maybeShot). */
|
/* A picture of the last state on request (see maybeShot). */
|
||||||
maybeShot(win, QString());
|
maybeShot(win, QString());
|
||||||
|
|
||||||
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
||||||
|
if (g_runLog.isOpen()) {
|
||||||
|
g_runLog.write("--- ");
|
||||||
|
g_runLog.write(g_fail ? "FAILED" : "all UI checks passed");
|
||||||
|
g_runLog.write("\n");
|
||||||
|
g_runLog.close();
|
||||||
|
}
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Hermit Desktop (Tauri 2)
|
||||||
|
|
||||||
|
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
|
||||||
|
(session rail · chat · tool timeline · composer); the agent engine is the
|
||||||
|
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Tauri 2** + Rust host
|
||||||
|
- **React 19** + Vite + TypeScript UI
|
||||||
|
- FFI to `libcstcore` (same embed path as the legacy Qt app)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Platform | Needs |
|
||||||
|
|----------|--------|
|
||||||
|
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
|
||||||
|
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
|
||||||
|
| Windows | WebView2 runtime |
|
||||||
|
| macOS | Xcode CLT |
|
||||||
|
|
||||||
|
Nix example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop
|
||||||
|
npm install
|
||||||
|
npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a full engine link yet, the UI runs against the mock bridge
|
||||||
|
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts land in `src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/desktop/
|
||||||
|
src/ React UI (DSH-like)
|
||||||
|
src-tauri/ Rust + Tauri + FFI
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The legacy Qt front end under `/app` is frozen and will be archived once
|
||||||
|
desktop feature parity lands.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hermit</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "hermit-desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.1.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^9.0.3",
|
||||||
|
"remark-gfm": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.1.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "hermit-desktop"
|
||||||
|
version = "1.1.0"
|
||||||
|
description = "Hermit desktop shell (Tauri 2)"
|
||||||
|
authors = ["WHC"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "hermit_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
cc = "1.2"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
# Link libcstcore from the repo (requires curl + built archive).
|
||||||
|
engine = []
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
fn main() {
|
||||||
|
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
|
||||||
|
// Example:
|
||||||
|
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
|
||||||
|
// cargo build --features engine
|
||||||
|
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
|
||||||
|
if let Ok(lib) = std::env::var("HERMIT_CORE_LIB") {
|
||||||
|
let path = std::path::PathBuf::from(&lib);
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||||
|
}
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("cstcore")
|
||||||
|
.trim_start_matches("lib");
|
||||||
|
println!("cargo:rustc-link-lib=static={}", stem);
|
||||||
|
println!("cargo:rustc-link-lib=curl");
|
||||||
|
println!("cargo:rustc-link-lib=pthread");
|
||||||
|
println!("cargo:rustc-link-lib=m");
|
||||||
|
println!("cargo:rerun-if-changed={}", lib);
|
||||||
|
}
|
||||||
|
if let Ok(inc) = std::env::var("HERMIT_CORE_INCLUDE") {
|
||||||
|
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
|
||||||
|
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", inc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default Hermit desktop capabilities",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
|
||||||
|
After Width: | Height: | Size: 682 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,173 @@
|
|||||||
|
//! Engine façade: mock by default; with `--features engine` + HERMIT_CORE_LIB
|
||||||
|
//! this module will grow real FFI around `cst_agent.h` (AgentCore parity).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind")]
|
||||||
|
pub enum ChatItem {
|
||||||
|
#[serde(rename = "user")]
|
||||||
|
User { id: String, text: String },
|
||||||
|
#[serde(rename = "assistant")]
|
||||||
|
Assistant { id: String, text: String },
|
||||||
|
#[serde(rename = "thought")]
|
||||||
|
Thought { id: String, text: String },
|
||||||
|
#[serde(rename = "tool")]
|
||||||
|
Tool {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
pub approval: String,
|
||||||
|
pub workspace: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum EngineEvent {
|
||||||
|
#[serde(rename = "assistant_delta")]
|
||||||
|
AssistantDelta { text: String },
|
||||||
|
#[serde(rename = "thought_delta")]
|
||||||
|
ThoughtDelta { text: String },
|
||||||
|
#[serde(rename = "tool_start")]
|
||||||
|
ToolStart {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "tool_end")]
|
||||||
|
ToolEnd {
|
||||||
|
id: String,
|
||||||
|
status: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "consent")]
|
||||||
|
Consent {
|
||||||
|
req: ConsentRequest,
|
||||||
|
},
|
||||||
|
#[serde(rename = "turn_end")]
|
||||||
|
TurnEnd { ok: bool },
|
||||||
|
#[serde(rename = "error")]
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConsentRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub tool: String,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EngineState {
|
||||||
|
pub settings: Settings,
|
||||||
|
pub sessions: Vec<SessionInfo>,
|
||||||
|
busy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EngineState {
|
||||||
|
fn default() -> Self {
|
||||||
|
let workspace = std::env::current_dir()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.to_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_else(|| ".".into());
|
||||||
|
Self {
|
||||||
|
settings: Settings {
|
||||||
|
provider: "deepseek".into(),
|
||||||
|
model: "deepseek-flash".into(),
|
||||||
|
approval: "auto".into(),
|
||||||
|
workspace,
|
||||||
|
},
|
||||||
|
sessions: vec![SessionInfo {
|
||||||
|
id: "welcome".into(),
|
||||||
|
title: "Welcome".into(),
|
||||||
|
updated_at: chrono_now(),
|
||||||
|
}],
|
||||||
|
busy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chrono_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedEngine = Mutex<EngineState>;
|
||||||
|
|
||||||
|
pub fn list_sessions(state: &SharedEngine) -> Vec<SessionInfo> {
|
||||||
|
state.lock().unwrap().sessions.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_session(state: &SharedEngine) -> SessionInfo {
|
||||||
|
let mut g = state.lock().unwrap();
|
||||||
|
let s = SessionInfo {
|
||||||
|
id: format!("s{}", chrono_now()),
|
||||||
|
title: "New session".into(),
|
||||||
|
updated_at: chrono_now(),
|
||||||
|
};
|
||||||
|
g.sessions.insert(0, s.clone());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_session(_state: &SharedEngine, _id: &str) -> Vec<ChatItem> {
|
||||||
|
// Real FFI will replay transcript from cst_agent_store_*.
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_get(state: &SharedEngine) -> Settings {
|
||||||
|
state.lock().unwrap().settings.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_turn(app: AppHandle, state: &SharedEngine, text: String) {
|
||||||
|
{
|
||||||
|
let mut g = state.lock().unwrap();
|
||||||
|
if g.busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g.busy = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder stream until libcstcore FFI is wired (feature = "engine").
|
||||||
|
let _ = app.emit(
|
||||||
|
"hermit://event",
|
||||||
|
EngineEvent::ThoughtDelta {
|
||||||
|
text: "Rust host received turn; FFI bridge pending full AgentCore port."
|
||||||
|
.into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
let _ = app.emit(
|
||||||
|
"hermit://event",
|
||||||
|
EngineEvent::AssistantDelta {
|
||||||
|
text: format!("Echo (host): {}", text),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let _ = app.emit("hermit://event", EngineEvent::TurnEnd { ok: true });
|
||||||
|
state.lock().unwrap().busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel_turn(state: &SharedEngine) {
|
||||||
|
state.lock().unwrap().busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consent_answer(_id: &str, _answer: &str) {
|
||||||
|
// Real path: wake the worker blocked in consent hook.
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Future FFI surface against `include/cst_agent.h` (AgentCore parity).
|
||||||
|
//!
|
||||||
|
//! Enable with:
|
||||||
|
//! make -C ../.. libcstcore.a # or cmake cstcore
|
||||||
|
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
|
||||||
|
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
|
||||||
|
//! cargo build --features engine
|
||||||
|
//!
|
||||||
|
//! Mapping (Qt AgentCore → Tauri commands/events):
|
||||||
|
//! open / send / cancel → session_* / send_turn / cancel_turn
|
||||||
|
//! hooks.assistant_delta → hermit://event AssistantDelta
|
||||||
|
//! hooks.tool_* → ToolStart / ToolEnd
|
||||||
|
//! hooks.consent (blocking) → Consent event + consent_answer wakes waiter
|
||||||
|
//! settings / creds → settings_get (+ later settings_set)
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
#[cfg(feature = "engine")]
|
||||||
|
mod live {
|
||||||
|
// Bindings land here (cbindgen or manual extern "C").
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "engine"))]
|
||||||
|
pub fn engine_linked() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "engine")]
|
||||||
|
pub fn engine_linked() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
|
||||||
|
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
|
||||||
|
typedef struct cst_agent cst_agent_t;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
mod engine;
|
||||||
|
mod ffi;
|
||||||
|
|
||||||
|
use engine::{SharedEngine, EngineState};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
|
||||||
|
engine::list_sessions(&state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
|
||||||
|
engine::new_session(&state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn session_open(
|
||||||
|
state: tauri::State<'_, SharedEngine>,
|
||||||
|
id: String,
|
||||||
|
) -> Vec<engine::ChatItem> {
|
||||||
|
engine::open_session(&state, &id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
|
||||||
|
engine::settings_get(&state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_turn(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, SharedEngine>,
|
||||||
|
text: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
engine::send_turn(app, &state, text).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
|
||||||
|
engine::cancel_turn(&state);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn consent_answer(id: String, answer: String) {
|
||||||
|
engine::consent_answer(&id, &answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.manage(Mutex::new(EngineState::default()))
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
session_list,
|
||||||
|
session_new,
|
||||||
|
session_open,
|
||||||
|
settings_get,
|
||||||
|
send_turn,
|
||||||
|
cancel_turn,
|
||||||
|
consent_answer
|
||||||
|
])
|
||||||
|
.setup(|app| {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.set_title("Hermit");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running Hermit desktop");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
hermit_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Hermit",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"identifier": "xyz.whcself.hermit",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Hermit",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 840,
|
||||||
|
"minWidth": 880,
|
||||||
|
"minHeight": 560,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"deb": {
|
||||||
|
"depends": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "12.0"
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "currentUser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Rail } from "@/components/Rail";
|
||||||
|
import { Transcript } from "@/components/Transcript";
|
||||||
|
import { Composer } from "@/components/Composer";
|
||||||
|
import { ConsentModal } from "@/components/ConsentModal";
|
||||||
|
import {
|
||||||
|
answerConsent,
|
||||||
|
cancelTurn,
|
||||||
|
getSettings,
|
||||||
|
listSessions,
|
||||||
|
newSession,
|
||||||
|
onEngineEvent,
|
||||||
|
openSession,
|
||||||
|
sendTurn,
|
||||||
|
type ChatItem,
|
||||||
|
type ConsentRequest,
|
||||||
|
type SessionInfo,
|
||||||
|
type Settings,
|
||||||
|
} from "@/lib/bridge";
|
||||||
|
import "@/styles/app.css";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [items, setItems] = useState<ChatItem[]>([]);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
provider: "deepseek",
|
||||||
|
model: "deepseek-flash",
|
||||||
|
approval: "auto",
|
||||||
|
workspace: ".",
|
||||||
|
});
|
||||||
|
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
setSessions(await listSessions());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
setSettings(await getSettings());
|
||||||
|
await refreshSessions();
|
||||||
|
})();
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let un: (() => void) | undefined;
|
||||||
|
void onEngineEvent((ev) => {
|
||||||
|
if (ev.type === "assistant_delta") {
|
||||||
|
setItems((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.kind === "assistant") {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, text: last.text + ev.text },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ kind: "assistant", id: `a${Date.now()}`, text: ev.text },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
} else if (ev.type === "thought_delta") {
|
||||||
|
setItems((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.kind === "thought") {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{ ...last, text: last.text + "\n" + ev.text },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ kind: "thought", id: `th${Date.now()}`, text: ev.text },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
} else if (ev.type === "tool_start") {
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
id: ev.id,
|
||||||
|
name: ev.name,
|
||||||
|
status: "running",
|
||||||
|
detail: ev.detail,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else if (ev.type === "tool_end") {
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) =>
|
||||||
|
it.kind === "tool" && it.id === ev.id
|
||||||
|
? { ...it, status: ev.status, detail: ev.detail || it.detail }
|
||||||
|
: it,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (ev.type === "consent") {
|
||||||
|
setConsent(ev.req);
|
||||||
|
} else if (ev.type === "turn_end") {
|
||||||
|
setBusy(false);
|
||||||
|
} else if (ev.type === "error") {
|
||||||
|
setBusy(false);
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
id: `err${Date.now()}`,
|
||||||
|
text: `Error: ${ev.message}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}).then((u) => {
|
||||||
|
un = u;
|
||||||
|
});
|
||||||
|
return () => un?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const open = async (id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setItems(await openSession(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNew = async () => {
|
||||||
|
const s = await newSession();
|
||||||
|
await refreshSessions();
|
||||||
|
setActiveId(s.id);
|
||||||
|
setItems([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSend = async () => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || busy) return;
|
||||||
|
setDraft("");
|
||||||
|
setBusy(true);
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ kind: "user", id: `u${Date.now()}`, text },
|
||||||
|
]);
|
||||||
|
await sendTurn(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Rail
|
||||||
|
sessions={sessions}
|
||||||
|
activeId={activeId}
|
||||||
|
workspace={settings.workspace}
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
onNew={() => void onNew()}
|
||||||
|
onOpen={(id) => void open(id)}
|
||||||
|
onSettings={() => setShowSettings(true)}
|
||||||
|
/>
|
||||||
|
<section className="main">
|
||||||
|
<header className="topbar">
|
||||||
|
<h1>
|
||||||
|
{sessions.find((s) => s.id === activeId)?.title ?? "New session"}
|
||||||
|
</h1>
|
||||||
|
<div className="chips">
|
||||||
|
<span className="chip ok">engine ready</span>
|
||||||
|
<span className="chip">
|
||||||
|
<strong>{settings.model}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Transcript items={items} />
|
||||||
|
<Composer
|
||||||
|
value={draft}
|
||||||
|
busy={busy}
|
||||||
|
settings={settings}
|
||||||
|
onChange={setDraft}
|
||||||
|
onSend={() => void onSend()}
|
||||||
|
onStop={() => void cancelTurn()}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{consent ? (
|
||||||
|
<ConsentModal
|
||||||
|
req={consent}
|
||||||
|
onAnswer={(a) => {
|
||||||
|
void answerConsent(consent.id, a);
|
||||||
|
setConsent(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showSettings ? (
|
||||||
|
<div className="consent" onClick={() => setShowSettings(false)}>
|
||||||
|
<div
|
||||||
|
className="consent-card"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<pre>{JSON.stringify(settings, null, 2)}</pre>
|
||||||
|
<div className="consent-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => setShowSettings(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { Settings } from "@/lib/bridge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
busy: boolean;
|
||||||
|
settings: Settings;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Composer({
|
||||||
|
value,
|
||||||
|
busy,
|
||||||
|
settings,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "0px";
|
||||||
|
el.style.height = Math.min(200, el.scrollHeight) + "px";
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="composer-wrap">
|
||||||
|
<div className="composer">
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Describe what you want to build, / commands, @ files…"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!busy && value.trim()) onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="composer-bar">
|
||||||
|
<div className="composer-left">
|
||||||
|
<span className="chip">
|
||||||
|
<strong>{settings.model}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="chip">
|
||||||
|
{settings.provider} · {settings.approval}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{busy ? (
|
||||||
|
<button type="button" className="send stop" onClick={onStop}>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="send"
|
||||||
|
disabled={!value.trim()}
|
||||||
|
onClick={onSend}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ConsentRequest } from "@/lib/bridge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
req: ConsentRequest;
|
||||||
|
onAnswer: (a: "deny" | "once" | "always_tool" | "always_prefix") => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConsentModal({ req, onAnswer }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="consent">
|
||||||
|
<div className="consent-card">
|
||||||
|
<h3>Allow {req.tool}?</h3>
|
||||||
|
<pre>{req.detail}</pre>
|
||||||
|
<div className="consent-actions">
|
||||||
|
<button type="button" onClick={() => onAnswer("deny")}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onAnswer("always_prefix")}>
|
||||||
|
Always prefix
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onAnswer("always_tool")}>
|
||||||
|
Always tool
|
||||||
|
</button>
|
||||||
|
<button type="button" className="primary" onClick={() => onAnswer("once")}>
|
||||||
|
Allow once
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { SessionInfo } from "@/lib/bridge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
activeId: string | null;
|
||||||
|
workspace: string;
|
||||||
|
query: string;
|
||||||
|
onQuery: (q: string) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onOpen: (id: string) => void;
|
||||||
|
onSettings: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Rail({
|
||||||
|
sessions,
|
||||||
|
activeId,
|
||||||
|
workspace,
|
||||||
|
query,
|
||||||
|
onQuery,
|
||||||
|
onNew,
|
||||||
|
onOpen,
|
||||||
|
onSettings,
|
||||||
|
}: Props) {
|
||||||
|
const filtered = sessions.filter(
|
||||||
|
(s) => !query || s.title.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<aside className="rail">
|
||||||
|
<div className="brand">
|
||||||
|
<div className="brand-mark">H</div>
|
||||||
|
<div>
|
||||||
|
<div className="brand-name">Hermit</div>
|
||||||
|
<div className="brand-sub">local coding agent</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="btn-new" onClick={onNew}>
|
||||||
|
+ New session
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rail-sec">
|
||||||
|
<span>Sessions</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="sess-search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search sessions"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="sessions">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={`sess${s.id === activeId ? " active" : ""}`}
|
||||||
|
onClick={() => onOpen(s.id)}
|
||||||
|
>
|
||||||
|
<div className="sess-title">{s.title}</div>
|
||||||
|
<div className="sess-meta">
|
||||||
|
{new Date(s.updated_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rail-foot">
|
||||||
|
<div className="workspace" title={workspace}>
|
||||||
|
{workspace}
|
||||||
|
</div>
|
||||||
|
<div className="rail-actions">
|
||||||
|
<button type="button" className="icon-btn" onClick={onSettings}>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ChatItem } from "@/lib/bridge";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
type Props = { items: ChatItem[] };
|
||||||
|
|
||||||
|
export function Transcript({ items }: Props) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="transcript">
|
||||||
|
<div className="empty">
|
||||||
|
<h2>Meet Hermit</h2>
|
||||||
|
<p>
|
||||||
|
Local coding agent — read and edit your workspace, run commands with
|
||||||
|
approval, keep sessions on disk. Describe a task to begin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="transcript">
|
||||||
|
{items.map((it) => {
|
||||||
|
if (it.kind === "user") {
|
||||||
|
return (
|
||||||
|
<div className="msg user" key={it.id}>
|
||||||
|
<div className="msg-role">You</div>
|
||||||
|
<div className="bubble">{it.text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (it.kind === "assistant") {
|
||||||
|
return (
|
||||||
|
<div className="msg assistant" key={it.id}>
|
||||||
|
<div className="msg-role">Hermit</div>
|
||||||
|
<div className="bubble">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{it.text}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (it.kind === "thought") {
|
||||||
|
return (
|
||||||
|
<details className="thought" key={it.id}>
|
||||||
|
<summary>Thought for a while</summary>
|
||||||
|
<div className="thought-body">{it.text}</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="tool-card" key={it.id}>
|
||||||
|
<div className="tool-head">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="name">{it.name}</span>
|
||||||
|
<span className="status">{it.status}</span>
|
||||||
|
</div>
|
||||||
|
{it.detail ? <div className="tool-body">{it.detail}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/** IPC / mock bridge for Hermit desktop. */
|
||||||
|
|
||||||
|
export type SessionInfo = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsentRequest = {
|
||||||
|
id: string;
|
||||||
|
tool: string;
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatItem =
|
||||||
|
| { kind: "user"; id: string; text: string }
|
||||||
|
| { kind: "assistant"; id: string; text: string }
|
||||||
|
| { kind: "thought"; id: string; text: string }
|
||||||
|
| {
|
||||||
|
kind: "tool";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "running" | "ok" | "denied" | "error";
|
||||||
|
detail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EngineEvent =
|
||||||
|
| { type: "assistant_delta"; text: string }
|
||||||
|
| { type: "thought_delta"; text: string }
|
||||||
|
| { type: "tool_start"; id: string; name: string; detail: string }
|
||||||
|
| { type: "tool_end"; id: string; status: "ok" | "denied" | "error"; detail: string }
|
||||||
|
| { type: "consent"; req: ConsentRequest }
|
||||||
|
| { type: "turn_end"; ok: boolean }
|
||||||
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
export type Settings = {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
approval: "ask" | "auto" | "yolo";
|
||||||
|
workspace: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Unlisten = () => void;
|
||||||
|
|
||||||
|
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
return await invoke<T>(cmd, args);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockSessions: SessionInfo[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
title: "Fix home TUI layout",
|
||||||
|
updated_at: Date.now() - 3600_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
title: "Ship hermit-update",
|
||||||
|
updated_at: Date.now() - 86400_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mockBusy = false;
|
||||||
|
const mockListeners = new Set<(e: EngineEvent) => void>();
|
||||||
|
|
||||||
|
function emitMock(e: EngineEvent) {
|
||||||
|
mockListeners.forEach((fn) => fn(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessions(): Promise<SessionInfo[]> {
|
||||||
|
const real = await tryInvoke<SessionInfo[]>("session_list");
|
||||||
|
return real ?? mockSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function newSession(): Promise<SessionInfo> {
|
||||||
|
const real = await tryInvoke<SessionInfo>("session_new");
|
||||||
|
if (real) return real;
|
||||||
|
const s: SessionInfo = {
|
||||||
|
id: `s${Date.now()}`,
|
||||||
|
title: "New session",
|
||||||
|
updated_at: Date.now(),
|
||||||
|
};
|
||||||
|
mockSessions.unshift(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSession(id: string): Promise<ChatItem[]> {
|
||||||
|
const real = await tryInvoke<ChatItem[]>("session_open", { id });
|
||||||
|
if (real) return real;
|
||||||
|
if (id === "s1") {
|
||||||
|
return [
|
||||||
|
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。" },
|
||||||
|
{
|
||||||
|
kind: "thought",
|
||||||
|
id: "t1",
|
||||||
|
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool",
|
||||||
|
id: "tool1",
|
||||||
|
name: "read_file",
|
||||||
|
status: "ok",
|
||||||
|
detail: "src/tui.c · draw_side / layout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
id: "a1",
|
||||||
|
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<Settings> {
|
||||||
|
const real = await tryInvoke<Settings>("settings_get");
|
||||||
|
return (
|
||||||
|
real ?? {
|
||||||
|
provider: "deepseek",
|
||||||
|
model: "deepseek-flash",
|
||||||
|
approval: "auto",
|
||||||
|
workspace: "~/hermit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTurn(text: string): Promise<void> {
|
||||||
|
const real = await tryInvoke<void>("send_turn", { text });
|
||||||
|
if (real !== null) return;
|
||||||
|
if (mockBusy) return;
|
||||||
|
mockBusy = true;
|
||||||
|
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
|
||||||
|
await delay(400);
|
||||||
|
emitMock({
|
||||||
|
type: "tool_start",
|
||||||
|
id: "m1",
|
||||||
|
name: "shell",
|
||||||
|
detail: "hermit --version",
|
||||||
|
});
|
||||||
|
await delay(500);
|
||||||
|
emitMock({
|
||||||
|
type: "tool_end",
|
||||||
|
id: "m1",
|
||||||
|
status: "ok",
|
||||||
|
detail: "hermit 1.1.0",
|
||||||
|
});
|
||||||
|
const reply =
|
||||||
|
"这是桌面壳的本地演示流(未链上引擎时)。接上 libcstcore 后,这里会变成真实 turn。";
|
||||||
|
for (const ch of reply) {
|
||||||
|
emitMock({ type: "assistant_delta", text: ch });
|
||||||
|
await delay(12);
|
||||||
|
}
|
||||||
|
emitMock({ type: "turn_end", ok: true });
|
||||||
|
mockBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelTurn(): Promise<void> {
|
||||||
|
await tryInvoke("cancel_turn");
|
||||||
|
mockBusy = false;
|
||||||
|
emitMock({ type: "turn_end", ok: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function answerConsent(
|
||||||
|
id: string,
|
||||||
|
answer: "deny" | "once" | "always_tool" | "always_prefix",
|
||||||
|
): Promise<void> {
|
||||||
|
await tryInvoke("consent_answer", { id, answer });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
|
||||||
|
try {
|
||||||
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
|
const un = await listen<EngineEvent>("hermit://event", (ev) => fn(ev.payload));
|
||||||
|
return () => {
|
||||||
|
un();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
mockListeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
mockListeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
@import "./theme.css";
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--rail-w) 1fr;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— left rail (DSH-like) —— */
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(145deg, var(--brand), var(--brand-dim));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(169, 217, 215, 0.25);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.brand-sub {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-new:hover {
|
||||||
|
background: #2f94ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-sec {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 6px 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sess-search {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sess-search:focus {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sess {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.sess:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sess.active {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sess-title {
|
||||||
|
font-weight: 550;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sess-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.rail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— main column —— */
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px var(--pad);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chip strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.chip.ok {
|
||||||
|
border-color: rgba(61, 154, 106, 0.35);
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px var(--pad) 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 420px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
max-width: 820px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.msg.user {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.msg-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.msg.user .bubble {
|
||||||
|
background: linear-gradient(160deg, var(--brand-dim), #1a4a56);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.msg.assistant .bubble {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thought,
|
||||||
|
.tool-card {
|
||||||
|
max-width: 820px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thought summary,
|
||||||
|
.tool-head {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.thought summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.thought[open] summary {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.thought-body,
|
||||||
|
.tool-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.tool-head .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--crab);
|
||||||
|
}
|
||||||
|
.tool-head .name {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.tool-head .status {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-wrap {
|
||||||
|
padding: 10px var(--pad) 16px;
|
||||||
|
}
|
||||||
|
.composer {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 10px 12px 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.composer textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
|
max-height: 200px;
|
||||||
|
resize: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
.composer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.composer-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.send {
|
||||||
|
height: 34px;
|
||||||
|
min-width: 72px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--crab);
|
||||||
|
color: #1a100c;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.send:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.send.stop {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.consent-card {
|
||||||
|
width: min(480px, calc(100% - 32px));
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.consent-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.consent-card pre {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.consent-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.consent-actions button {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.consent-actions .primary {
|
||||||
|
background: var(--brand);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
:root {
|
||||||
|
/* Hermit brand + DSH-like rhythm */
|
||||||
|
--brand: #277f94;
|
||||||
|
--brand-dim: #1d5f6e;
|
||||||
|
--crab: #f68042;
|
||||||
|
--eye: #9957ad;
|
||||||
|
--ok: #3d9a6a;
|
||||||
|
--warn: #e9ba47;
|
||||||
|
--danger: #c45c4a;
|
||||||
|
|
||||||
|
--bg: #0f1114;
|
||||||
|
--bg-elev: #161a1f;
|
||||||
|
--bg-card: #1b2128;
|
||||||
|
--bg-hover: #222933;
|
||||||
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--text: #e8ecf0;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-faint: #5c6570;
|
||||||
|
--accent-text: #a9d9d7;
|
||||||
|
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--pad: 16px;
|
||||||
|
--rail-w: 260px;
|
||||||
|
--font: "IBM Plex Sans", "Segoe UI", "Noto Sans SC", system-ui, sans-serif;
|
||||||
|
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
--shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Composer.tsx","./src/components/ConsentModal.tsx","./src/components/Rail.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||||
|
watch: { ignored: ["**/src-tauri/**"] },
|
||||||
|
},
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||||
|
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# NixOS and other nix users: legacy Qt Quick front end (frozen; Tauri app is
|
||||||
|
# apps/desktop). CLI remains scripts/bootstrap_linux.sh.
|
||||||
|
#
|
||||||
|
# nix-build # ./result/bin/hermit-app (Qt, transitional)
|
||||||
|
# nix-env -f . -i hermit
|
||||||
|
{ 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 = "hermit";
|
||||||
|
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 =
|
||||||
|
"Hermit: local coding agent (legacy Qt Quick front end; prefer apps/desktop)";
|
||||||
|
homepage = "https://git.whcself.xyz/WHC/Hermit";
|
||||||
|
platforms = pkgs.lib.platforms.linux;
|
||||||
|
mainProgram = "cst-agent-app";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Hermit 文档索引
|
||||||
|
|
||||||
|
规范仓库:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。从旧名迁移见根目录 [MIGRATION.md](../MIGRATION.md)。
|
||||||
|
|
||||||
|
二进制:`hermit`(兼容软链 `cst-agent`)。配置目录:`~/.config/hermit`(若无则回退 `~/.config/cst-agent`)。
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [env_openai.md](env_openai.md) | Provider、模型、API Key、环境变量(`HERMIT_*` / `CST_*`) |
|
||||||
|
| [parliament.md](parliament.md) | 多设备议会 |
|
||||||
|
| [workers.md](workers.md) | 后台任务、worker、job 完成报告 |
|
||||||
|
| [webui.md](webui.md) | 内置 HTTP + SSE WebUI |
|
||||||
|
| [plugins.md](plugins.md) | Skills、插件、MCP |
|
||||||
|
| [compat.md](compat.md) | 平台兼容 |
|
||||||
|
| [README-windows.md](README-windows.md) | Windows |
|
||||||
|
| [android.md](android.md) | Android / APK |
|
||||||
|
| [archive/](archive/) | 历史设计笔记(非产品文档) |
|
||||||
@@ -197,6 +197,25 @@ have, so the script rewrites the shebang of the build-tools wrappers.
|
|||||||
start of the turn and released at its end, `run_shell sleep 60` finished with
|
start of the turn and released at its end, `run_shell sleep 60` finished with
|
||||||
the screen dark, and the service was gone once the turn was over.
|
the screen dark, and the service was gone once the turn was over.
|
||||||
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
|
||||||
* Still to come: the SAF picker for attaching a picture from another app, the
|
* **A turn that asked and got no answer**: a consent question cannot outlive
|
||||||
back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the
|
the app being off screen. `AgentCore::hookPermission()` / `hookAskUser()`
|
||||||
phone and draws empty boxes where the desktop shows Breeze icons.
|
refuse (or drop the question) at once while the app is in the background --
|
||||||
|
Qt reports that as `Qt::ApplicationSuspended`, so the worker is never parked
|
||||||
|
on a dialog nobody can see, and the transcript carries a line saying why. In
|
||||||
|
the foreground the same two hooks wait two minutes (a permission) or five
|
||||||
|
(a question) and then answer "no" with a reason instead of waiting for good.
|
||||||
|
The GUI hop a platform tool makes (`onGuiThread()` in
|
||||||
|
`app/src/platform/android/AndroidTools.cpp`) pings first and gives up after
|
||||||
|
three seconds, and the header shows what the turn is doing
|
||||||
|
(`shell: make -j8`) so a long turn is not mistaken for a stuck one.
|
||||||
|
`--uitest` covers the window side of all this.
|
||||||
|
* **Documents**: `md_to_pdf` (the window's own tool,
|
||||||
|
`app/src/core/PdfTools.cpp`, owner `app:qt`) lays markdown or a workspace
|
||||||
|
`.md`/`.html` out as an A4 PDF with 15 mm margins and hands it over as a file
|
||||||
|
card, exactly the way `deliver_file` does; `QPrinter`/PrintSupport is not
|
||||||
|
used, so the phone gets it too. Qt's PDF engine embeds a font *subset*, which
|
||||||
|
is why a Chinese page is a few KB rather than the few MB a whole CJK font
|
||||||
|
would cost (see `app/README.md`).
|
||||||
|
* Still to come: the back gesture, and the icon glyphs -- `ThemeIcon` finds no
|
||||||
|
icon files on the phone and draws empty boxes where the desktop shows Breeze
|
||||||
|
icons.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 归档
|
||||||
|
|
||||||
|
历史设计笔记与评测草稿,**不是**面向用户的产品文档,也不再作为论文素材维护。
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `theory_tool_cst.md` | 早期工具调用确认流设计笔记 |
|
||||||
|
| `eval_tool_cst.md` | 早期协议/夹具评测草稿 |
|
||||||
|
|
||||||
|
日常使用请看仓库根目录 [README.md](../../README.md) 与同级其它 `docs/*.md`。
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
# Tool-loop CST 评测笔记(反哺论文素材)
|
# Tool-loop CST 评测笔记(反哺论文素材)
|
||||||
|
|
||||||
> 本文是 **CST-Agent** 侧的评测/案例记录,供写入 SD 主论文附录或独立 TR 时粘贴。
|
> **归档。** 早期评测草稿,已停止维护;请勿当作当前产品说明。
|
||||||
> **不**替代 [`SD/paper/sections/eval_zh.tex`](../../SD/paper/sections/eval_zh.tex) 中 Markdown→HTML 主实验。
|
|
||||||
|
|
||||||
理论锚点见 [theory_tool_cst.md](theory_tool_cst.md)。
|
理论锚点见同目录 [theory_tool_cst.md](theory_tool_cst.md)。
|
||||||
|
|
||||||
## 命题分工
|
## 命题分工
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Tool-loop CST:确认前沿推广到工具调用
|
# Tool-loop CST:确认前沿推广到工具调用
|
||||||
|
|
||||||
|
> **归档。** 早期设计笔记,已停止维护;请勿当作当前产品说明。见 [README.md](README.md)。
|
||||||
|
|
||||||
## 命题
|
## 命题
|
||||||
|
|
||||||
在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
|
在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
|
||||||
@@ -22,8 +22,8 @@ adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
|
|||||||
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
Homebrew it falls back to the system `-lncurses` (wide-character capable).
|
||||||
|
|
||||||
Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
|
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
|
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`)
|
||||||
with TinyCC on Debian and builds the static musl releases for x86_64, x86 and
|
checks with TinyCC and builds the static musl releases for x86_64, x86 and
|
||||||
aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
|
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.
|
test on aarch64). macOS and WSL are checked by hand with the list below.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Hermit Desktop (Tauri)
|
||||||
|
|
||||||
|
Canonical GUI: [`apps/desktop`](../apps/desktop/). Qt under [`app/`](../app/) is frozen ([DEPRECATED](../app/DEPRECATED.md)).
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
- **Shell**: Tauri 2 (Linux / Windows / macOS)
|
||||||
|
- **UI**: React 19 — session rail, chat, tool cards, composer (DeepSeek Harness–like IA)
|
||||||
|
- **Engine**: in-process `libcstcore` via Rust FFI (feature `engine`); without it the host uses a mock / echo path so the UI is developable
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
|
||||||
|
cd apps/desktop && npm install && npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product env
|
||||||
|
|
||||||
|
`HERMIT_*` preferred, `CST_*` accepted (see `hermit_getenv`). Config dir `~/.config/hermit`.
|
||||||
@@ -12,6 +12,8 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
|
||||||
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
|
||||||
|
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
|
||||||
|
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
|
||||||
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
|
||||||
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
|
||||||
|
|
||||||
@@ -26,6 +28,12 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|
|||||||
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
|
||||||
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
|
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
|
||||||
|
|
||||||
|
Kimi 的 K3 请求使用 `reasoning_effort`;K2.6/K2.7 使用 `thinking`,并在后续请求中回传 `reasoning_content`。默认发送 Kimi 1 小时隐式 prompt cache 提示,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭。
|
||||||
|
|
||||||
|
当 provider 为 Kimi 或 DeepSeek 时,`web_search` 和 `web_fetch` 使用对应的官方联网 Search/Fetch API;其它 provider 使用通用 URL 抓取。
|
||||||
|
|
||||||
|
`kimi-code` 使用 Kimi Code 的 OpenAI-compatible API。它和按量计费的 `kimi` 开放平台使用不同的 Base URL 与 API Key,不能混用。
|
||||||
|
|
||||||
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
|
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
|
||||||
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
|
||||||
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
|
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
|
||||||
@@ -42,6 +50,20 @@ cst-agent --provider deepseek # 首次会询问 key
|
|||||||
cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought
|
cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Kimi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cst-agent --provider kimi # 默认 kimi-k3
|
||||||
|
cst-agent --provider kimi --model kimi-k2.6 # K2 thinking 模式
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kimi Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cst-agent --provider kimi-code # 默认 k3
|
||||||
|
cst-agent --provider kimi-code --model k3-256k
|
||||||
|
```
|
||||||
|
|
||||||
### OpenAI 官方
|
### OpenAI 官方
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Parliament(议会)快速上手
|
||||||
|
|
||||||
|
密码串派生 Realm → 登录夺主席 / 加入席位 → 常驻服务密封同步共享上下文。
|
||||||
|
主席是用户入口;成员席位在本机干活并用 `parliament_note` 通报进度。
|
||||||
|
|
||||||
|
## 一次性初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 交互输入密码,或事先 export CST_REALM_PASSWORD=…
|
||||||
|
cst-agent --realm-password-init
|
||||||
|
```
|
||||||
|
|
||||||
|
磁盘只存 salt + 公钥,**不存私钥**。
|
||||||
|
|
||||||
|
## 日常:登录夺主席,或加入席位
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CST_INSTANCE_ID=laptop-a # 可选;默认用本机 hostname
|
||||||
|
cst-agent --login --realm-label "thinkpad"
|
||||||
|
# 其他机器(同 salt/公钥 + 密码):
|
||||||
|
cst-agent --join --instance phone-b --realm-label "pixel"
|
||||||
|
# TUI: /realm login(夺主席)或 /realm join(不抢主席)
|
||||||
|
```
|
||||||
|
|
||||||
|
跨网段(如 Tailscale)多播发现不可用时,在 Realm 目录放 `peers.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"peers":[{"host":"100.64.0.13","port":9100,"iid":"labpc","name":"labpc"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
效果:
|
||||||
|
|
||||||
|
1. 用密码解锁 Realm;`--login` / `/realm login` 使本机成为**唯一主席**
|
||||||
|
2. `--join` / `/realm join` 只登记席位票:主席空缺时才夺椅,否则以 **member** 身份加入
|
||||||
|
3. 写入本地登录票(24h;成员票在主席易主后仍有效,除非本席被剥夺主席)
|
||||||
|
4. 拉起后台议会服务(发现 + 密封通道按 revision 同步 `parliament.context`;
|
||||||
|
Unix 下 accept 独立线程,dial 失败会指数退避,避免饿死入站)
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cst-agent # TUI: /status 或 /realm
|
||||||
|
# /realm login|join|stop
|
||||||
|
cst-agent --service-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常驻服务(推荐)
|
||||||
|
|
||||||
|
登录后会 fork 后台席位进程;也可用 systemd --user 托管前台循环(崩溃自动拉起):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装脚本附带的辅助命令
|
||||||
|
cst-agent-service install-unit
|
||||||
|
# 在 ~/.config/cst-agent/parliament.env 写入:
|
||||||
|
# CST_REALM_PASSWORD=…
|
||||||
|
# CST_INSTANCE_ID=my-laptop
|
||||||
|
systemctl --user enable --now cst-agent-parliament.service
|
||||||
|
cst-agent-service status
|
||||||
|
```
|
||||||
|
|
||||||
|
或手动前台:`CST_REALM_PASSWORD=… cst-agent --service-fg`。
|
||||||
|
|
||||||
|
## 单机默认(solo)
|
||||||
|
|
||||||
|
未设置 `collaboration` 时,**即使存在密码 Realm,默认也是单机 solo**:`task` /
|
||||||
|
`worker_*` / runtime jobs 可用,不强制登录闸。密码 Realm 仅保管密钥,不再自动开议会。
|
||||||
|
|
||||||
|
要进入议会模式,任选其一:
|
||||||
|
|
||||||
|
- `CST_COLLABORATION=parliament` 或 `settings.json` 里 `"collaboration": "parliament"`
|
||||||
|
- 已有 open 状态的 parliament meta(例如刚 `--login` 过)
|
||||||
|
|
||||||
|
显式议会模式下打开 agent 才要求登录(TTY 下可当场输入密码)。强制恢复本机 worker:
|
||||||
|
`CST_COLLABORATION=legacy_workers`。
|
||||||
|
|
||||||
|
## 议员协作协议
|
||||||
|
|
||||||
|
席位通过共享 `parliament.context` 协作(不是进程内 `task`/`worker_*`):
|
||||||
|
|
||||||
|
1. 开工前阅读 Environment 里的 Shared parliament context
|
||||||
|
2. 用本机工具完成分配切片
|
||||||
|
3. 跨席相关的进度/议程/结果用工具 **`parliament_note`** 发布(`kind=status|agenda|result|handover`)
|
||||||
|
4. 服务在写入后尽快 dial peers;回合内若 revision 变化,会注入 `[cst] Parliament shared context updated`
|
||||||
|
|
||||||
|
显式 `"collaboration": "parliament"`(或 env)时:
|
||||||
|
|
||||||
|
- **硬禁用** `task` / `worker_*` / runtime 委托工具
|
||||||
|
- 提供 `parliament_note`;提示词说明 chair / member 角色合同
|
||||||
|
|
||||||
|
## 换项目(清场)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cst-agent --new-project my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
清空共享上下文、撤销主席与登录票,并停止本机服务。Realm 密钥保留。
|
||||||
|
|
||||||
|
## 传输安全
|
||||||
|
|
||||||
|
Peer 在 Realm 证书互鉴后做 **临时 X25519 密钥交换**,后续议会上下文同步走
|
||||||
|
**XChaCha20-Poly1305**(Monocypher,无需 OpenSSL)。握手前的发现/证书交换仍是明文元数据。
|
||||||
|
|
||||||
|
## Docker 冒烟
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/docker_parliament_test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Alpine 内构建,chair↔member 握手 + 密封通道同步(`sealed=1` / `peer_sync`)。
|
||||||
|
|
||||||
|
## 相关标志
|
||||||
|
|
||||||
|
| 标志 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `--realm-password-init` | 创建密码 Realm |
|
||||||
|
| `--login` | 登录 + 夺主席 + 启服务 |
|
||||||
|
| `--join` | 加入席位(不抢主席)+ 启服务 |
|
||||||
|
| `--service` / `--service-fg` | 仅启服务 |
|
||||||
|
| `--service-stop` | 停服务 |
|
||||||
|
| `--new-project ID` | 清场换项目 |
|
||||||
|
| `--peer-port N` | 登录/服务用的 TCP 端口(默认 9100) |
|
||||||
|
| `--instance ID` | 实例 id(也可用 `CST_INSTANCE_ID`;默认 hostname) |
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,125 @@
|
|||||||
|
# 多模型 worker 与编排模式
|
||||||
|
|
||||||
|
主 agent 使用强模型进行设计与验收,子 agent 按角色绑定较便宜的模型。
|
||||||
|
用户配置目录的 `workers.json` 可设置所有项目的默认配置;存在工作区
|
||||||
|
`.agent/workers.json` 时,以工作区的完整配置为准,不合并两份配置。
|
||||||
|
在工作区创建 `.agent/workers.json`,或者通过 TUI `/agents config <JSON>`、
|
||||||
|
WebUI 扩展面板的「模型与 worker 配置」保存同样的配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orchestrate": true,
|
||||||
|
"max_steps": 20,
|
||||||
|
"max_tasks": 8,
|
||||||
|
"max_requests": 100,
|
||||||
|
"profiles": {
|
||||||
|
"worker": {
|
||||||
|
"provider": "deepseek",
|
||||||
|
"model": "deepseek-flash",
|
||||||
|
"effort": "low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"general": "worker",
|
||||||
|
"explore": "worker",
|
||||||
|
"plan": "worker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
provider 必须已注册,使用其独立保存的凭据或 provider 的环境变量。
|
||||||
|
档案支持 `temperature`、`top_p`、`max_tokens`。不要在此文件中保存 API Key。
|
||||||
|
自定义角色 Markdown frontmatter 也可指定 `profile: worker`;JSON 中的角色绑定优先。
|
||||||
|
未绑定的角色继承主 agent;旧 `model` frontmatter 仍在原 provider 下生效。
|
||||||
|
|
||||||
|
CLI `--orchestrate` / `--no-orchestrate` 可以覆盖工作区设置。开启后主 agent
|
||||||
|
优先委派适合拆分的任务,但保留获授权的写文件和 shell 工具;简单操作可以直接执行。
|
||||||
|
规划模式和只读限制继续约束子 agent。旧 `task` 的只读调用可并行,可写调用串行。
|
||||||
|
|
||||||
|
## V2:任务控制与异步执行
|
||||||
|
|
||||||
|
默认工具配置增加 `worker_start`:`kind=read` 路由到 `explore` 角色;
|
||||||
|
`kind=edit` 路由到 `general`,必须提供 `files` 路径数组,在独立 Git worktree 中编辑;
|
||||||
|
`kind=external` 使用受保护的串行资源。模型仍由上述 profiles/roles 配置决定,
|
||||||
|
例如 explore 绑定 DS Flash、general 绑定 DS Pro,主 agent 使用 Kimi Code。
|
||||||
|
旧 `task`、worker 续接和预算配置继续兼容。
|
||||||
|
|
||||||
|
异步调用返回 `job_id`。`job_read` 查看状态,`job_wait` 最多等待 30 秒;
|
||||||
|
主循环在未完成的作业上等待,不通过反复请求模型轮询。
|
||||||
|
主 agent 的默认 `run_shell` 在 250ms 内未结束时也返回作业状态。
|
||||||
|
`effect=workspace` 表示允许安全中断的工作区命令;未声明的外部命令默认受保护,
|
||||||
|
涉及 fastboot/adb/flash 的命令始终按受保护作业处理。这是调度约定,不是安全认证。
|
||||||
|
|
||||||
|
新消息在执行中发送时会作为纠正指令:任务版本递增,旧版本后续工具调用被拒绝,
|
||||||
|
可中断作业收到取消请求;已完成的副作用不会回滚。受保护外部操作不因普通取消被杀死,
|
||||||
|
只有用户明确强制停止或程序关闭时才强制中断。不要把强制停止用于正在刷写的设备。
|
||||||
|
状态明确记录取消来源,不能把 instruction_changed/user 等同于 timeout。
|
||||||
|
纠正后只等待当前任务版本的作业;旧版本受保护作业仍在后台时,任务报告 partial,
|
||||||
|
不把新指令卡在旧操作上,也不谎报旧操作已完成。
|
||||||
|
|
||||||
|
TUI:`/task state`、`/task readonly`、`/task write`、`/task direct`(禁止委派)、
|
||||||
|
`/task auto`、`/task pause`、`/task resume`、`/task cancel JOB_ID`、`/task force JOB_ID`。
|
||||||
|
暂停/恢复是权限控制,不会重放操作;恢复后发送下一条指令继续。
|
||||||
|
WebUI `/task` 打开任务面板;Qt 桌面/Android 共用 Tasks 面板。
|
||||||
|
面板可查看作业输出、切换约束、取消作业、预览和集成编辑结果;Qt 还提供 worker JSON 配置。
|
||||||
|
HTTP 对应 `GET/POST /api/task`、`POST /api/jobs/cancel`、`POST /api/worktree`。
|
||||||
|
|
||||||
|
编辑 worker 的 `worker_diff` 预览增量,`worker_integrate` 执行三方合并。
|
||||||
|
基线包含用户已有 tracked 修改及显式作用域内的 untracked 输入,使用私有 index,
|
||||||
|
不会暂存、提交或重置用户分支。集成冲突时原文件和用户 index 不变,worktree/补丁保留。
|
||||||
|
非 Git 根目录使用直接执行或兼容 task。worktree 不是恶意 shell 的完整安全边界;
|
||||||
|
可用时编辑 shell 使用系统 sandbox,集成时再检查 tracked 修改范围。
|
||||||
|
|
||||||
|
任务结果使用 completed/partial/blocked/cancelled,存在运行作业、最近动作失败或缺少
|
||||||
|
必要工具证据时不接受 completed,并最多要求一次模型修正;这不是语义正确性的证明。
|
||||||
|
长输出写入证据文件,`evidence_read` 按 ID、字节偏移和长度检索,避免重跑命令。
|
||||||
|
默认活跃上下文预算上限为主 agent 48k、worker 24k,保留稳定系统/工具前缀。
|
||||||
|
会话旁的 `.task-v2.json` 保存状态和有限事件历史,重启只展示历史,不重启外部操作。
|
||||||
|
当前每个运行时最多保留 64 个热作业槽;已结束的槽可复用,旧状态和输出保留为文件,
|
||||||
|
可继续按 job ID 查询。达到 64 个未结束作业时明确拒绝新作业。
|
||||||
|
|
||||||
|
本地验证:`tests/test_runtime` 覆盖版本失效、只读/委派控制、完成证据、异步等待、
|
||||||
|
保护取消、恢复配置、私有 index 与三方集成冲突;`tests/test_subagent` 验证模型路由和预算。
|
||||||
|
真实 API 的费用、延迟、缓存命中提升需要运行下面的付费评测,不从模拟测试推导。
|
||||||
|
|
||||||
|
## 单机默认
|
||||||
|
|
||||||
|
默认协作模式是 **solo / legacy_workers**(未设 `CST_COLLABORATION` 且无 open
|
||||||
|
parliament meta)。本机可直接使用本节全部工具;密码 Realm 不会自动关掉它们。
|
||||||
|
仅当显式 `collaboration=parliament`(或已有 open parliament)时,这些工具被隐藏,
|
||||||
|
改走 `parliament_note`。详见 `docs/parliament.md`「单机默认」。
|
||||||
|
|
||||||
|
作业终态会入队完成事件:TUI 推送一行系统消息,下一模型回合注入 `[cst] job …`
|
||||||
|
笔记,减少盲轮询 `job_wait`。
|
||||||
|
|
||||||
|
## 任务和续接
|
||||||
|
|
||||||
|
首次通过 `task` 指定 `agent`、`description` 和完整 `prompt`,结果包含 `worker_id`。
|
||||||
|
再次调用 `task` 时传 `worker_id` 和新任务即可续接;历史、模型绑定与角色指令保存于
|
||||||
|
主会话目录中,重启恢复主会话后仍可使用。模型配置变化适用于新 worker。
|
||||||
|
运行中断不会自动重放工具;先检查当前文件状态再续接。
|
||||||
|
|
||||||
|
`max_steps` 可降低本次请求上限,不得提高角色或工作区上限。
|
||||||
|
默认每次委派最多 20 个实际模型请求,主回合最多 8 次委派、100 个子 agent 请求。
|
||||||
|
续接也算委派,重试、压缩、总结和缓存命中的请求都计数。达到上限后不再发请求,
|
||||||
|
任务返回最后可用结果与失败原因,不额外调用模型总结。下一主回合重置共享预算。
|
||||||
|
|
||||||
|
TUI `/agents` 查看角色和运行记录,`/agents config` 查看配置,
|
||||||
|
`/agents budget` 查看当前回合总量,`/agents cancel sub-1` 请求取消指定 worker。
|
||||||
|
WebUI 显示 worker 模型、请求数、缓存 token 和续接/取消入口。
|
||||||
|
每次委派的使用记录追加到 `sub-N.runs.jsonl`;最新状态在 `sub-N.json`。
|
||||||
|
|
||||||
|
## 缓存与验证
|
||||||
|
|
||||||
|
复用 worker 保留其历史前缀。角色指令位于环境信息前,任务差异作为新消息追加。
|
||||||
|
支持缓存路由的平台使用包含工作区、模型、角色、系统提示和工具 schema 的稳定标识,
|
||||||
|
相同配置的新 worker 也可利用公共前缀。其它平台仍使用自身隐式缓存或已有缓存断点。
|
||||||
|
缓存数据未返回时显示未知,不把未知视为零命中;实际命中率取决于平台。
|
||||||
|
|
||||||
|
使用 `tests/test_subagent` 验证跨平台路由、持久化续接、预算、并行与取消。
|
||||||
|
真实模型验收应固定模型版本、参数和任务,对比完成率、越界操作、无效调用、请求数及缓存。
|
||||||
|
可运行 `bash scripts/eval_workers.sh --provider kimi-code --model k3`;设置
|
||||||
|
`WORKER_EVAL_CONFIG` 为配置文件路径可对比编排方案。脚本在临时目录测试限定文件修改与
|
||||||
|
长文件定位,保留输出、会话和 worker 使用记录,不删除评测材料。
|
||||||
|
建议任务包含:限定单文件修复、读取截断输出、工具失败恢复、验证结果报告和主 agent 分派验收。
|
||||||
|
不要在真实模型评测中使用生产密钥以外的敏感数据;评测会产生 API 费用。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Hermit / 寄居蟹 · 扁平 SVG 动图包
|
||||||
|
|
||||||
|
设计原则:遵循原始草图。纯色块、橙色方形身体、紫色眼睛、短脚;无渐变、无阴影、无高光。蓝色终端方块就是寄居蟹的壳。
|
||||||
|
|
||||||
|
6 个状态 × 2 个版本,共 12 个 SVG:
|
||||||
|
- idle:等待输入
|
||||||
|
- question:请求问题
|
||||||
|
- thinking:思考,黄色小钳子托腮
|
||||||
|
- coding:写码,>_ 眼睛
|
||||||
|
- success:完成
|
||||||
|
- error:遇到问题
|
||||||
|
|
||||||
|
icons/{状态}-shell.svg 是大的带壳图标;icons/{状态}.svg 是小的无壳图标。透明背景、CSS 循环动画,支持系统减少动态效果。
|
||||||
|
|
||||||
|
直接打开 index.html 查看,支持深浅背景、暂停、速度调整、自动轮播以及两个版本的 SVG 下载。无联网依赖。
|
||||||
|
|
||||||
|
接入示例:<img src="icons/idle.svg" width="24" alt="等待输入">。大图标建议 64px 及以上,小图标建议 24–32px;16px 时状态细节较弱。使用内联 SVG 时可通过 --speed 设置速度。
|
||||||
|
|
||||||
|
普通字符终端不直接渲染 SVG;图标适用于支持图形的终端界面、Webview 或 CLI 伴随应用。
|
||||||
|
|
||||||
|
**TUI 双轨**:默认极简字符/色块帧见 `src/hermit_glyph.c`(idle 眨眼、coding `>_`、ask/thinking/success/error,与本目录 HTML 动画状态一致);`make hermit-font`(Pillow + fonttools,无需 rsvg)烘焙 `res/hermit-frames/` 与 `res/hermit-icons.ttf`(sbix,PUA `U+E700+`)。`make install` / bootstrap 会安装该字体;`HERMIT_ICONS=font` 时输出码点序列(终端需选用 Hermit Icons)。
|
||||||
|
|
||||||
|
更新:橘杏色身体、莓紫眼睛、低饱和蓝绿终端壳。预览主角色以 620ms 缓入缓出插值连续改变眼睛路径与身体姿态,支持中途切换;不重建主角色。状态符号淡入淡出。独立 SVG 保留各状态循环动效;跨状态变形逻辑位于 index.html。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="coding" role="img" aria-label="Hermit 写码 带壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="coding" role="img" aria-label="Hermit 写码 无壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="error" role="img" aria-label="Hermit 遇到问题 带壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="error" role="img" aria-label="Hermit 遇到问题 无壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="idle" role="img" aria-label="Hermit 等待输入 带壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="idle" role="img" aria-label="Hermit 等待输入 无壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="question" role="img" aria-label="Hermit 请求问题 带壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="question" role="img" aria-label="Hermit 请求问题 无壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="success" role="img" aria-label="Hermit 完成 带壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="success" role="img" aria-label="Hermit 完成 无壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="thinking" role="img" aria-label="Hermit 思考 带壳"><title>Hermit · 思考</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M31 22v3m10-3v3"/></g><path class="hermit-mark" d="M43 32v7h-8" stroke="#e9ba47" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |