diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 542ed61e2..4e993fd73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,7 @@ jobs: run: | python3 tests/scripts/macos_installer_release_test.py python3 tests/scripts/macos_portable_package_test.py + python3 tests/scripts/macos_update_dedup_test.py bash tests/scripts/macos_release_scripts_test.sh web-tests: diff --git a/CMakeLists.txt b/CMakeLists.txt index e88e62e2d..6ae1d4dfe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -130,6 +130,7 @@ find_package(nlohmann_json CONFIG REQUIRED) find_package(unofficial-sqlite3 CONFIG REQUIRED) find_package(Crow CONFIG REQUIRED) find_package(Threads REQUIRED) +find_package(ZLIB REQUIRED) # cpr ships with libcurl; on Windows libcurl >= 8.14 is required for proper # certificate handling. Detect the version through the CURL::libcurl target diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html index 8e822d63d..f4b35180c 100644 --- a/assets/desktop_pet/agent_office_pet.html +++ b/assets/desktop_pet/agent_office_pet.html @@ -95,6 +95,7 @@
+ " _script_tag) + string(FIND "${_hex}" "${_script_tag}" _script_pos) + if(_script_pos EQUAL -1) + message(FATAL_ERROR "bin2cpp: shared office character script tag missing") + endif() + string(REPLACE "${_script_tag}" "3c7363726970743e${_script_hex}3c2f7363726970743e" _hex "${_hex}") +endif() string(REGEX REPLACE "(..)" "0x\\1," _arr "${_hex}") string(LENGTH "${_hex}" _hexlen) math(EXPR _len "${_hexlen} / 2") diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake index 7e3326e91..6004c1697 100644 --- a/cmake/acecode_desktop.cmake +++ b/cmake/acecode_desktop.cmake @@ -145,10 +145,12 @@ if(WIN32 OR APPLE) OUTPUT ${ACECODE_DESKTOP_PET_PAGE_CPP} COMMAND ${CMAKE_COMMAND} -DBIN2CPP_INPUT=${ACECODE_DESKTOP_PET_PAGE} + -DBIN2CPP_INLINE_JS=${CMAKE_SOURCE_DIR}/assets/desktop_pet/office_characters.js -DBIN2CPP_OUTPUT=${ACECODE_DESKTOP_PET_PAGE_CPP} -DBIN2CPP_SYMBOL=desktop_pet_page -P ${CMAKE_SOURCE_DIR}/cmake/acecode_bin2cpp.cmake DEPENDS ${ACECODE_DESKTOP_PET_PAGE} ${CMAKE_SOURCE_DIR}/cmake/acecode_bin2cpp.cmake + ${CMAKE_SOURCE_DIR}/assets/desktop_pet/office_characters.js COMMENT "Embedding the desktop agent office" VERBATIM ) @@ -251,9 +253,11 @@ if(APPLE) MACOSX_BUNDLE_BUNDLE_VERSION "${ACECODE_BUILD_VERSION}" MACOSX_BUNDLE_COPYRIGHT "ACECode contributors" ) - add_dependencies(acecode-desktop acecode acecode-computer-use) + add_executable(acecode-macos-cli-launcher ${ACECODE_MACOS_CLI_LAUNCHER_SOURCE}) + add_dependencies(acecode-desktop acecode acecode-computer-use acecode-macos-cli-launcher) set_property(TARGET acecode-desktop APPEND PROPERTY - LINK_DEPENDS $ $) + LINK_DEPENDS $ $ + $) add_custom_command(TARGET acecode-desktop POST_BUILD COMMAND ${CMAKE_COMMAND} -E rm -f $/acecode-desktop @@ -263,6 +267,9 @@ if(APPLE) COMMAND ${CMAKE_COMMAND} -E copy_if_different $ $/acecode-computer-use + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $/acecode-cli COMMAND ${CMAKE_COMMAND} -E rm -rf "$/../acecode-desktop.app" COMMENT "Copying acecode daemon into ACECode.app bundle" diff --git a/cmake/acecode_embed_assets.cmake b/cmake/acecode_embed_assets.cmake index 8b3b90476..076159e89 100644 --- a/cmake/acecode_embed_assets.cmake +++ b/cmake/acecode_embed_assets.cmake @@ -1,96 +1,23 @@ -# acecode_embed_assets: 把 web/ 下的所有文件嵌入成 C++ 字节数组,在 build -# 时生成 ${CMAKE_BINARY_DIR}/generated/static_assets_data.cpp。 -# -# 用法: -# include(cmake/acecode_embed_assets.cmake) -# acecode_generate_embedded_assets(${CMAKE_SOURCE_DIR}/web -# ${CMAKE_BINARY_DIR}/generated/static_assets_data.cpp -# ${BUILD_VERSION}) -# -# 生成的文件实现 `acecode::web::lookup_embedded_asset(name)` —— 静态 std::map -# 把相对路径(如 "index.html" / "components/ace-app.js")映射到 std::span。 -# index.html 的内容会做一次模板替换,把 `?v=__VERSION__` 替换成传入的 -# BUILD_VERSION 字符串(失败 fallback 不替换 — 不阻断 build)。 -# -# 注意: 单文件输出会让 MSVC 编译稍慢(几百 KB cpp);若以后嵌入资源膨胀严重 -# 可拆多 TU。当前 web/ 总量 << 1MB,不优化。 - +# Generate deterministic gzip resources. Track both added/removed paths and +# modifications of existing files so an incremental native build refreshes Vite output. +set(_ACECODE_EMBED_GENERATOR "${CMAKE_CURRENT_LIST_DIR}/../scripts/embed_web_assets.py") function(acecode_generate_embedded_assets web_dir out_file version) - if(NOT EXISTS "${web_dir}") - message(STATUS "[acecode_embed_assets] web dir not found: ${web_dir} — generating empty asset map") - file(WRITE "${out_file}" "// Auto-generated. web/ dir absent at configure time.\n#include \"web/static_assets.hpp\"\nnamespace acecode::web {\nconst std::map>& embedded_asset_map() {\n static const std::map> m;\n return m;\n}\n}\n") - return() + find_package(Python3 REQUIRED COMPONENTS Interpreter) + file(GLOB_RECURSE assets CONFIGURE_DEPENDS "${web_dir}/*") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${assets} "${_ACECODE_EMBED_GENERATOR}") + execute_process( + COMMAND "${Python3_EXECUTABLE}" "${_ACECODE_EMBED_GENERATOR}" + --root "${web_dir}" --output "${out_file}" --version "${version}" + RESULT_VARIABLE result + OUTPUT_VARIABLE report + ERROR_VARIABLE errors + ) + if(NOT result EQUAL 0) + message(FATAL_ERROR "Embedded asset generation failed: ${errors}") endif() - - file(GLOB_RECURSE rels CONFIGURE_DEPENDS RELATIVE "${web_dir}" "${web_dir}/*") - list(FILTER rels EXCLUDE REGEX "(^|/)(README\\.md|\\.gitignore)$") - - set(content "// Auto-generated by acecode_embed_assets. DO NOT EDIT.\n") - string(APPEND content "#include \"web/static_assets.hpp\"\n\n") - string(APPEND content "namespace acecode::web {\n\n") - - set(map_entries "") - set(idx 0) - foreach(rel IN LISTS rels) - set(src_file "${web_dir}/${rel}") - if(IS_DIRECTORY "${src_file}") - continue() - endif() - # GLOB CONFIGURE_DEPENDS notices added/removed names, not changes to - # existing files. Reconfigure when Vite rewrites an embedded asset. - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${src_file}") - - # index.html 走模板替换:把 ?v=__VERSION__ 替成 ?v=${version} - # 其它文件原样读取。 - # - # 关键:必须用 HEX(二进制)模式读,绝不能用文本模式 file(READ ... raw_text)。 - # 压缩后的前端 bundle 里含真实控制字节(某些库把 ASCII 控制符定义成裸字节, - # 如 f.SUB="\x1a"),而 CMake 在 Windows 文本模式下会把 0x1A(Ctrl+Z)当 EOF, - # 把 index.html 在该字节处截断 → 嵌入的前端残缺 → 部署机(走嵌入)白屏。 - # 因此 __VERSION__ 的替换改在 hex 空间做(token 与 version 都是安全 ASCII)。 - if(rel STREQUAL "index.html") - file(READ "${src_file}" hex_data HEX) - # "__VERSION__" 的小写 hex(file(READ HEX) 输出小写) - set(_token_hex "5f5f56455253494f4e5f5f") - set(_ver_tmp "${out_file}.ver.tmp") - file(WRITE "${_ver_tmp}" "${version}") - file(READ "${_ver_tmp}" _version_hex HEX) - file(REMOVE "${_ver_tmp}") - string(REPLACE "${_token_hex}" "${_version_hex}" hex_data "${hex_data}") - else() - file(READ "${src_file}" hex_data HEX) - endif() - - # 文件名 → C++ 变量名:非字母数字转 _,加序号防 collision - string(REGEX REPLACE "[^A-Za-z0-9]" "_" var_safe "${rel}") - set(var_name "asset_${idx}_${var_safe}") - - # 把每两位 hex 转成 0xXX, 格式 - string(REGEX REPLACE "(..)" "0x\\1," hex_arr "${hex_data}") - string(LENGTH "${hex_data}" hex_len) - math(EXPR data_len "${hex_len} / 2") - - # 空文件:hex_arr 为空,需要一个最小占位避免 "{}; " 编译失败 — - # 写一个 0 字节占位,size 仍为 0。 - if(data_len EQUAL 0) - string(APPEND content "static const unsigned char ${var_name}[1] = {0};\n") - else() - string(APPEND content "static const unsigned char ${var_name}[] = {${hex_arr}};\n") - endif() - # 把反斜杠路径分隔符换成 / 让 map key 跨平台一致。 - string(REPLACE "\\" "/" rel_norm "${rel}") - string(APPEND map_entries " {\"${rel_norm}\", {${var_name}, ${data_len}}},\n") - - math(EXPR idx "${idx} + 1") - endforeach() - - string(APPEND content "\nconst std::map>& embedded_asset_map() {\n") - string(APPEND content " static const std::map> m = {\n${map_entries} };\n") - string(APPEND content " return m;\n}\n\n") - string(APPEND content "} // namespace acecode::web\n") - - file(WRITE "${out_file}" "${content}") - message(STATUS "[acecode_embed_assets] wrote ${out_file} with ${idx} assets (BUILD_VERSION=${version})") + string(STRIP "${report}" report) + message(STATUS "${report}") endfunction() # 拿 git short hash 作 BUILD_VERSION;非 git 环境 fallback 用日期+秒。 diff --git a/cmake/acecode_layer_contract.cmake b/cmake/acecode_layer_contract.cmake index 61e4ef31f..3c55840d2 100644 --- a/cmake/acecode_layer_contract.cmake +++ b/cmake/acecode_layer_contract.cmake @@ -18,6 +18,10 @@ if(TARGET acecode_deepin_window_effects) list(APPEND _primary_targets acecode_deepin_window_effects) list(REMOVE_ITEM _desktop_inactive ${ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE}) endif() +if(TARGET acecode-macos-cli-launcher) + list(APPEND _primary_targets acecode-macos-cli-launcher) + list(REMOVE_ITEM _desktop_inactive ${ACECODE_MACOS_CLI_LAUNCHER_SOURCE}) +endif() if(TARGET acecode_computer_use_native) list(APPEND _primary_targets acecode_computer_use_native acecode-computer-use) endif() diff --git a/cmake/acecode_layer_libraries.cmake b/cmake/acecode_layer_libraries.cmake index be9e22746..1500fa923 100644 --- a/cmake/acecode_layer_libraries.cmake +++ b/cmake/acecode_layer_libraries.cmake @@ -142,7 +142,7 @@ acecode_set_source_define("${ACECODE_CHANNEL_BRIDGE_SOURCE}" acecode_collect_group_sources(_web apps/web) acecode_add_layer_library(acecode_web apps ${_web} "${CMAKE_BINARY_DIR}/generated/static_assets_data.cpp") -target_link_libraries(acecode_web PUBLIC acecode_host acecode_crow) +target_link_libraries(acecode_web PUBLIC acecode_host acecode_crow PRIVATE ZLIB::ZLIB) acecode_collect_group_sources(_tui apps/tui) acecode_add_layer_library(acecode_tui apps ${_tui} @@ -166,6 +166,7 @@ target_link_libraries(acecode_cli PUBLIC acecode_tui acecode_headless acecode_da # Reusable desktop code is application-owned, with only base-layer linkage. # Browser hosts and platform shell entry points remain executable-owned. set(ACECODE_DESKTOP_BINARY_ONLY_SOURCES + ${ACECODE_MACOS_CLI_LAUNCHER_SOURCE} ${ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE} ${ACECODE_DESKTOP_LINUX_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE} ${ACECODE_DESKTOP_MAIN_SOURCE} ${ACECODE_DESKTOP_SPLASH_SOURCE} diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index 95d7a1dfe..9418de5f5 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -5,6 +5,7 @@ set(ACECODE_CHANNEL_BRIDGE_SOURCE "${CMAKE_SOURCE_DIR}/src/host/channels/bridge. set(ACECODE_WINPTY_AGENT_LOCATION_SOURCE "${CMAKE_SOURCE_DIR}/src/base/pty/winpty_agent_location.cpp") set(ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/deepin_window_effects.cpp") set(ACECODE_DESKTOP_MAIN_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/main.cpp") +set(ACECODE_MACOS_CLI_LAUNCHER_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/macos_cli_launcher.cpp") set(ACECODE_DESKTOP_SPLASH_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/splash_screen.cpp") set(ACECODE_DESKTOP_WEB_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/web_host.cpp") set(ACECODE_DESKTOP_PET_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet.cpp") diff --git a/docs/channels.md b/docs/channels.md index ddd885836..a4ebcb966 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -151,12 +151,15 @@ new one. | Command | Effect | | --- | --- | | `/help` | Commands available to the sender | -| `/status` | Platform, session, location, model, busy state, pending approvals and questions | +| `/status` | Platform, session, location, model, permission mode, reasoning effort, busy state, pending approvals and questions | | `/stop` | Stop the current turn | | `/new` | New session in the bound session's workspace, or a no-workspace session | | `/sessions`, `/sessions more`, `/sessions search ` | Numbered list of sessions to switch to | | `/resume ` | Switch the binding; numbers refer to the latest list | | `/model [name]` | List saved models, or switch the bound session's model from the next turn | +| `/mode [default\|auto\|yolo]` | Show or change the bound session's permission mode: default, automatic, or full access (`accept-edits` is an alias for `auto`) | +| `/yolo` | Switch directly to full access, skipping tool permission prompts and allowing pending permission requests | +| `/effort [level\|default]` | Show current reasoning effort and supported levels, set a supported level, or restore the model preset with `default` | | `/approve `, `/deny ` | Decide a permission request once | | `/aq ...` | Answer an AskUserQuestion, with the same syntax as `/rc` | @@ -169,6 +172,14 @@ starts with `/` is expanded as an opencode or skill command exactly like Web input. Plain text sent while a question is pending is handed to that question as an interjection. +`/mode`, `/yolo`, and `/effort` apply only to the bound session and work while +it is busy. Permission changes follow the same rules as Desktop. Effort changes +take effect on the next model request, including later requests in the current +turn; the in-flight request is not interrupted. Use `/effort` to see the current +model's supported levels. Unsupported levels leave the setting unchanged. These +commands require an existing session (`/new` or a normal message creates one) +and do not change global defaults. + ### Output And Media The assistant text of each turn and turn errors are sent; tool progress is not. diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 029612bf1..3c6a8c6c2 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -235,6 +235,14 @@ update their transcript presentation. --- +### Embedded frontend encoding + +Embedded frontend resources negotiate gzip using `Accept-Encoding`. Compressed +responses include `Content-Encoding: gzip` and `Vary: Accept-Encoding`; clients +without gzip support (including explicit `gzip;q=0`) receive the original bytes +with the same content type and cache policy. SPA fallback follows the same rule. +Development `--static-dir` resources continue to be read from disk on each request. + ## 3. HTTP Endpoint Index | Method | Path | Purpose | @@ -566,6 +574,8 @@ disabled, the write is silently ignored. `session` 与 `workspace` 可省略;默认选最近办公室。选择子会话时返回它所属的根办公室。 显式选择不存在或已归档会话返回 `404`,并在可用时附带当前 `offices`。 +绑定专家的条目保留 `expert_id`、`expert_member_id` 与 `expert` 展示信息,其中 `expert.avatar_identity` 是目录头像、子 agent 和办公室共用的稳定像素身份。引用式专家团成员使用被引用专家身份,主会话使用主理人身份;内嵌成员使用团队 ID 与成员 ID 的组合。`expert.avatar_url` 继续指向用户配置的头像(没有配置时为空)。这些字段不包含本地文件路径。 + ```json { "offices": [{"id":"root","workspace_hash":"abc123","title":"Review", "last_user_message_at":"2026-10-06T10:00:00Z"}], @@ -1071,11 +1081,11 @@ storage directly; they do not call the daemon HTTP API: | Tool | Behavior | |---|---| -| `create_thread` | create a background thread and queue its initial prompt | +| `create_thread` | create a thread in the inherited or explicitly selected workspace and queue its initial prompt | | `fork_thread` | fork completed persisted history into a new thread | | `list_threads` | globally list pinned threads plus cursor-paginated recent threads | | `read_thread` | read bounded, cursor-paginated turns from any workspace | -| `send_message_to_thread` | queue a follow-up prompt | +| `send_message_to_thread` | queue a follow-up prompt to a thread in any workspace | | `wait_threads` | wait for up to eight targets across workspaces using event cursors | | `set_thread_title` | rename a thread | | `set_thread_pinned` | update the existing pinned-session state | @@ -1098,7 +1108,36 @@ the next page; it is an offset into the current ordering, not a frozen snapshot. `errors` reports incomplete project scans. `read_thread` and each `wait_threads` target accept optional `workspaceHash` from the list for direct lookup and disambiguation when the same `threadId` exists in multiple projects. -Creation and mutation tools retain their existing calling-workspace semantics. +Other mutation tools retain their existing calling-workspace semantics. + +`create_thread` accepts required `prompt`, optional `title`/`model`, and an +optional target: `workspaceHash` for a registered workspace, `cwd` for an +existing absolute directory, or `noWorkspace:true`. These three target forms +are mutually exclusive. An omitted target (`noWorkspace:false` also counts as +omitted) inherits the caller's workspace-free state or working directory. +Workspace-free threads receive independent cache directories and persist +`no_workspace:true`, so the no-workspace sidebar list can discover them. +Directory targets are registered as visible workspaces, preserving existing +names and other workspace preferences. An explicit `cwd` is canonicalized; +a workspace hash retains the registered workspace's directory identity. +Invalid target types, relative/missing/non-directory paths, unknown hashes, +and conflicting selectors fail without creating a thread or silently falling +back to the caller's directory. + +For example, `create_thread({"cwd":"N:/Users/shao/test333","prompt":"哈哈哈"})` +returns `{"threadId":"...","title":"","status":"running","cwd":"...", +"workspaceHash":"...","noWorkspace":false}`. The returned `workspaceHash` +is the storage project hash, including for workspace-free threads, consistent +with `list_threads` and `read_thread`. If initial input delivery fails, the +new thread is destroyed and its files are removed from its actual project. + +`send_message_to_thread` accepts optional `workspaceHash` from creation or +discovery. It locates threads globally and restores inactive targets using +their own cwd and workspace-free state. Ambiguous ids require a hash; an id +already active in another project is rejected to prevent misdelivery. A +successful result is `{"threadId":"...","delivery":"queued","workspaceHash":"..."}`. +This queues ordinary input; it does not answer or dismiss a pending user +question, and sending to the calling thread remains invalid. `delete_thread` also removes search-index and pin records. It may target its calling thread, including a cascade whose tree contains the caller. In that @@ -2672,6 +2711,13 @@ when no main avatar is configured. `state_avatar_urls` contains each state that has an effective image: the configured state image, or the main avatar when that state is not configured. +Definitions and their `agents` also include `avatar_identity`, a stable key for +the shared virtual-office pixel portrait. Clients use it when `avatar_url` is +empty. Session expert presentation carries the corresponding selected member's +identity and custom avatar URL, so spawning a new session does not change the +expert's pixel appearance. Built-in experts use pixel portraits; user-supplied +image and state-image endpoints keep their existing behavior. + `GET /api/experts/:id/avatar?workspace=` serves the main avatar. Appending `&state=working`, `&state=needs_attention`, or `&state=idle` serves that state image and falls back to the main avatar if the state file becomes @@ -2801,10 +2847,43 @@ Returns the effective skill directory: } ``` -`source` is `project_acecode`, `project_agent`, or `global_acecode`. -`global_path` always points at the user-global skills root -(`~/.acecode/skills`) regardless of which root was selected as `path`; -the settings page "open global skills directory" button relies on it. +With default scanning, `source` is `project_acecode`, `project_agent`, or +`global_acecode`, and `global_path` points at the active ACECode data +directory's `skills` folder. With custom scan paths, `path` is the first +configured project root (or the first global root if none), `global_path` +is the first global root, and `source` is `project_configured` or +`global_configured`. An unconfigured scope returns an empty path. Custom +roots are not created by this request. + +### `GET /api/config/skill-paths` + +Returns `{ "paths": [".acecode/skills", "${ACECODE_DATA_DIR}/skills"], +"default_paths": [...], "customized": true }` (illustrative custom list). +`paths` is the editable effective list; defaults honor legacy +`skills.reuse_opencode` and `skills.external_dirs`. + +### `PUT /api/config/skill-paths` + +Body: `{ "paths": [".acecode/skills", "~/.codex/skills"] }`. +Persists `skills.scan_paths` and refreshes daemon and existing session +skill registries (session prompt snapshots apply at the control boundary). +Returns the same shape as GET. Requires normal API authorization; returns +400 for invalid input, 500 for persistence failure, and 409 during data +directory migration. Failed validation/persistence leaves active paths intact. + +- Missing/null `skills.scan_paths` preserves legacy scanning exactly. + PUT `{ "paths": null }` restores this default behavior. +- An explicit list replaces all ordinary defaults and legacy external roots; + an empty list disables ordinary scanning without deleting skills. +- Entries are searched in list order; first skill name wins. Each relative + entry expands from the selected workspace through ancestors, excluding HOME + (or up to the filesystem root if HOME is not an ancestor). Global entries + remain global when no workspace is selected. +- Absolute paths, `~`, and `${ENV}` are supported. `${ACECODE_DATA_DIR}` resolves + to the active data directory; `${XDG_CONFIG_HOME}` / `${XDG_CACHE_HOME}` fall + back to `~/.config` / `~/.cache`. Unset other variables are skipped. +- Expert package skill roots retain priority. Allow/disable/platform policies + and seed installation locations are unchanged. ### `GET /api/skills?workspace=` @@ -2831,9 +2910,15 @@ including, HOME) or `"global"` (user-global roots and `config.skills.external_dirs`). `workspace` is optional; without it the daemon's compatibility workspace (its own cwd) is used. -Disabled config entries whose skill no longer exists on disk (ghost -entries) are still included with `enabled:false` and `source:""` so the UI -can release them from `config.skills.disabled`. +When `skills.scan_paths` is configured, the same ordered resolution used by +runtime discovery determines duplicate selection; relative entries are +classified as project roots and absolute entries as global roots. + +Only discovered skills are listed. A skill whose `SKILL.md` was deleted or +whose directory is outside the effective scan scope disappears on refresh, +even if its name remains in `config.skills.disabled`. Listing does not prune +disable preferences: a skill restored later (or discovered in another +workspace) still appears with `enabled:false`. ### `PUT /api/skills/:name?workspace=` diff --git a/docs/macos-release.md b/docs/macos-release.md index da9f1949a..86eb7ca7a 100644 --- a/docs/macos-release.md +++ b/docs/macos-release.md @@ -68,16 +68,30 @@ package size, and SHA-256. Native preflight additionally enforces: outside App Translocation and other app bundles. Custom directories are supported alongside `~/Applications` and `/Applications`. -The updater stages a hidden sibling, validates it, retains a uniquely named previous bundle, -and rolls back when replacement validation fails. New PKG installations always +The updater creates a private sibling directory `.acecode-update-` with +`staging/ACECode.app`, `previous/ACECode.app`, and `failed/ACECode.app` slots. +It validates the staged app, retains the previous bundle, and rolls back when +replacement validation fails. All copies retain the actual name `ACECode.app`: +an old process can remain alive until restart, and renaming its bundle to a +random name can expose that name in macOS permission prompts. The UUID belongs +to the containing directory, not the application name. Empty containers are +removed on exit; remaining application copies are preserved. Copies inside an +update directory cannot be used as self-update installation targets. +New PKG installations always use `~/Applications`; existing `/Applications` installations remain supported but are neither relocated nor deleted automatically. Custom installer locations (including the `~/Apps` fallback) update in place without elevation when the current account has permission. Unwritable locations require a manual installation or moving the app to a writable folder. Retained -`.ACECode-.previous.app` backups require manual cleanup when no longer -needed; the updater does not delete unrelated or existing backups. +`.acecode-update-/previous/ACECode.app` backups require manual cleanup +when no longer needed and their old processes have exited. Historical +`.ACECode-.previous.app` backups are left untouched; the updater does not +delete unrelated or existing backups. An update executed by an older version +still uses that version's naming policy. After updating, quit and reopen the +installed `ACECode.app` to stop running from the old copy. macOS permission +dialog attribution must be verified on a Mac; portable filesystem tests do not +prove the text of a system permission dialog. Older ACECode versions with the two-directory restriction need one manual installation of the new version before custom-location self-update is available. diff --git a/docs/release-artifacts.md b/docs/release-artifacts.md index 2194f36d9..17b885206 100644 --- a/docs/release-artifacts.md +++ b/docs/release-artifacts.md @@ -20,6 +20,8 @@ Deepin 构建是发布任务的必需依赖,不能因已有普通 Linux 包而 调试符号使用 `dev_only.` 文件名前缀;避免方括号等会被 GitHub 改写的字符,使 `SHA256SUMS.txt` 中的文件名与下载名一致。 +macOS 更新 ZIP 的运行时与资源只保存在 `ACECode.app` 中。根目录 `acecode` 是从已签名、公证应用的 `Contents/MacOS/acecode-cli` 复制的小型转发程序(上限 256 KiB),让旧版平铺 CLI 更新器仍可安装;它按自身真实路径执行应用内 daemon,资源从应用内解析。禁止再附带根目录 `share`、`channels` 或 `acecode-computer-use`。检查模型目录和种子时使用应用内路径,并验证转发程序与应用内副本一致。普通 macOS 手动便携包保留既有布局。 + 完整正式版同步到 aupdate 时,验证六类更新 ZIP 和两个 PKG 的版本、大小、哈希及公网下载。Linux 更新 ZIP 使用 `linux-x64-updater-v1`、`linux-arm64-updater-v1` manifest target。PKG 是独立手动下载项,不写入自动更新 manifest。 本地可运行 `python tests/scripts/verify_release_assets_test.py` 验证缺包、空包、重复包和错误版本的阻断逻辑;macOS CI 另验证签名、公证、安装器布局与打包脚本。 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/.openspec.yaml b/openspec/changes/add-channel-mode-and-effort-commands/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/design.md b/openspec/changes/add-channel-mode-and-effort-commands/design.md new file mode 100644 index 000000000..3e54dcc50 --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/design.md @@ -0,0 +1,23 @@ +## Context + +内置通道共用 `channels/core/commands` 和 `Conversations`。权限与 effort 的真实状态由 `SessionRegistry` 管理:`set_permission_mode` 同步模式、清理许可缓存并在 yolo 时解决待审批;`set_reasoning_effort` 校验能力、替换后续请求使用的 provider,持久化失败回滚。 + +## Goals / Non-Goals + +**Goals:** 复用现有会话接口及生命周期,保持各平台一致的命令行为与中文回执。 + +**Non-Goals:** 不增加全局默认设置,不修改权限引擎、模型能力规则或新会话默认权限;不改前端。 + +## Decisions + +1. 在既有命令解析器新增 Mode、Effort 和 Yolo 类型。模式参数在解析层归一化;只接受目标三档和既有 auto 别名,拒绝多余参数。 +2. `ConversationDeps` 注入权限设置、模型状态读取、effort 设置回调,由 daemon 组合根接入 registry。通道不直接操作 SessionEntry 内部状态,也不创建第二套权限规则。 +3. `/effort default` 映射为空 optional;查询从 `SessionModelState` 读取实际值及启用的 `supported_efforts`。成功回执使用 registry 返回状态,失败时查询并展示现有能力。 +4. 控制命令沿用先授权、去重、恢复绑定的顺序,在普通输入/技能展开前截获。无绑定时沿用提示,不悄悄建会话。 +5. 用解析单测、路由假服务测试和 daemon 生产装配端到端测试分别验证语法、隔离以及真实 registry 行为。 + +## Risks / Trade-offs + +- [用户在待审批时发送 yolo] → 沿用桌面端语义,立即允许挂起请求,帮助中明确跳过工具权限确认。 +- [模型能力变化或持久化失败] → 由 registry 再校验并保留原设置,通道不宣称成功。 +- [已安装 daemon 未更新] → 本次验证源码和测试产物,部署需要重新构建并启动对应版本。 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/proposal.md b/openspec/changes/add-channel-mode-and-effort-commands/proposal.md new file mode 100644 index 000000000..b78828c8b --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/proposal.md @@ -0,0 +1,24 @@ +## Why + +消息通道目前只能切模型、逐次批准工具,无法像桌面端一样调整当前会话的权限模式和思考深度。用户需要在 IM 中直接切换默认、自动、完全审批模式,并用 `/yolo` 一步开启完全访问权限。 + +## What Changes + +- 增加 `/mode [default|auto|yolo]`,查询或切换当前绑定会话的权限;`accept-edits` 兼容为 `auto`。 +- 增加 `/yolo`,直接切换为 `yolo`,沿用现有完全访问权限语义。 +- 增加 `/effort [档位|default]`,查询当前值与模型支持的档位,或设置思考深度;`default` 恢复模型预设。 +- 帮助和状态回复展示新命令、当前权限及 effort;命令在忙碌时立即处理,不进入模型上下文。 + +## Capabilities + +### New Capabilities + +- `channel-session-controls`: 内置消息通道的会话权限与 effort 指令、回执和错误语义。 + +### Modified Capabilities + +无。内置消息通道原规范仍在 `add-desktop-im-channels` 变更中,主规范尚未收录。 + +## Impact + +修改 `channels/core` 的解析和路由、daemon 的会话服务装配、对应 C++ 测试及 `docs/channels.md`。复用 `SessionRegistry` 的权限和 reasoning 接口,保持默认新会话权限、授权检查、绑定隔离和消息去重。 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/specs/channel-session-controls/spec.md b/openspec/changes/add-channel-mode-and-effort-commands/specs/channel-session-controls/spec.md new file mode 100644 index 000000000..e1b425798 --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/specs/channel-session-controls/spec.md @@ -0,0 +1,45 @@ +## Purpose + +为内置消息通道提供与当前会话绑定的权限模式和思考深度指令,使用户在 IM 中查询并调整执行策略,同时保留授权、绑定隔离和忙碌会话的既有行为。 + +## ADDED Requirements + +### Requirement: 通道权限模式指令 + +内置消息通道 SHALL 支持 `/mode` 查询及 `/mode default|auto|yolo` 切换绑定会话权限,兼容 `accept-edits` 为 `auto`。`/yolo` SHALL 直接等价于 `/mode yolo`,使用现有完全访问权限语义,包括允许已挂起的权限请求。所有回复 SHALL 使用中文并展示模式名称。 + +#### Scenario: 切换三种权限 +- **WHEN** 已授权用户依次发送 `/mode auto`、`/yolo`、`/mode default` +- **THEN** 仅其绑定会话依次切到自动、完全访问权限和默认权限,持久化模式并回复成功 + +#### Scenario: 查询与错误输入 +- **WHEN** 用户发送 `/mode` 或 `/mode invalid` 或 `/yolo extra` +- **THEN** 查询返回当前模式和用法,错误输入返回用法并保持原状态 + +### Requirement: 通道思考深度指令 + +通道 SHALL 支持 `/effort` 查询当前思考深度与模型支持的档位、`/effort <档位>` 切换,以及 `/effort default` 恢复模型预设。档位 SHALL 取自当前模型能力;不支持的值、能力不可用或更新失败 SHALL 返回明确错误,保留原状态。 + +#### Scenario: 忙碌时调整深度 +- **WHEN** 会话正在执行,用户发送模型支持的 `/effort high` +- **THEN** 更新会话设置并持久化,从下一次模型请求起生效,当前请求不中断 + +#### Scenario: 恢复模型预设 +- **WHEN** 用户发送 `/effort default` +- **THEN** 移除会话覆盖,回复更新后的有效档位 + +#### Scenario: 模型不支持指定档位 +- **WHEN** 用户请求当前模型不支持的 effort +- **THEN** 回复中文错误和当前可用档位,原设置不变 + +### Requirement: 命令路由与可发现性 + +`/help` SHALL 列出新指令,`/status` SHALL 显示当前权限模式和 effort。上述新指令 SHALL 在会话忙碌时直接处理且不进入对话或技能展开。未授权、无绑定、会话已删除及重复消息 SHALL 沿用既有处理,不能修改其他会话或全局配置。 + +#### Scenario: 控制命令不进入模型 +- **WHEN** 已绑定会话收到新的权限或 effort 命令 +- **THEN** 回复操作结果,模型输入数量保持不变 + +#### Scenario: 无绑定或未授权 +- **WHEN** 无绑定的联系人或未授权用户发送 `/yolo` +- **THEN** 提示建立会话或完成授权,不新建会话且不修改任何权限 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/tasks.md b/openspec/changes/add-channel-mode-and-effort-commands/tasks.md new file mode 100644 index 000000000..c2cfd4c36 --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/tasks.md @@ -0,0 +1,9 @@ +## 1. 指令与路由 + +- [x] 1.1 增加 mode、yolo、effort 解析和帮助,解析测试覆盖别名、参数错误和忙碌策略。 +- [x] 1.2 接入会话服务及中文回执、查询和状态展示;路由测试覆盖会话隔离、无绑定、去重、忙碌及失败。 + +## 2. 集成验收 + +- [x] 2.1 增加 daemon 生产装配端到端用例,验证真实权限切换、挂起审批及 effort 更新和恢复默认。 +- [x] 2.2 更新通道命令文档,通过 Windows C++ 定向回归、OpenSpec 严格校验和 diff 检查,记录验收边界。 diff --git a/openspec/changes/add-channel-mode-and-effort-commands/verification.md b/openspec/changes/add-channel-mode-and-effort-commands/verification.md new file mode 100644 index 000000000..205a2d852 --- /dev/null +++ b/openspec/changes/add-channel-mode-and-effort-commands/verification.md @@ -0,0 +1,23 @@ +# 验收记录 + +日期:2026-10-11。范围:当前检出的内置消息通道 `/` 指令及 `/help`,未修改设置页 UI。 + +## 构建与静态检查 + +- `cmake --build build --config Release --target acecode_unit_tests --parallel 4`:通过。 +- `openspec validate add-channel-mode-and-effort-commands --strict`:通过。 +- 本次九个代码、测试与文档文件的 `git diff --check`:通过。 +- `python scripts/layers/check_layers.py --strict`:全仓仍有两项与本次无关的工作区问题:`src/apps/desktop/web_host.cpp:73` 的 `web_host_focus.hpp` include 未解析;`src/host/session_host/thread_service.cpp` 为 1259 行,超过登记上限 1211。本次修改没有对应命中。 + +## 回归范围 + +使用 Windows Release 测试程序执行 `ChannelCommands.*:ChannelRouter.*:ImChannelsEndToEnd.*:SessionReasoningTest.*`。 + +最终结果:4 个测试套件共 55 项全部通过,耗时约 86 秒。 + +- 解析及路由:参数、别名、帮助、忙碌时执行、消息去重、绑定隔离、无绑定、无授权、会话删除、失败保留状态。 +- daemon 生产装配及本机模拟 Telegram:三档权限切换与持久化、`/yolo` 放行正在等待的审批、effort 支持档位查询、更新与恢复预设、非法档位拒绝。 +- 原有跨平台集成回归及 reasoning 忙碌更新、持久化失败回滚测试。 +- 首轮 `/help` 回执等待超时:测试原先只等待权限状态更新就继续发送,未等限速队列中的回执;已改为逐条等待实际回复,保留失败时的回复快照用于诊断。 + +验证使用生产通道与会话实现、模拟平台服务器;未向真实聊天账号发送消息,未重启或替换用户正在运行的 Desktop/daemon。 diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md index 172a3f454..7569750ae 100644 --- a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/design.md @@ -4,7 +4,7 @@ App owns navigation and panels; ChatView owns input, cancellation and reasoning ## Goals / Non-Goals -Keep each action's existing handler and lifetime owner. Share shortcut definitions between dispatch, help and terminal forwarding. No rebinding editor, backend protocol change, packaging or release. +Keep each action's existing handler and lifetime owner. Share shortcut definitions between dispatch, help and terminal forwarding. No backend protocol change, packaging or release. ## Decisions @@ -25,3 +25,10 @@ Keep each action's existing handler and lifetime owner. Share shortcut definitio ## Migration Plan Ship with the regular Web build; Desktop requires a later embedded bundle rebuild. No stored-data migration. Reverting this change restores the old UI and listeners. + +## 2026-10-11: Editable bindings + +- Every displayed binding gains an accessible edit icon to its right. A focused recording dialog captures a chord without dispatching it; Save validates conflicts and reserved keys, Cancel leaves the current binding intact, and Restore default resets only the selected action. History exposes separate previous/next bindings within its existing row. +- Keep defaults immutable and persist validated overrides through the existing preference storage helpers. One observable store supplies dispatch, help, button hints and terminal forwarding; updates apply immediately and storage events synchronize other windows. A failed persistent write leaves the old binding active and reports an error. +- Contextual send/newline/history/save/find/dismiss handlers consult the same registry while retaining their current focus and IME guards. Do not synthesize keyboard events or create parallel action implementations. Browser-default suppression and sandboxed presentation forwarding follow the effective binding. +- Record exact physical key and modifiers. Prevent duplicates (including optional-Shift reasoning defaults), standalone modifiers, printable unmodified global shortcuts, focus traversal and known system/browser editing chords. Preserve visible close/cancel controls so recording Escape is safe. diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md index 520d11b43..821c0a5e6 100644 --- a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/proposal.md @@ -5,6 +5,7 @@ ## What Changes - 设置新增“快捷键”页签,按功能及按键搜索,搜索栏沿用已归档会话的吸顶布局。 +- 每项按键右侧提供编辑图标;支持录入、冲突校验、持久保存和单项恢复默认,实际处理入口与提示同步更新(2026-10-11 补充)。 - 实现已确认的七项默认快捷键,以及 Ctrl+- 前进、Ctrl+Shift+- 后退、Ctrl+Alt+> / < 增减当前会话思维深度。 - 保留现有快捷键,明确输入法、弹窗、编辑器及终端的事件优先级。 - 消息通道卡片保持稳定高度,管理位于连接按钮左侧;启用后可取消连接。状态和请求详情集中到管理对话框。 diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md index d1bf59662..02f221434 100644 --- a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/specs/keyboard-shortcuts/spec.md @@ -4,6 +4,21 @@ Make frequent ACECode Web and Desktop actions discoverable and executable throug ## ADDED Requirements +### Requirement: Editable persistent bindings +Each listed shortcut SHALL expose an edit icon after its key display. Recording SHALL capture the new combination without performing any application action. Saving SHALL replace the old binding immediately, update visible hints, and persist across reopening. Cancel SHALL leave the binding unchanged. Restore default SHALL affect only the selected binding. Previous/next history keys SHALL be independently editable. + +#### Scenario: Rebind a common action +- **WHEN** the user edits New session from Ctrl+Alt+N to Ctrl+Alt+J and saves +- **THEN** only the new chord creates a session, its label updates, and the binding survives reload + +#### Scenario: Conflict or persistence failure +- **WHEN** the chosen chord conflicts with another action, is reserved, or cannot be saved +- **THEN** an actionable inline message is shown and the old binding remains active + +#### Scenario: Contextual shortcut +- **WHEN** the user edits a sending, newline, history, saving, finding or dismissal binding +- **THEN** the new chord works in the owning context with existing IME and focus protections + ### Requirement: Searchable shortcut settings Settings SHALL expose a 快捷键 section containing both existing and new shortcuts, searchable by action, description and key combination. The search bar SHALL remain visible at the top of the settings content scroll area. @@ -12,7 +27,7 @@ Settings SHALL expose a 快捷键 section containing both existing and new short - **THEN** the opaque search bar stays at the top and the list filters to matching shortcuts ### Requirement: Common application actions -The application SHALL support Ctrl+Alt+N for a new current-workspace session, Ctrl+Alt+I to focus its input without changing draft text, Ctrl+Alt+B/E for left/right panels, Ctrl+, for settings, Ctrl+/ for shortcut settings, and Ctrl+Shift+. for stopping current work. Existing search, console, find and contextual editing shortcuts SHALL retain their behavior. macOS SHALL display and accept corresponding Cmd/Option bindings except the existing Ctrl+backquote console binding. +With default bindings, the application SHALL support Ctrl+Alt+N for a new current-workspace session, Ctrl+Alt+I to focus its input without changing draft text, Ctrl+Alt+B/E for left/right panels, Ctrl+, for settings, Ctrl+/ for shortcut settings, and Ctrl+Shift+. for stopping current work. Existing search, console, find and contextual editing shortcuts SHALL retain their behavior until explicitly rebound. macOS SHALL display and accept corresponding Cmd/Option defaults except the existing Ctrl+backquote console binding. #### Scenario: Navigation history - **WHEN** the user presses Ctrl+- or Ctrl+Shift+- @@ -27,7 +42,7 @@ The application SHALL support Ctrl+Alt+N for a new current-workspace session, Ct - **THEN** the corresponding existing action runs once in the active workspace/session and preserves drafts and cancellation behavior ### Requirement: Incremental reasoning depth -Ctrl+Alt+> SHALL increase and Ctrl+Alt+< SHALL decrease the active conversation's supported reasoning depth. The physical Period/Comma keys with or without Shift SHALL be accepted. The effective default SHALL be respected, unsupported levels skipped, bounds clamped and in-flight changes guarded. +With default bindings, Ctrl+Alt+> SHALL increase and Ctrl+Alt+< SHALL decrease the active conversation's supported reasoning depth. The physical Period/Comma keys with or without Shift SHALL be accepted by default. The effective default depth SHALL be respected, unsupported levels skipped, bounds clamped and in-flight changes guarded. #### Scenario: Running conversation - **WHEN** a running conversation changes depth using the keyboard diff --git a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md index d8f27795b..a31e50f95 100644 --- a/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md +++ b/openspec/changes/add-keyboard-shortcuts-and-compact-channels/tasks.md @@ -14,7 +14,25 @@ - [x] 3.1 Run focused and full Web tests, i18n generation, build, strict OpenSpec validation and diff checks; record results. - [x] 3.2 Run browser checks across desktop/narrow, light/dark and keyboard/overlay/IME contexts; record evidence and separate Web proof from installed Desktop verification. -## Validation evidence (2026-10-07) +## 4. Editable bindings (2026-10-11) + +- [x] 4.1 Implement observable persistent overrides, capture/validation/conflict/reset logic; verify default compatibility, modifier collisions and storage failure tests. +- [x] 4.2 Connect effective bindings to global/contextual handlers, labels, terminal and presentation forwarding; verify old bindings stop working and new bindings act once. +- [x] 4.3 Add edit icons and recording UI with save/cancel/default controls and localized errors; verify keyboard focus, recording isolation and reload persistence in browser. +- [x] 4.4 Run focused/full Web tests, production build, i18n, strict OpenSpec and scoped diff checks; record browser evidence and native verification limits. + +## Editable-binding validation evidence (2026-10-11) + +- All 20 catalog rows expose editable bindings; the history row has independent previous/next editors (21 edit buttons). +- `pnpm i18n:catalog`, final `pnpm test` (3101 pass records, exit 0), `pnpm build`, strict OpenSpec validation and `git diff --check` passed. The production compatibility scan checked 4524 regex literals with no lookbehind. +- `node scripts/test-shortcut-editing.mjs` passed recording isolation, cancel, conflicts, reserved keys, immediate replacement, reload persistence, reactive hints, xterm forwarding, cross-window storage updates, failed-write preservation, send/newline/history/find, dismissal and individual reset checks. +- `node scripts/test-preview-presentation.mjs` passed existing preview regressions plus actual Markdown save rebinding and live sandboxed PPT shortcut updates without reloading slides. Its fixture now derives dirty state from draft/baseline text so saving is actually exercised. +- Browser screenshots/results: `C:/Users/shao/AppData/Local/Temp/ace-shortcut-editing-Kjdf4t/`; preview regression screenshots: `C:/Users/shao/AppData/Local/Temp/ace-preview-presentation-q65JeG/`. +- Full-suite/build logs: `%TEMP%/ace-shortcut-edit-web-tests-final.log` and `%TEMP%/ace-shortcut-edit-build-final.log`. Impeccable detector returned no findings; desktop light and narrow dark editor screenshots were inspected. +- Existing source-based tests were updated to follow the shared matcher; behavior tests retain the default desktop Ctrl+Enter newline contract, IME/keyCode 229 protections and legacy alternate-layout/numpad matching. A remapped desktop newline no longer falls through into sending a message. +- Browser tests use isolated storage and mocked APIs. No real sessions, channels or credentials were changed. Unrelated dirty work remains preserved; no commit, release or installed Desktop package rebuild was performed. + +## Prior validation evidence (2026-10-07) - Focused Node tests: appShortcuts, channelsSettings, settingsNavigation and browserDefaults passed. - `pnpm i18n:catalog`, final `pnpm test`, final `pnpm build`, strict OpenSpec validation and `git diff --check` passed. Build compatibility scan checked 4518 regex literals with no lookbehind. diff --git a/openspec/changes/animate-session-title-updates/.openspec.yaml b/openspec/changes/animate-session-title-updates/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/animate-session-title-updates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/animate-session-title-updates/design.md b/openspec/changes/animate-session-title-updates/design.md new file mode 100644 index 000000000..8542bfc69 --- /dev/null +++ b/openspec/changes/animate-session-title-updates/design.md @@ -0,0 +1,31 @@ +## 背景 + +两处标题均由服务端 title / summary 派生,分别在 transcript 与 Sidebar 中消费 session_updated。侧栏已有测量溢出与悬停滚动,顶部既支持内联也支持 portal。 + +## 目标与边界 + +让标题更新可见,并保持两处播放进度一致。动画仅属于展示层;持久化标题、可访问名称、编辑初值始终是完整文本。 + +## 决策 + +1. 按会话作用域和 ID 共享短生命周期的动画状态,React hook 只在同一已加载会话的标题发生变化时启动。初次挂载与历史加载直接显示完整标题,订阅释放后清理计时与状态。 +2. 按字素逐步呈现,约每字 28ms,总时长限制为 180–650ms。开始显示第一个字并跟随黑色方块光标,最后一个字完成时立即移除光标,不附加闪烁循环。深色背景提供细浅色边缘,使黑色方块仍然可辨。旧 WebView 缺少字素分割能力时直接显示完整文字,避免截断复合字符。 +3. 共享文字组件使用不可见完整文本稳定测量宽度,视觉层展示当前进度,避免标题栏工作区标签随打字移动。完整文字供辅助技术读取,视觉层与光标隐藏于可访问树。 +4. 打字期间暂停侧栏跑马灯,完成后恢复原有溢出行为。减少动态效果时将逐字反馈缩短至最多 320ms,保留不闪烁的短暂光标;页面处于后台时直接完成。已有编辑流程与鼠标键盘事件保持原样。 + +### 2026-10-12 实机修正 + +运行中的 Desktop 服务已经返回含打字组件的前端,而 Windows `SPI_GETCLIENTAREAANIMATION` 返回 false。上一版将减少动态效果直接映射为完全跳过打字,违背了用户需要感知改名的目标。修正为更短的非位移动效,不改 Windows 设置;验证必须包含 reduced-motion 模式下的可见中间帧,而不只是普通模式。 + +### 2026-10-12 标题同步修正 + +真实 transcript hook 复现:正文事件 seq=20 先到时,后到的首次标题通知 seq=19 被全局 lastSeq 丢弃,侧栏没有这道过滤。对 title 与 summary 分别记录最后接受的事件序号;正文水位继续维持原去重规则,旧标题不能覆盖新标题,旧蜂群模式不随放行的标题更新回退。 + +同时为历史请求捕获发出时的状态,按字段修订号保护请求期间到达的标题与摘要(包括用户清空)。初始历史、补拉响应及游标失效重载均采用该规则;请求发出前的元数据仍可被更新快照替换,不把标题永久锁死。 + +## 风险与处理 + +- 不同数据源到达时间不同:共享同一会话的目标文本与进度,重复更新不会重置计时。 +- 历史加载被误判为改名:顶部在 transcript 加载完成之前仅建立基线;切换身份重置观察。 +- 快速重命名与组件卸载:新目标覆盖旧动画;最后一个订阅移除时取消调度。 +- 源码验证与安装版不同步:执行 Web 测试、构建及浏览器验证,单独说明未重建的 Desktop。 diff --git a/openspec/changes/animate-session-title-updates/proposal.md b/openspec/changes/animate-session-title-updates/proposal.md new file mode 100644 index 000000000..d9f02c95a --- /dev/null +++ b/openspec/changes/animate-session-title-updates/proposal.md @@ -0,0 +1,23 @@ +## 为什么 + +会话首句触发自动命名时,顶部和侧边栏直接替换文字,用户容易错过标题变化。增加快速打字与短暂光标,让这次更新清楚可见。 + +## 变更内容 + +- 同一会话的显示标题更新时,顶部和侧边栏同步逐字呈现新标题,结束立即移除光标。 +- 初次显示、切换会话、重复刷新相同标题不重播;连续改名直接呈现最新一轮。 +- 保留侧边栏长标题悬停滚动、标题编辑与既有布局;遵循减少动态效果偏好。 + +## 能力 + +### 新增能力 + +- `session-title-transition`:会话显示标题变更时的同步打字反馈。 + +### 修改能力 + +无。 + +## 影响 + +影响 Web 与 Desktop 共用的标题栏、侧边栏及展示辅助模块。标题生成、存储与通信协议保持原有语义,不引入依赖。 diff --git a/openspec/changes/animate-session-title-updates/specs/session-title-transition/spec.md b/openspec/changes/animate-session-title-updates/specs/session-title-transition/spec.md new file mode 100644 index 000000000..05cc9f53e --- /dev/null +++ b/openspec/changes/animate-session-title-updates/specs/session-title-transition/spec.md @@ -0,0 +1,41 @@ +## Purpose + +提供会话标题更新时的明确视觉反馈,使用户能够在顶部标题栏与侧边栏同时察觉自动命名和后续改名,并保证加载、导航、编辑与无障碍阅读时标题内容稳定完整。 + +## ADDED Requirements + +### Requirement: 标题更新同步快速打字 + +系统 SHALL 在已显示会话的标题变更时,在顶部与侧边栏按相同进度快速逐字呈现新标题,并在当前文字末尾显示明显的黑色方块光标。完成后 MUST 立即移除光标。 + +#### Scenario: 标题通知与正文或历史响应交错 +- **WHEN** 自动命名通知在序号更高的正文事件之后到达,或新标题已到达而旧历史请求尚未结束 +- **THEN** 顶部仍显示最新命名结果,无需切换会话;旧的改名通知不能覆盖更新的改名结果 + +#### Scenario: 首句自动命名 +- **WHEN** 首句发送后的自动标题替换当前显示标题 +- **THEN** 顶部与侧栏同步呈现新标题,整个打字过程不超过 650ms,完成后无光标残留 + +#### Scenario: 连续更新 +- **WHEN** 打字尚未结束时收到不同的新标题 +- **THEN** 立即转为最新标题的打字过程,旧标题不排队播放 + +### Requirement: 加载和导航不重复播放 + +系统 SHALL 在初次加载或切换会话时直接展示完整标题;相同标题的重复通知 MUST NOT 重启动画。 + +#### Scenario: 加载历史及重复刷新 +- **WHEN** 打开历史会话、切换会话或列表刷新仍返回相同标题 +- **THEN** 完整标题正常显示,不因挂载或刷新触发打字 + +### Requirement: 保持布局和访问能力 + +系统 SHALL 保持标题行尺寸、完整可访问名称、重命名输入和长标题溢出行为;打字期间暂停侧栏悬停滚动,完成后恢复。减少动态效果偏好下 SHALL 保留不超过 320ms 的逐字反馈,不引入闪烁或布局移动。 + +#### Scenario: 长标题与重命名 +- **WHEN** 长标题正在打字或用户进入标题编辑 +- **THEN** 相邻标签及按钮不因逐字显示移动,编辑初值始终为完整标题,打字结束后长标题仍可悬停滚动 + +#### Scenario: 减少动态效果 +- **WHEN** 系统启用了减少动态效果 +- **THEN** 顶部和侧栏仍呈现可见的同步打字中间帧,320ms 内完成并移除光标 diff --git a/openspec/changes/animate-session-title-updates/tasks.md b/openspec/changes/animate-session-title-updates/tasks.md new file mode 100644 index 000000000..f235259df --- /dev/null +++ b/openspec/changes/animate-session-title-updates/tasks.md @@ -0,0 +1,45 @@ +## 1. 展示实现 + +- [x] 1.1 实现共享打字进度、字素分割、打断与订阅清理,以定向测试验证重复更新、连续改名和计时释放。 +- [x] 1.2 将顶部与侧栏接入同一展示组件,保留编辑、布局、悬停滚动及减少动态效果路径,以浏览器检查验证。 + +## 2. 验证 + +- [x] 2.1 完成浏览器回归:初次加载、同步打字、完成光标消失、切换会话、连续更新、长标题、内联与 portal、窄屏及减少动态效果。 +- [x] 2.2 执行 pnpm test、pnpm build、OpenSpec 严格验证及差异审查,记录结果与安装版验收边界。 + +## 3. 实机减少动态效果修复 + +- [x] 3.1 调整减少动态效果分支,保留最多 320ms 的打字反馈;以普通与 reduced-motion 中间帧和完成断言验证。 +- [x] 3.2 重跑 Web 测试与构建,核验运行中 Desktop 服务返回修复后的资源,并记录浏览器与实机证据边界。 + +## 4. 顶部标题同步与方块光标 + +- [x] 4.1 复现并修复标题元数据被正文事件水位误丢弃或旧历史响应覆盖的问题;用真实 transcript hook 验证无需切换会话即可更新,旧改名仍不能覆盖新改名。 +- [x] 4.2 光标改为明显的黑色方块,完成立即移除;浏览器验证两处光标的颜色、尺寸和生命周期,保留已有减少动态效果反馈。 +- [x] 4.3 重跑 Web 测试、构建、浏览器回归与 OpenSpec 严格验证,记录运行资源是否已更新。 + +## 2026-10-12 顶部同步与光标验证 + +- 修改前真实 transcript hook 回归失败:正文 seq=20 后收到标题 seq=19,顶部仍为原标题;修复后通过,旧标题 seq=18 不能回退。 +- 已覆盖旧补拉响应晚于新标题、清空标题、独立摘要序号、正文去重与旧蜂群模式隔离;`pnpm test` 全量通过。 +- 浏览器 14 组场景通过,新增真实 hook + 独立侧栏事件接收的竞态检查,以及光标黑色与方形尺寸检查。浅色、深色及减少动态效果下均正常,完成后无光标残留。证据:本机临时目录 `ace-title-animation-0TGqwi`。 +- `pnpm build`、正则兼容性、差异检查与 OpenSpec 严格验证通过。保留本轮之前的减少动态效果及轨迹改动。 +- 最后核验时 Desktop 与 daemon 进程均已退出,旧运行端口不可连接,因此未宣称当前客户端页面已加载本轮代码。已生成最新 `web/dist`,客户端重新打开或刷新后加载;未重启用户进程、未改动用户真实会话标题。 + +## 2026-10-12 修复验证 + +- Windows `SPI_GETCLIENTAREAANIMATION=false`;独立原生 WebView2 验证窗口实际返回 `prefers-reduced-motion=true`,未覆盖系统偏好。两处捕获到相同且可见的逐字中间帧,结束无光标残留。 +- 浏览器 12 组场景通过,增加 reduced-motion 模式 100ms 中间帧及 320ms 完成检查;截图在本机临时目录 `ace-title-animation-GXrP0H`。 +- `pnpm test`、`pnpm build`(含正则兼容检查)、OpenSpec 严格验证与差异检查通过。 +- 当前 Desktop daemon 使用工作区 `web/dist`;HTTP 返回内容与新构建的 SHA-256 一致,包含 320ms 缩短分支,响应为 `Cache-Control: no-cache`。用户当前已打开的页面需要 F5 刷新才能加载新脚本。 +- 本轮未修改 Windows 设置、未重启用户当前 Desktop、未写入用户会话标题。原生证据来自独立验证窗口,运行实例证据来自其实际 HTTP 静态资源。 + +## 首次验证记录 + +- `pnpm test`:通过,包含共享进度、重复更新、打断、字素、时长上限、减少动态效果及清理的新增测试。 +- `pnpm build`:通过,正则兼容性检查通过。 +- `node scripts/test-session-title-animation.mjs`:Edge / Chromium 真实组件验证 12 组场景通过,包括内联与 portal、390px 窄屏、实时减少动态效果和完整重命名初值。截图位于本机临时目录 `ace-title-animation-UL9l0e`。 +- `openspec validate animate-session-title-updates --strict` 与 `git diff --check`:通过。 +- Impeccable 检测只报告全局样式中的 12 个既有问题,新增标题样式未命中。 +- 已核对并保留工作区既有改动;未提交或推送。Desktop 未重建,以上结果为源码、Web 构建和浏览器证据,不代表已安装客户端已更新。 diff --git a/openspec/changes/configure-skill-scan-paths/.openspec.yaml b/openspec/changes/configure-skill-scan-paths/.openspec.yaml new file mode 100644 index 000000000..53f62d706 --- /dev/null +++ b/openspec/changes/configure-skill-scan-paths/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/configure-skill-scan-paths/design.md b/openspec/changes/configure-skill-scan-paths/design.md new file mode 100644 index 000000000..3236de760 --- /dev/null +++ b/openspec/changes/configure-skill-scan-paths/design.md @@ -0,0 +1,29 @@ +## Context + +`skill_init.cpp` is shared by TUI, daemon, tools and session initialization. It splits project/global roots. Settings has a dedicated Configuration section and search locating behavior. Skill policy refresh already publishes snapshots via session control callbacks. + +## Goals / Non-Goals + +Goals: one editable ordered list, consistent runtime/UI resolution, backward-compatible absence, persistence-before-refresh. +Non-goals: changing seed installation locations, importing skill content, packaging or release. + +## Decisions + +- Use `optional> scan_paths` instead of repurposing `external_dirs`: preserves absent vs empty and lets old configuration retain exact discovery order. +- Custom list entries resolve in order. Relative entries expand along workspace ancestors; absolute/`~` paths are global. `${ACECODE_DATA_DIR}` denotes the active data directory; `${XDG_CONFIG_HOME}` and `${XDG_CACHE_HOME}` fall back to `~/.config` and `~/.cache`. Other `${ENV}` require a nonempty value. This keeps one human-editable list instead of exposing separate arrays and switches. +- Default editor values include project-relative roots and global templates, honoring legacy `reuse_opencode` and `external_dirs`. Unchanged drafts are not saved. Explicit custom save uses literal list priority, including interleaved global/project paths. Restore sends null. +- Keep existing project/global helper projections for grouping, add the shared ordered resolver for initialization and UI duplicate selection. Expert roots remain prepended. +- GET/PUT `/api/config/skill-paths` exposes effective editable `paths`, `default_paths`, and `customized`. PUT requires `paths` (string array or null), validates before atomically mutating only `skills.scan_paths`, then refreshes daemon/session policies under the existing config lock. +- Configuration uses a multiline editor with save/reset and help. Skills links internally to Configuration, focuses the named textarea after mount/loading, and reuses existing focus styling. Register all new visible copy in i18n and add a search entry. +- Follow-up deletion fix: the settings catalog is based solely on discoverable skills. Remove the Web API's synthetic rows derived only from `skills.disabled`. Keep disable preferences without pruning during GET: a missing skill may exist in another workspace, on a temporarily unavailable directory, or be restored later. Existing disabled skills retain their full metadata; a restored same-name skill remains disabled. +- Follow-up pagination: preserve the global/workspace grouping and complete API arrays, but slice each filtered list before creating card elements. One section-level page-size preference uses the existing `usePreference` helper with 20/50/100 and a default of 50. Each list owns its page; query/page-size changes reset to 1, refreshed shorter lists clamp the page, toggles preserve it. Top and bottom controls make long pages navigable; page changes scroll/focus the list's top controls. Counts still use the full lists. This addresses render volume without changing disk scan or API pagination contracts. + +## Risks / Trade-offs + +- Reordering a custom list can override a project skill with a global one -> explain first-match priority and test UI/runtime agreement. +- Missing directories/environment variables -> skip without creating arbitrary paths; preserve entries so removable/offline paths can later work. +- Native default install/open paths may be excluded by a custom list -> keep seed location unchanged; directory opening should follow configured roots where customized. + +## Migration Plan + +No eager user-config rewrite. Missing/null uses old paths; reset removes the override. Existing `external_dirs` and `reuse_opencode` remain stored and resume on reset. diff --git a/openspec/changes/configure-skill-scan-paths/proposal.md b/openspec/changes/configure-skill-scan-paths/proposal.md new file mode 100644 index 000000000..1ff01241b --- /dev/null +++ b/openspec/changes/configure-skill-scan-paths/proposal.md @@ -0,0 +1,23 @@ +## Why + +Skill 扫描目录目前大多内置,用户无法在设置中统一增删或调整优先级;技能页也缺少跳转入口。 + +## What Changes + +- 增加 `skills.scan_paths` 有序路径列表,未配置时保持旧扫描规则,显式空列表关闭普通目录扫描。 +- 配置页增加多行路径编辑、保存及恢复默认;技能说明旁增加可定位到该配置的链接。 +- 保存后更新全局与已有会话 registry,保持专家包目录优先及现有启停策略。 +- 技能列表只展示实际扫描到的技能,删除文件或移出扫描范围后不再因禁用记录补回幽灵条目;保留禁用偏好。 +- 技能设置增加分页,全局与各工作区分别分页,共用每页 20/50/100 条选择,默认 50,仅渲染当前页卡片。 + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `agent-skill-directory-compat`: 允许覆盖默认扫描路径,并通过设置配置及跳转。 + +## Impact + +涉及配置序列化、共享技能目录解析、Web 设置 API、会话 registry 刷新、设置页及搜索索引;同步 API 文档和回归测试。默认安装位置不变,无发布副作用。 diff --git a/openspec/changes/configure-skill-scan-paths/specs/agent-skill-directory-compat/spec.md b/openspec/changes/configure-skill-scan-paths/specs/agent-skill-directory-compat/spec.md new file mode 100644 index 000000000..a625628c2 --- /dev/null +++ b/openspec/changes/configure-skill-scan-paths/specs/agent-skill-directory-compat/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Configure the complete ordered skill scan path list +The system SHALL accept `skills.scan_paths` as an optional ordered string array replacing ordinary default and legacy external scan roots. Missing or null SHALL preserve legacy discovery and priority; an empty array SHALL scan no ordinary roots. Relative paths SHALL resolve along the current workspace ancestor chain excluding HOME; absolute and home-relative paths SHALL be global. Unresolved environment variables SHALL be skipped. Expert package roots SHALL retain first priority. + +#### Scenario: Custom paths replace defaults and respect order +- **WHEN** a user saves a list of global and project-relative paths +- **THEN** only those ordinary paths are scanned in list order, with nearer ancestors first for each relative path +- **AND** UI listing, commands and session skill discovery agree on duplicate resolution + +#### Scenario: Empty list and restore defaults +- **WHEN** the user saves an empty array +- **THEN** ordinary skill discovery is disabled without deleting any files +- **WHEN** the user restores defaults +- **THEN** existing compatibility flags and external directories determine discovery again + +### Requirement: Edit scan paths in Settings and navigate from Skills +Settings Configuration SHALL provide a labeled multiline Skill scan paths editor, explicit save, restore defaults, and loading/error/saved feedback. Skills SHALL show a keyboard-accessible link beside its description that opens Configuration and locates the editor. Settings search SHALL find the field in Chinese and English. + +#### Scenario: Navigate and save +- **WHEN** a user activates the scan-path link in Skills +- **THEN** Configuration opens with the scan-path field visible and focused +- **WHEN** the user saves valid paths +- **THEN** the configuration is persisted and existing session skill policies refresh at their control boundary + +#### Scenario: Invalid or failed save +- **WHEN** the request contains a non-array or non-string path, or persistence fails +- **THEN** the server rejects the request without changing active paths +- **AND** the editor retains the user's draft and displays an error + +### Requirement: Settings skill catalog reflects discovered files +The settings skill catalog SHALL include only skills discovered under the effective scan paths. Disable preferences SHALL affect the enabled state of discovered skills without generating synthetic catalog entries or being removed by a list refresh. + +#### Scenario: A disabled skill is deleted and later restored +- **GIVEN** a discovered skill is disabled +- **WHEN** its SKILL.md is removed and the catalog is refreshed +- **THEN** the skill disappears from the catalog +- **AND** other discovered skills remain listed +- **WHEN** the same skill is restored and the catalog is refreshed +- **THEN** it appears again with its disabled preference preserved + +#### Scenario: A disabled skill leaves the scan scope +- **WHEN** its directory is removed from the configured scan paths or is outside the selected workspace +- **THEN** the skill does not appear as a synthetic global entry +- **AND** the disable preference is preserved for other workspaces or later discovery + +### Requirement: Paginate skills in Settings +The Skills settings page SHALL offer page sizes of 20, 50 and 100, default to 50 when no valid preference exists, and remember the selected size. The global list and each expanded workspace list SHALL paginate independently using that shared size and SHALL render only their current page's skill cards. + +#### Scenario: Large catalog and page navigation +- **WHEN** a catalog contains more than 50 skills and the user has no saved page-size preference +- **THEN** each visible list renders at most 50 cards with total count and page navigation +- **WHEN** the user changes a list's page +- **THEN** that list shows the corresponding cards without changing other lists' pages +- **AND** the page's controls remain reachable by keyboard + +#### Scenario: Search and page-size changes +- **WHEN** the user searches skills +- **THEN** the filter covers the complete catalogs before pagination and each list starts on page 1 +- **WHEN** the user selects 20 or 100 entries per page +- **THEN** each list returns to page 1 using the selected size + +#### Scenario: Toggle and refreshed catalog +- **WHEN** a user toggles a skill on a later page +- **THEN** its enabled state updates without resetting the page and enabled counts still describe the complete catalog +- **WHEN** a refresh makes the current page out of range +- **THEN** the list shows its last valid page, or the existing empty state if no entries remain + +## MODIFIED Requirements + +### Requirement: Discover skills from .agent skill directories +Unless an explicit scan-path list overrides default discovery, the system SHALL discover and register skills from the default `.agent` skill directory in addition to the default `.acecode` skill directory, using the same `SKILL.md`-based directory structure and metadata parsing rules. + +#### Scenario: Skills are found in the .agent directory +- **WHEN** the user has a valid skill with `SKILL.md` under the supported default `.agent` skill root and has not configured an explicit scan-path list +- **THEN** the system discovers that skill during skill registry scan +- **THEN** the skill is available to list and load through the existing skill interfaces diff --git a/openspec/changes/configure-skill-scan-paths/tasks.md b/openspec/changes/configure-skill-scan-paths/tasks.md new file mode 100644 index 000000000..a280869df --- /dev/null +++ b/openspec/changes/configure-skill-scan-paths/tasks.md @@ -0,0 +1,26 @@ +## 1. Configuration and discovery + +- [x] 1.1 Add optional ordered scan paths and shared resolution; verify config round-trip, empty/reset, environment, workspace isolation and precedence with C++ tests. +- [x] 1.2 Add authenticated configuration API, atomic save and live policy refresh; verify HTTP validation/persistence and listing/runtime agreement, and document the API. + + Verified: Windows MSVC Release `acecode_unit_tests` rebuilt successfully (`--parallel 1`). Focused filter `SkillInitOpencodeTest.*:SkillsHandlerTest.*:ConfigFirstInitTest.*:WebServerHttp.Skill*:CommandsHandlerTest.*:SkillViewToolTest.*:SkillsToolTest.*` passed 80/80 tests. HTTP coverage includes updating an already-created session, daemon body lookup, list refresh, persistence, empty list and reset. Handler tests cover invalid input and write failure preserving configuration. + +## 2. Settings + +- [x] 2.1 Add the path editor, defaults action, Skills link, search and translations; verify Web unit tests and keyboard navigation/save in browser. + + Verified: `pnpm test`, `pnpm build`, and `node scripts/test-skill-scan-paths.mjs` with installed Edge. Browser covers keyboard link/focus, default values, failed-save draft retention, retry, navigation persistence, empty/reset, light/dark/narrow layouts and English. Impeccable detector returned no findings. This is browser fixture verification, not installed Desktop verification. + +- [x] 2.2 Remove synthetic disabled entries from the settings catalog; verify delete/refresh/restore and changed scan scope through native handler and HTTP regression tests, update API documentation, and rerun strict validation. + + Follow-up verified on 2026-10-10: rebuilt Windows MSVC Release tests with `--parallel 1`; the same focused native filter now passes 82/82. New handler and HTTP scenarios cover actual SKILL.md deletion in project/global roots, remaining installed entries, restore with disable preferences intact, and switching to empty scan paths. `node src/lib/skillsSettings.test.js`, strict OpenSpec validation and `git diff --check` pass. This backend-only fix did not require new Web assets; installed Desktop/daemon was not restarted or repackaged. + +## 3. Integration + +- [x] 3.1 Run focused native tests, pnpm test/build, strict OpenSpec validation and git diff --check; record results and remaining limits. + + Verified on 2026-10-10: all checks above plus `openspec validate configure-skill-scan-paths --strict` and `git diff --check`. Full native suite and installed Desktop packaging/restart were not run; no commit, push or release performed. + +- [x] 3.2 Add 20/50/100 (default 50) per-list pagination, shared persisted size, full-catalog search and page clamping; verify pure pagination tests and browser checks for card counts, workspace independence, keyboard navigation, refresh and toggles; run pnpm test/build, translations and strict validation. + + Pagination verified on 2026-10-10: `pnpm test`, `pnpm build`, `node scripts/test-skill-pagination.mjs`, strict OpenSpec and diff checks pass. Installed Edge fixture with 2,300 global skills and 123 workspace skills confirms at most the selected page size is rendered per list, independent pages, search across all entries, toggle count/page preservation, refresh clamping, no repeated scan requests on page/search changes, persisted 20/50/100 selection, invalid preference fallback, keyboard focus, light/dark/narrow layouts and English. Impeccable detector reports no findings. Native API contracts are unchanged; installed Desktop was not repackaged. diff --git a/openspec/changes/fix-thread-workspace-creation/.openspec.yaml b/openspec/changes/fix-thread-workspace-creation/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/fix-thread-workspace-creation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/fix-thread-workspace-creation/design.md b/openspec/changes/fix-thread-workspace-creation/design.md new file mode 100644 index 000000000..6875ed4e8 --- /dev/null +++ b/openspec/changes/fix-thread-workspace-creation/design.md @@ -0,0 +1,28 @@ +## Context + +见 proposal.md。当前 ThreadScope 只保留 cwd;ThreadService::create 只设置 cwd/model,SessionOptions 的 no_workspace 默认 false。界面按 no_workspace 与可见 WorkspaceRegistry 分别列举会话。续聊的 resolve_thread 只查调用方项目,无法覆盖目标切换后的创建结果。 + +## Goals / Non-Goals + +**Goals:** 在工具适配层保留结构化归属,在服务层统一解析目标,复用现有注册、缓存目录分配和全局会话定位机制。 + +**Non-Goals:** 不迁移历史数据、不修改提问工具行为、不扩展删除/归档等其他写工具作用域,不变更 UI 布局或发布运行版本。 + +## Decisions + +1. 引入 ThreadCreateTarget 值对象;ThreadScope 补充 no_workspace。工具通过调用方 SessionManager 的元数据提取归属,不根据路径名猜测。创建支持 workspaceHash/cwd/noWorkspace,互斥冲突在执行前报错。 +2. 默认继承 no_workspace;无工作区交给 SessionRegistry 分配独立缓存目录,避免多个独立会话共用文件写入位置。普通目录复用 WorkspaceRegistry::register_new,随后读取持久化 marker 确认可见性,避免只报告内存注册成功。显式 hash 保留注册表中的 cwd 身份;显式目录使用 canonical 路径。 +3. 返回实际 entry 的 cwd、存储 hash、noWorkspace;失败清理由新 entry 的存储位置决定,不能继续使用调用方 scope.cwd。 +4. 续聊复用 resolve_discovered_thread,支持 workspaceHash;恢复使用目标元数据,并验证 runtime 中同 ID 的存储项目一致,阻止跨项目误投递。其他写工具保持既有作用域。 +5. 用临时 HOME 的真实 SessionRegistry 与记录输入的 LocalSessionClient 测试替身验证持久化和路由,不发真实模型请求;追加 Web 列表验证(若已有测试入口适用),复用 Windows 增量构建。 + +## Risks / Trade-offs + +- 显式创建到隐藏工作区会使该工作区重新可见 → 保留名称、图标和附加目录,符合用户创建后能找到的预期。 +- 新增可选参数不能破坏旧调用 → 默认行为只修复归属,title/model/prompt 保持原有约定。 +- 当前运行中的 Desktop 不会热加载 C++ 修改 → 验证源码和测试,不将其表述为已替换运行版本。 +- 工作区注册后首条投递可能失败 → 清理新会话文件,保留可见工作区注册及原有会话数据。 + +## Migration Plan + +代码合入后随下次构建启动生效。已有不可见会话不做自动修复;本次不直接改写正在运行会话的磁盘元数据。回滚可移除新增可选工具参数与目标解析实现,既有会话格式不变。 diff --git a/openspec/changes/fix-thread-workspace-creation/proposal.md b/openspec/changes/fix-thread-workspace-creation/proposal.md new file mode 100644 index 000000000..50f5aca8c --- /dev/null +++ b/openspec/changes/fix-thread-workspace-creation/proposal.md @@ -0,0 +1,24 @@ +## Why + +无工作区会话通过 `create_thread` 创建新会话时,只复制临时 cwd,遗漏 no_workspace,导致新会话同时被无工作区列表和可见工作区列表排除。创建工具也不能指定目标工作区或目录,无法完成用户明确要求的跨工作区创建。 + +## What Changes + +- 创建工具支持可选 `workspaceHash`、`cwd`、`noWorkspace`,默认继承调用会话归属。 +- 无工作区新会话使用独立缓存目录;普通目标目录注册为可见工作区,返回真实位置元数据。 +- 续聊工具支持全局定位和可选 `workspaceHash`,使新会话创建后仍可从原会话继续发送任务。 +- 补充无工作区、跨工作区、目标校验、失败清理及续聊回归测试,更新工具协议文档。 + +## Capabilities + +### New Capabilities + +- `thread-workspace-creation`: 定义 AI 创建会话的目标选择、可见性、返回身份与跨工作区续聊约定。已有 thread 工具规范仍在未归档变更中,本次不覆盖其历史记录。 + +### Modified Capabilities + +无。复用既有 shared-daemon-workspaces 的工作区注册与会话归属机制。 + +## Impact + +涉及 `ThreadService`、thread 工具 schema、会话工具单元测试和 `docs/daemon-api.md`。不变更 HTTP 路由,不改其他元数据修改工具的作用域,不自动修改已有用户会话,不发布或替换运行中的 Desktop。 diff --git a/openspec/changes/fix-thread-workspace-creation/specs/thread-workspace-creation/spec.md b/openspec/changes/fix-thread-workspace-creation/specs/thread-workspace-creation/spec.md new file mode 100644 index 000000000..a0d2f765e --- /dev/null +++ b/openspec/changes/fix-thread-workspace-creation/specs/thread-workspace-creation/spec.md @@ -0,0 +1,49 @@ +## Purpose + +让用户通过 AI 在当前归属、指定工作区或指定文件夹创建可在界面找到的独立会话,并能从原会话继续发送任务,避免将无工作区的临时执行目录误识别为普通工作区。 + +## ADDED Requirements + +### Requirement: 显式选择创建目标 + +`create_thread` SHALL 接受可选 `workspaceHash`、`cwd`、`noWorkspace`。省略目标时继承调用会话归属;`workspaceHash` 指向已注册工作区,`cwd` 为已存在的绝对目录,`noWorkspace=true` 创建无工作区会话。系统 MUST 拒绝冲突目标、错误类型、未知工作区、相对路径及不存在或非目录路径,不能静默回退到当前目录。 + +#### Scenario: 指定其他工作区 +- **WHEN** 从无工作区调用创建工具并提供已注册工作区的 hash +- **THEN** 新会话使用目标工作区 cwd、归属及存储位置 + +#### Scenario: 指定文件夹 +- **WHEN** 提供已存在的绝对目录 cwd +- **THEN** 系统规范化该目录并确保目标工作区在界面可见,保留已有显示名称等配置 + +#### Scenario: 目标参数非法 +- **WHEN** 同时提供 cwd 与 workspaceHash、noWorkspace=true 与目录目标,或目标类型/路径非法 +- **THEN** 返回明确错误,不创建会话 + +### Requirement: 正确继承无工作区归属并返回位置 + +系统 SHALL 为无工作区新会话分配独立缓存目录,持久化 no_workspace,并返回 `threadId`、`cwd`、`workspaceHash` 和 `noWorkspace`;workspaceHash 使用与全局会话查询一致的存储项目 hash。普通会话目标 SHALL 注册为可见工作区,创建结果可通过对应界面列表发现。首条输入投递失败时 MUST 清理实际目标中的新会话文件。 + +#### Scenario: 无工作区调用默认创建 +- **WHEN** 无工作区会话省略目标调用 create_thread +- **THEN** 新会话仍属于无工作区,可通过无工作区列表发现,且不会把调用方临时目录注册成工作区 + +#### Scenario: 普通工作区调用默认创建 +- **WHEN** 普通会话省略目标调用 create_thread +- **THEN** 新会话使用调用方目录,并保留 title/model 与首条 prompt 行为 + +#### Scenario: 投递失败 +- **WHEN** 新会话创建后首条输入无法排队 +- **THEN** 销毁新会话并清理目标存储,不删除调用方历史 + +### Requirement: 跨工作区继续发送任务 + +`send_message_to_thread` SHALL 全局定位 threadId,并接受可选 workspaceHash 消除歧义。恢复非活跃目标时 MUST 使用目标自身 cwd 和 noWorkspace 属性。不能将请求发送给其他工作区中同 ID 的活跃会话;自发消息仍被拒绝。 + +#### Scenario: 新建后继续发送 +- **WHEN** 原会话向刚创建的其他工作区或独立无工作区会话发送 prompt +- **THEN** 输入进入目标会话队列,目标归属保持不变 + +#### Scenario: 同 ID 冲突 +- **WHEN** 目标 ID 对应多个存储项目且未指定 workspaceHash,或运行时已加载其他项目的同 ID +- **THEN** 返回明确错误,不把消息投递给错误会话 diff --git a/openspec/changes/fix-thread-workspace-creation/tasks.md b/openspec/changes/fix-thread-workspace-creation/tasks.md new file mode 100644 index 000000000..a50c93a2a --- /dev/null +++ b/openspec/changes/fix-thread-workspace-creation/tasks.md @@ -0,0 +1,23 @@ +## 1. 实现与回归 + +- [x] 1.1 补齐创建目标解析、无工作区归属、可见注册及返回身份,并通过 ThreadTools 创建与失败清理回归。 +- [x] 1.2 接通跨工作区续聊与目标恢复,验证无工作区续聊、磁盘恢复和同 ID 防误投递测试。 +- [x] 1.3 更新工具 schema、描述与协议文档,验证旧参数兼容及非法目标类型拒绝。 + +## 2. 集成验收 + +- [x] 2.1 复用 Windows 构建目录完成单元测试编译,运行 thread/workspace/no-workspace 相关测试,记录真实结果。 +- [x] 2.2 通过 OpenSpec 严格校验与 git diff --check,审查文件范围并区分源码验证和运行版本。 + +- [x] 2.3 发布整合时将纯标题/事件格式化辅助函数拆到独立实现文件,保持 thread_service.cpp 不超过既有行数上限;通过分层检查和线程相关原生回归。 + +## 验收记录 + +- 2026-10-11:复用 `build/refactor-phase1-windows`,通过 `scripts/dev_windows_env.bat` 加载 MSVC 环境后增量构建 `acecode_unit_tests` 成功。 +- `ThreadCreation.*:ThreadTools.*:HeadlessSessionScope.*:WorkspaceRegistry*.*` 及五项 WebServerHttp 会话/工作区回归共 69/69 通过。其中新增 10 项创建/续聊测试及 1 项 AI 创建后侧栏 HTTP 列表测试。 +- 新增 HTTP 回归验证 `/api/sessions?scope=no-workspace` 在新会话活跃及退出后都返回正确标题和无工作区归属。 +- 测试报告:`C:/Users/shao/AppData/Local/Temp/acecode-thread-workspace-final-tests.xml`;构建目录是验证副本,未替换 `build/Release` 中正在运行的 Desktop/daemon。 +- 不修改现有用户会话;原不可见历史会话没有迁移。未提交、未发布。 +- `openspec validate fix-thread-workspace-creation --strict` 与本次文件的 `git diff --check` 均通过;144 个已记录基线的无关修改文件内容未变。 + +- 2026-10-12 发布整合验收:`thread_service.cpp` 1188 行,未超过既有 1211 行上限;分层、体积、所有权、路径和映射严格检查全部通过。Windows MinSizeRel 三目标构建成功,线程/技能/配置/通道/升级等 1382 项原生回归中 1380 项通过、2 项外部环境用例按设计跳过,无失败。 diff --git a/openspec/changes/fix-trajectory-turn-order/.openspec.yaml b/openspec/changes/fix-trajectory-turn-order/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/fix-trajectory-turn-order/design.md b/openspec/changes/fix-trajectory-turn-order/design.md new file mode 100644 index 000000000..90d6e47e1 --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/design.md @@ -0,0 +1,24 @@ +## Context + +现场只读接口确认事件顺序为 legacy_context、message(user)、turn_start、model_step_start、model_request、model_response、turn_end。当前投影遇上下文创建匿名回合,遇用户消息创建回合,遇 turn_start 强制再建回合。四次输入被投影为 3、5、7、9。 + +布局原先通过下一个助手或 partial 推断输入归属;尚无助手结果时请求与用户行可落入不同回合。实时 partial 的 turn 则来自聊天计数,与轨迹投影不是同一身份来源。 + +## Goals / Non-Goals + +目标:修复投影身份和实时布局,并兼容用户消息早于或晚于 turn_start 的事件顺序及旧会话。 + +不改变后端事件协议、磁盘记录、轮询频率、轨迹视觉样式;不把加载局部历史时缺少的绝对回合总数伪装为已知数据。 + +## Decisions + +1. 上下文暂存至真正回合建立,再附入该回合;turn_start 复用 turn_id 或 user_message_id 对应回合,结束事件不创建新回合。保留仅有上下文的历史展示。 +2. 投影产生输入序号到回合编号的映射,布局优先使用该映射,兼容未提供映射的 DeepSeek 输入。系统提示使用所属请求的回合。相比仅修计数,这也能消除尚无助手结果时的临时错位。 +3. 实时状态携带 activeTurnId,投影提供身份到编号的映射。匹配活动身份优先于聊天估算编号;新回合尚未被轮询到时放在当前历史之后,不回填到旧回合。已持久化相同步骤结果后隐藏 partial。 +4. 在每个事件前缀上重建投影,验证编号、输入归属与分段合并;浏览器回放验证真实组件在轮询与流式更新交替时的顺序。 + +## Risks / Trade-offs + +- [风险] 旧会话缺少 turn_start → 用户消息仍可建立回合,legacy_index 保持原有排序。 +- [风险] 历史快照与流式消息不同步 → 以稳定身份匹配;明确已完成步骤后抑制残留 partial。 +- [风险] 工作区存在大量其他改动 → 本次仅改相关轨迹文件、实时状态字段和测试,不提交其他改动。 diff --git a/openspec/changes/fix-trajectory-turn-order/proposal.md b/openspec/changes/fix-trajectory-turn-order/proposal.md new file mode 100644 index 000000000..2138906f6 --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/proposal.md @@ -0,0 +1,24 @@ +## Why + +真实测试会话只有四次用户输入,轨迹却显示 Turn 3、5、7、9;实时回复又使用聊天计数,导致轮询前后编号和消息位置跳动。需要按同一次输入的稳定身份统一轨迹归属。 + +## What Changes + +- 初始化上下文不单独消耗回合编号,用户消息及其开始、结束事件归入同一回合。 +- 给输入和上下文保留明确回合归属,尚未收到助手结果时也不依赖后续助手猜测位置。 +- 实时回复优先按活动回合身份匹配持久轨迹,完成后不重复展示旧 partial。 +- 增加真实事件顺序、轮询分段、连续回合、历史兼容记录的回归验证。 + +## Capabilities + +### New Capabilities + +- `session-trajectory-tolerance`: 沿用未归档 change 中的能力路径,补充回合编号与实时归属容错约束。 + +### Modified Capabilities + +无。 + +## Impact + +影响 Web 轨迹投影、布局输入及聊天实时轨迹状态。保持轨迹接口、持久化文件、页面样式与英文文案不变。 diff --git a/openspec/changes/fix-trajectory-turn-order/specs/session-trajectory-tolerance/spec.md b/openspec/changes/fix-trajectory-turn-order/specs/session-trajectory-tolerance/spec.md new file mode 100644 index 000000000..20c09356c --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/specs/session-trajectory-tolerance/spec.md @@ -0,0 +1,33 @@ +## Purpose + +保证会话轨迹在历史记录、回合生命周期事件与实时消息分批到达时具有一致的回合编号和消息归属,使用户能够可靠查看每次输入、模型步骤及回复的执行顺序。 + +## ADDED Requirements + +### Requirement: 同一次输入只占一个轨迹回合 + +轨迹 SHALL 将同一次用户输入及其回合开始、结束事件归入同一回合,初始化上下文 SHALL NOT 单独增加回合编号。 + +#### Scenario: 初始化上下文先于用户消息和回合开始 +- **WHEN** 新会话依次出现初始化上下文、用户消息、同一输入的回合开始事件和助手回复 +- **THEN** 它们显示在 Turn 1,后续输入依次显示为 Turn 2、Turn 3 + +#### Scenario: 回合开始早于用户消息 +- **WHEN** 开始事件先到,且 user_message_id 与后到用户消息的 id 对应 +- **THEN** 两条事件归入同一回合,不产生空白回合或跳号 + +### Requirement: 分批更新保持输入与实时回复的回合归属 + +轨迹 SHALL 在尚无助手结果时保留用户及上下文的回合归属,实时回复 SHALL 优先按活动回合身份关联,且 SHALL NOT 因聊天计数不同而插入已完成旧回合。 + +#### Scenario: 新回合先流式输出后收到轨迹轮询 +- **WHEN** 已有多个完成回合,新回合实时回复先于持久轨迹更新到达 +- **THEN** 新回复显示在历史之后,轨迹更新到达后保持同一回合位置 + +#### Scenario: 持久结果到达但实时状态尚未清空 +- **WHEN** 同一回合和模型步骤的持久结果已收到,但旧流式片段仍存在 +- **THEN** 只显示持久结果,不重复展示旧流式片段 + +#### Scenario: 旧回合没有助手结果 +- **WHEN** 一个回合在生成助手结果前结束,随后出现新用户回合 +- **THEN** 两次用户输入分别留在各自回合,不合并到后一个助手回合 diff --git a/openspec/changes/fix-trajectory-turn-order/tasks.md b/openspec/changes/fix-trajectory-turn-order/tasks.md new file mode 100644 index 000000000..9aafc4df5 --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/tasks.md @@ -0,0 +1,9 @@ +## 1. 修复与回归 + +- [x] 1.1 复用用户和生命周期身份,修复上下文虚增回合,并用真实顺序及逐事件前缀测试验证连续编号 +- [x] 1.2 布局保留输入归属,实时状态按回合身份匹配,并验证轮询延迟、空回复及已完成 partial 去重 + +## 2. 集成验证 + +- [x] 2.1 浏览器回放连续回合和延迟轮询,确认 turn 编号及消息顺序稳定 +- [x] 2.2 通过完整 pnpm test、pnpm build、OpenSpec 严格校验及本次差异检查,记录实际会话只读复验结果 diff --git a/openspec/changes/fix-trajectory-turn-order/verification.md b/openspec/changes/fix-trajectory-turn-order/verification.md new file mode 100644 index 000000000..b4d8cadd0 --- /dev/null +++ b/openspec/changes/fix-trajectory-turn-order/verification.md @@ -0,0 +1,26 @@ +# 验证记录 + +日期:2026-10-11。当前 master 工作区内实施;保留已有其他改动。 + +## 根因与现场只读复验 + +- 原始真实事件顺序为初始化上下文、用户消息、turn_start;前端分别创建了三个回合。 +- 修复前四次输入投影为 `[3, 5, 7, 9]`;修复后,同一真实接口数据投影为 `[1, 2, 3, 4]`。 +- 另一条单次输入的测试会话修复后投影为 `[1]`。 +- 现场接口仅执行 GET,没有修改会话或重启运行中的 Desktop/daemon。 + +## 自动化验证 + +- `node web/src/lib/trajectoryModel.test.js`:通过。覆盖上下文、两种生命周期顺序、不同身份别名、重复开始/结束边界、连续四轮每条事件前缀、无助手结果、身份优先于过时编号、tail 缺少开始事件、引导与工具注入输入、完成后 partial 去重。 +- `node web/src/lib/sessionTranscript.test.js`:通过。覆盖活动回合身份进入 partial,以及历史快照缺少开始事件时恢复身份。 +- `node web/scripts/test-trajectory-turn-order.mjs`:Chromium 无头浏览器通过,无页面异常。通过 `ACE_PLAYWRIGHT_MODULE` 与 `ACE_CHROMIUM_EXECUTABLE` 复用本机已安装工具。 +- 浏览器回放覆盖初始化上下文的首轮、六轮历史后第七轮先流式输出、用户消息/turn_start/model_request/model_response 分段轮询及最终结果去重。聊天状态的计数故意保持过时,轨迹仍按活动身份定位。 +- 浏览器截图:`C:/Users/shao/AppData/Local/Temp/ace-trajectory-turn-order/trajectory-turn-order.png`。 +- `pnpm test`:最终代码通过;日志包含 3110 条 `[pass]`,Node TAP 汇总 3/3,通过且无失败。 +- `pnpm build`:最终代码通过;构建后的 4524 个正则通过旧 WebView lookbehind 兼容检查。 +- `openspec validate fix-trajectory-turn-order --strict`:通过。 +- 本次六个已跟踪修改文件的 `git diff --check`:通过;新增浏览器脚本独立保留。 + +## 验证边界 + +上述为当前源码、真实只读响应投影及浏览器组件回放结果。已安装 Desktop 尚未重打包或替换,不能据此宣称当前运行的安装版已经包含修复。OpenSpec change 目录遵循当前仓库忽略规则,文档保留在本地。 diff --git a/openspec/changes/preserve-macos-update-app-name/.openspec.yaml b/openspec/changes/preserve-macos-update-app-name/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/preserve-macos-update-app-name/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/preserve-macos-update-app-name/design.md b/openspec/changes/preserve-macos-update-app-name/design.md new file mode 100644 index 000000000..edd34840e --- /dev/null +++ b/openspec/changes/preserve-macos-update-app-name/design.md @@ -0,0 +1,27 @@ +## Context + +见 proposal.md。自更新在旧进程仍存活时移动整个应用;桌面重启为后续单独操作。主应用 `CFBundleName` / `CFBundleDisplayName` 已为 ACECode,但更新备份的实际文件名包含 UUID。Apple 文档说明显示名称可能采用实际文件名,TCC 还依赖 responsible code 归属;这里确认了不合适的命名代码,尚未确认用户机器的具体 TCC 归属。 + +参考:[Apple Display Names](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPFileSystem/Articles/DisplayNames.html)、[On File System Permissions](https://developer.apple.com/forums/thread/678819)。前者说明文件名与显示名称关系,不作为特定 TCC 弹窗已复现的证据。 + +## Goals / Non-Goals + +目标是更新各阶段始终保持应用叶子目录名 `ACECode.app`,且完整保留签名与故障恢复。范围仅为内置 macOS 自更新,不扩展为权限管理或安装器重构,不改变重启时机。 + +## Decisions + +- 单次更新创建同卷私有目录 `.acecode-update-`,其中 `staging/ACECode.app`、`previous/ACECode.app` 和 `failed/ACECode.app` 分别承载三个阶段。随机标识只进入外层目录。 +- 在已有 portable bundle 模块中封装目录所有权;根目录独占创建,POSIX 权限为 0700,已有目录或符号链接拒绝接管。析构只删除自己创建的空目录,任何仍含应用的目录必须保留。 +- 原有复制、信任验证、两步移动与回滚顺序保持。成功备份仍由现有返回值和诊断日志公开实际路径。 +- 安装位置校验拒绝 `.acecode-update-*` 下的副本,避免保持名称后把备份当作正式安装再次升级。 +- 不直接修改运行中应用的 plist;这会破坏封装签名。单纯改 `CFBundleDisplayName` 也无法消除实际文件名的 UUID。 + +## Risks / Trade-offs + +- [TCC 名称选择受 macOS 版本和启动方式影响] → 源码和文件操作测试只证明名称与恢复约束;系统弹窗列为 Mac 实机待验,不能声称通过。 +- [旧版本仍按旧逻辑升级] → 新逻辑随新版本运行后生效,旧版本执行的那次更新仍可能留下旧名字;正常退出并重新打开正式安装可结束旧进程。 +- [回滚失败] → 只清理空容器,备份及失败副本保留,错误继续给出可恢复路径。 + +## Migration Plan + +随常规版本发布,不在本任务发布或提交。不重命名或删除历史 `.ACECode-.previous.app`;文档记录新旧两类备份。Mac 验收覆盖更新完成但未重启时触发文件权限、重启后申请及回滚故障。 diff --git a/openspec/changes/preserve-macos-update-app-name/proposal.md b/openspec/changes/preserve-macos-update-app-name/proposal.md new file mode 100644 index 000000000..a52721a61 --- /dev/null +++ b/openspec/changes/preserve-macos-update-app-name/proposal.md @@ -0,0 +1,24 @@ +## Why + +用户反馈 macOS 权限弹窗出现 `.acecode-xxxx-xxxx`,无法辨认请求者。代码中存在高度吻合的来源:自更新把仍可能运行的旧应用重命名为 `.ACECode-.previous.app`;系统可能采用实际 bundle 文件名。当前 Windows 环境尚未复现用户的系统弹窗。 + +## What Changes + +- 自更新的暂存、备份和失败隔离应用均保持 `ACECode.app` 名称,随机编号及阶段信息移入外层私有目录。 +- 保持同卷替换、签名验证、失败回滚和备份保留行为;清理仅删除本次创建的空容器。 +- 更新目录中的副本不得被作为正式安装位置再次自更新。 +- 补充路径、回滚文件操作和清理回归验证,以及 Mac 实机待验说明。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `self-upgrade`: macOS 更新期间保持应用名称及备份恢复能力。 + +## Impact + +涉及 `src/adapters/upgrade/macos_bundle.*`、`macos_app_installer.mm`、对应测试与 `docs/macos-release.md`。不改签名内容、bundle identifier、权限范围或发布配置;不自动迁移或删除旧备份。 diff --git a/openspec/changes/preserve-macos-update-app-name/specs/self-upgrade/spec.md b/openspec/changes/preserve-macos-update-app-name/specs/self-upgrade/spec.md new file mode 100644 index 000000000..0a5ba6da4 --- /dev/null +++ b/openspec/changes/preserve-macos-update-app-name/specs/self-upgrade/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: macOS update copies preserve the application name + +macOS 自更新 SHALL 在暂存、备份及失败隔离阶段保持应用文件名 `ACECode.app`,唯一编号 SHALL 仅放在外层目录。系统 SHALL 保留原有签名验证和失败回滚能力,不更改应用签名内容。 + +#### Scenario: Update completes before the old process exits +- **WHEN** 自更新替换完成但旧应用仍在运行 +- **THEN** 旧应用所在的备份 bundle 仍名为 `ACECode.app` +- **AND** 返回的备份路径指向可恢复的旧应用 + +#### Scenario: Replacement fails and rollback succeeds +- **WHEN** 安装失败但可恢复原应用 +- **THEN** 原应用恢复到正式安装路径,当前更新的空容器被清理 + +#### Scenario: Rollback cannot restore the original +- **WHEN** 安装和回滚均失败 +- **THEN** 仍含旧应用或失败应用的容器被保留,不因清理操作丢失副本 + +#### Scenario: Update directory already exists +- **WHEN** 本次更新选定的私有根路径已经存在,包括符号链接 +- **THEN** 更新拒绝接管该目录,且不删除或覆盖其内容 + +#### Scenario: Backup copy attempts self-update +- **WHEN** 运行于更新私有目录中的应用尝试自更新 +- **THEN** 安装位置校验拒绝该副本作为正式安装目标 diff --git a/openspec/changes/preserve-macos-update-app-name/tasks.md b/openspec/changes/preserve-macos-update-app-name/tasks.md new file mode 100644 index 000000000..e219adc78 --- /dev/null +++ b/openspec/changes/preserve-macos-update-app-name/tasks.md @@ -0,0 +1,17 @@ +## 1. 实现 + +- [x] 1.1 封装保留 ACECode.app 名称的更新目录和空目录清理生命周期;以目录独占、名称和备份保留回归测试验证。 +- [x] 1.2 接入 macOS 原生更新器并拒绝对备份自身更新;审查所有复制、移动、验证和回滚分支,并通过路径校验测试。 +- [x] 1.3 更新 Mac 发布说明,解释新旧备份路径及更新后重启行为;核对文档和实现一致。 + +## 2. 验证 + +- [x] 2.1 执行定向原生测试、OpenSpec strict 校验和 git diff --check,记录结果与平台限制。 +- [ ] 2.2 在 Mac 上编译原生更新器并验证签名更新、故障回滚,以及更新前后权限弹窗的实际名称。 + +## 验证记录(2026-10-11) + +- WSL Ubuntu 使用 g++ C++17 编译实际 `macos_bundle.cpp`、`encoding.cpp` 和 `macos_bundle_test.cpp`(仓库现有 GoogleTest),10 项全部通过,0 跳过。包括 0700 权限、已有目录/符号链接拒绝、应用名称、成功备份、恢复后空目录清理、未恢复副本保留,以及备份不可自更新。 +- `openspec validate preserve-macos-update-app-name --strict` 通过。 +- 本次五个源代码/测试/文档文件 `git diff --check` 通过。 +- 当前为 Windows 主机,未编译 Objective-C++ 原生更新器、未使用 Developer ID 签名应用做实际更新,未复现 TCC 弹窗;2.2 保持待验。未发布、未提交。 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/.openspec.yaml b/openspec/changes/restore-chat-focus-on-window-activation/.openspec.yaml new file mode 100644 index 000000000..a36921962 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/design.md b/openspec/changes/restore-chat-focus-on-window-activation/design.md new file mode 100644 index 000000000..21aa6aec3 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/design.md @@ -0,0 +1,33 @@ +## Context + +目前 Windows 的 WM_ACTIVATE 只发送网页事件,没有调用 WebView2 MoveFocus;macOS/Linux 的 set_window_focus_handler 为空实现。前端通过多次未托管的延迟调用恢复 Slate 焦点,并额外保留终端焦点。 + +## Goals / Non-Goals + +目标:让原生激活、网页选择和实际键盘输入一致;保留草稿、选区以及 Slate 等待 DOM 更新的保护。 +不涉及:消息发送、布局、浏览器普通网页的自动聚焦策略、发布或替换运行中的用户程序。 + +## Decisions + +1. 增加仅聚焦当前活动窗口 WebView 的 bridge。Windows 使用 MoveFocus,macOS 使用 makeFirstResponder,Linux 使用 gtk_widget_grab_focus。先由网页确认聊天可用,再调用原生层,成功后恢复输入。不同于现有拉前窗口功能,此入口不得激活后台窗口。 +2. 各平台接通原生窗口激活通知,最大化/恢复状态变化也发送同一通知。回调沿用 GUI 线程模型;macOS 观察者析构退订,Linux 信号随窗口销毁。 +3. 将窗口激活后的帧调度集中在 composerCaretRestore,合并重复通知;失焦、隐藏、卸载或用户开始其他交互后撤销待执行恢复。ChatView 直接提供聚焦回调,不再叠加无人管理的重试。 +4. 按本次要求移除终端焦点例外。保留设置、提问和模态框等阻塞表面的焦点管理,它们打开时底层聊天不是可用输入区域。 +5. 三平台原生聚焦和观察者资源管理收敛到 `web_host_focus.hpp`;WebHost 保留窗口事件转发。文件拖放复用同一活动 WebView 聚焦入口,保持入口文件不超过分层行数上限。 + +## Risks / Trade-offs + +### 2026-10-12 中文输入回归 + +在当前 Windows 的真实 WebView2 中复现:`Input.imeSetComposition` 已启动中文合成时,窗口激活通知仍调用原生 `MoveFocus`,合成文本被提前提交;随后正常提交再次插入相同文字。普通 Chromium 模拟的网页焦点没有此行为。现有恢复只由 pointerdown / keydown 撤销,遗漏 compositionstart、beforeinput 和 input,并且新激活通知仍可在合成中进入原生桥。 + +修复在自动恢复调度器记录合成状态:合成开始、文本输入与合成结束均撤销过期任务,合成中拒绝新恢复;页面已经获得真实焦点时不重复调用原生 MoveFocus,仅恢复聊天元素。RichComposer 的所有命令式聚焦入口在 active / settling 阶段直接退出,避免其他恢复入口改动原生候选词及选区;未渲染的 React 更新也延迟聚焦。保留旧 DOM 光标采纳和未渲染操作保护,不重建编辑器。以原生 WebView2 的合成中间状态、最终文字以及 DOM / Slate 光标偏移一致作为验收依据。 + +- 原生和 DOM 通知可能重入 → 合并在途请求,回调完成后复核页面状态。 +- 快速切走或打开弹框 → 在每次恢复前校验,取消过期异步工作。 +- 原生 WebView 获焦与 Slate 内部状态可能不同步 → 使用真实编辑器验证实际键盘输入和选区;保留已有 Slate 防护。 +- 当前验收机器为 Windows → 优先完成原生 Windows 验证,WebKit 自动化与 macOS/Linux 真机结果分别记录。 + +## Migration Plan + +无配置或数据迁移。前端兼容缺少新 bridge 的旧桌面壳;回滚本次源文件即可恢复原行为。 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/proposal.md b/openspec/changes/restore-chat-focus-on-window-activation/proposal.md new file mode 100644 index 000000000..beeec5df0 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/proposal.md @@ -0,0 +1,22 @@ +## Why + +桌面窗口经 Alt+Tab、最小化恢复或最大化后,聊天输入框可能显示光标但收不到键盘输入,需要再次点击。用户要求窗口重新获得焦点时,有可用聊天输入框就自动恢复输入,Windows 优先并覆盖 macOS、Linux。 + +## What Changes + +- 补齐原生窗口激活、WebView 键盘焦点和聊天光标之间的恢复流程。 +- 窗口重新激活时,即使此前使用过终端或预览,也回到当前聊天输入框。 +- 保留草稿和选区;后台窗口、隐藏聊天和正在交互的阻塞弹框不执行恢复;取消失效的异步恢复。 +- 补充 Windows 原生及跨浏览器回归验证,并分别记录其他平台的实现和实测状态。 + +## Capabilities + +### New Capabilities +- `desktop-chat-focus`: 桌面窗口重新激活时恢复当前可用聊天输入框的键盘焦点。 + +### Modified Capabilities +无。 + +## Impact + +涉及 desktop WebHost、原生 bridge、ChatView 和 composerCaretRestore;不增加依赖,不改变消息提交协议。 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/specs/desktop-chat-focus/spec.md b/openspec/changes/restore-chat-focus-on-window-activation/specs/desktop-chat-focus/spec.md new file mode 100644 index 000000000..7e4741301 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/specs/desktop-chat-focus/spec.md @@ -0,0 +1,41 @@ +## Purpose + +保证桌面应用从其他程序切回、从最小化恢复或切换窗口显示状态时,当前可用聊天输入框立即具备真实键盘输入能力,用户不必再次点击;同时保护现有草稿、选区与阻塞弹框交互。 + +## ADDED Requirements + +### Requirement: 窗口激活恢复聊天输入 + +Windows、macOS、Linux 桌面端 SHALL 在窗口重新获得焦点,或活动窗口完成最大化/恢复后,将键盘焦点交给当前可用聊天输入框。之前位于终端或预览中的焦点 MUST NOT 阻止恢复。 + +#### Scenario: 从其他程序切回 +- **WHEN** 用户通过 Alt+Tab 或平台对应操作切回有聊天输入框的窗口 +- **THEN** 用户无需鼠标点击即可继续输入,已有草稿和有效选区保留 + +#### Scenario: 恢复或最大化 +- **WHEN** 用户从最小化、隐藏状态恢复聊天窗口,或最大化/还原活动窗口 +- **THEN** 聊天输入框接收随后键入的文字 + +### Requirement: 恢复只作用于当前可用聊天 + +系统 MUST 取消过期恢复,不得拉前后台窗口、聚焦已卸载或隐藏的聊天,或覆盖阻塞弹框中的输入;普通窗口内控件切换 MUST NOT 触发持续抢焦点。 + +#### Scenario: 快速切走 +- **WHEN** 恢复仍在等待执行,用户又切到其他窗口 +- **THEN** 过期恢复不改变当前窗口或输入位置 + +#### Scenario: 阻塞表面打开 +- **WHEN** 设置、搜索、提问或模态框正在占用输入 +- **THEN** 底层聊天不夺取焦点 + +#### Scenario: 激活后继续其他操作 +- **WHEN** 用户已经恢复窗口并主动点击其他控件或输入文字 +- **THEN** 尚未执行的重复恢复被撤销,不影响本次交互 + +#### Scenario: 中文输入期间收到恢复通知 +- **WHEN** 中文输入法处于合成或提交结算阶段,窗口再次通知激活,或上一轮延迟恢复到达 +- **THEN** 系统 MUST 保留原生合成与可见光标,不重新聚焦 WebView 或编辑器,不提前提交、重复插入或移到其他位置 + +#### Scenario: 无键盘事件的文本输入 +- **WHEN** 输入法或原生输入服务以 beforeinput / input 更新文本而没有 keydown +- **THEN** 待执行的焦点恢复 MUST 被撤销,后续文字沿可见光标继续插入 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/tasks.md b/openspec/changes/restore-chat-focus-on-window-activation/tasks.md new file mode 100644 index 000000000..5d1229d35 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/tasks.md @@ -0,0 +1,26 @@ +## 1. 原生焦点恢复 + +- [x] 1.1 实现三平台活动窗口 WebView 聚焦与激活/恢复通知,通过 Windows 编译和平台分支审查验证。 + +## 2. 聊天输入恢复 + +- [x] 2.1 实现可撤销、去重的原生/DOM 焦点恢复,移除终端例外并保留 Slate 选区,通过定向回归测试验证。 + +## 3. 集成验收 + +- [x] 3.1 执行前端完整测试与构建、跨浏览器输入验证、OpenSpec 严格验证,记录证据和平台实测限制。 +- [x] 3.2 在连接且解锁的 Windows 桌面执行原生恢复输入实测;2026-10-12 使用独立 WebHost 验证窗口,通过切换、最小化恢复、最大化还原、隐藏显示的真实键盘输入及选区/弹框保护。 + +## 4. 中文合成与焦点恢复回归 + +- [x] 4.1 补齐输入事件的恢复撤销与合成期聚焦保护,以调度器和命令式聚焦定向测试验证。 +- [x] 4.2 使用真实 WebView2 复现并回归窗口激活与中文合成交错,断言合成未被提前提交、最终文字无重复、DOM 和 Slate 光标一致;执行 Web 全量测试、构建及 OpenSpec 严格验证。 + +## 2026-10-12 验证记录 + +- 修复前独立原生 WebView2 复现:合成中的 `中文` 因激活恢复提前写入 `tail`,得到 `中文tail`,再次提交后成为 `中文中文abctail`;对应断言稳定失败。普通浏览器的同一场景未出现原生提前提交。 +- `test-composer-focus-selection.mjs`:修复后 Chromium 6 组、原生 WebView2 7 组通过,覆盖恢复前后合成、遗留拖拽、直接聚焦、替换选区及真实窗口切回;提交后可见 DOM 光标与 Slate 插入点一致。测试通过 CDP 驱动浏览器中文合成,不宣称人工操作 Windows 候选词窗口验收。 +- `test-desktop-focus.mjs`:原生窗口切换、最小化恢复、最大化还原、隐藏显示的真实键盘输入,以及后台拒绝、选区和模态框保护通过。 +- `pnpm test`、`pnpm build`(正则兼容检查)、OpenSpec 严格验证和差异检查通过;新增取消路径包含 beforeinput、input、compositionstart/end,以及合成结束事件丢失后的失焦/隐藏恢复。 +- 运行中 Desktop 服务的 HTTP 前端资源 SHA-256 与最终 `web/dist/index.html` 一致。未重启用户正在使用的客户端;当前窗口需重新加载脚本。F5 被 browserDefaults 拦截,应重新启动客户端加载修复。 +- 保留工作区其他已有改动,未提交或推送;macOS/Linux 真机范围不扩充。 diff --git a/openspec/changes/restore-chat-focus-on-window-activation/validation.md b/openspec/changes/restore-chat-focus-on-window-activation/validation.md new file mode 100644 index 000000000..5351e75b4 --- /dev/null +++ b/openspec/changes/restore-chat-focus-on-window-activation/validation.md @@ -0,0 +1,44 @@ +# 窗口激活后的聊天焦点验收 + +日期:2026-10-11。当前 master 工作区;保留原有未提交改动,未提交、推送、发布或替换运行中的 Desktop。 + +## 实现 + +- Windows 继续接收 WM_ACTIVATE,并补齐最大化/还原通知;前端请求经过活动窗口校验后,WebView2 MoveFocus 交还系统键盘焦点。 +- macOS 补齐窗口成为 key、最小化恢复和全屏/最大化状态通知,使用 makeFirstResponder;Linux 接通 focus-in-event、窗口状态通知并使用 gtk_widget_grab_focus。 +- 前端统一管理恢复请求、帧调度和有限重试。终端不再阻止聊天恢复;原生通知先于页面可见时保留意图。失焦、隐藏、卸载或用户点击/输入会取消旧请求,阻塞弹框维持焦点保护。 +- 复用 Slate 已有选区和 DOM 就绪保护,并将取消条件传入其延迟重试。 + +## 已通过 + +- `node web/src/lib/composerCaretRestore.test.js`:覆盖原生桥成功/拒绝/失败、通知合并、激活早于可见、终端切回、弹框、失焦、交互和卸载取消。 +- `node web/src/lib/richComposerFocus.test.js`:7 项,包含 Slate 待提交时撤销恢复。 +- `pnpm test`、`pnpm build`:完整前端测试与生产构建通过;旧 WebView 正则兼容扫描通过。 +- `web/scripts/test-desktop-focus.mjs`:Chromium 与 Playwright WebKit 均通过真实编辑器键盘输入、原生桥重入去重、终端切回、草稿选区恢复、弹框保护和卸载检查。未发现页面运行错误。 +- Windows MSVC Release/x64 定向编译:本次 `web_host.cpp`、`main.cpp` 成功;独立原生测试宿主链接成功。 +- `python -B scripts/layers/check_layers.py --repo . --layout final --strict`、OpenSpec strict、定向 `git diff --check` 通过;Impeccable detector 结果 `[]`。分层检查使用包含新增文件的临时 Git 索引,实际用户索引未改变。 + +## 原生实测限制 + +独立宿主 `tests/desktop/fixtures/window_focus_host.cpp` 使用生产 WebHost、生产 RichComposer 和 Win32 SendInput,检查窗口切换、最小化恢复、最大化/还原、隐藏恢复、后台拒绝和选区/弹框保护。 + +本次运行环境的 `query session` 显示 shao 会话 2 为 Disc;当前线程桌面为 WinSta0/Default,输入桌面不可访问,`GetForegroundWindow()` 返回空。测试不能获得真实前台或发送可验证键盘输入,最终以退出码 77 明确跳过。此前诊断运行中后台聚焦入口返回 false,与保护约定一致。**Windows 真正前台键盘恢复仍需在连接、解锁的桌面补验,不宣称已通过。** + +已有静态库混用编译器版本导致独立宿主第一次链接出现 `__std_find_end_1` / `__std_mismatch_*`。在临时目录以同一 MSVC 重编译所需的三个 helper 对象后链接通过;未清理或覆盖用户构建目录。 + +初次分层检查发现入口文件超过行数限制,随后将原生聚焦与观察者提取到 `web_host_focus.hpp`,保持原限制不变。已有文件拖放架构测试同步追踪新的公共聚焦路径与后台保护,最终分层检查为零问题。 + +macOS、Linux 已完成实现及源码审查,未在对应操作系统编译或真机实测;Windows 上的 Playwright WebKit 不等同于原生 WKWebView。 + +## 复测入口 + +```powershell +$env:ACE_PLAYWRIGHT_MODULE = 'C:/Users/shao/AppData/Local/Python/pythoncore-3.14-64/Lib/site-packages/playwright/driver/package/index.mjs' +node web/scripts/test-desktop-focus.mjs + +# 在连接且解锁的 Windows 桌面运行;原生宿主需链接当前 web_host.cpp 和 desktop support。 +$env:ACE_FOCUS_NATIVE_HOST = "$env:TEMP/ace-focus-native/window_focus_host.exe" +node web/scripts/test-desktop-focus.mjs +``` + +本次编译命令和日志位于 `%TEMP%/ace-focus-native/compile.cmd`、`link.cmd`、`compile.log`、`link.log`、`smoke.log`;前端完整日志位于 `%TEMP%/ace-focus-web-tests.log` 和 `ace-focus-web-build.log`。 diff --git a/openspec/changes/slim-release-payloads/.openspec.yaml b/openspec/changes/slim-release-payloads/.openspec.yaml new file mode 100644 index 000000000..53f62d706 --- /dev/null +++ b/openspec/changes/slim-release-payloads/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/slim-release-payloads/design.md b/openspec/changes/slim-release-payloads/design.md new file mode 100644 index 000000000..53cada181 --- /dev/null +++ b/openspec/changes/slim-release-payloads/design.md @@ -0,0 +1,41 @@ +## Context + +See proposal.md for measured baselines. `acecode_embed_assets.cmake` currently emits raw arrays and `static_assets.cpp` returns them; the route already handles cache headers. `agent_office_pet.html` owns the actual pixel body, accessories, palette, seeded appearance and animation. Session metadata already persists expert/member IDs, but the office projection drops these fields. macOS desktop and flat CLI share one manifest target; existing extractors reject ZIP symlinks, and old flat validators require a root `acecode` file. + +## Goals / Non-Goals + +**Goals:** Retain complete offline features, URLs, custom expert resources, session semantics and old updater compatibility while reducing packaged data. Reuse the office's existing pixel art exactly. + +**Non-Goals:** Frontend feature removal, dependency/language reductions, layout redesign, deleting developer build caches, publishing a release or rewriting tags. Manual macOS portable archive layout is outside this update-ZIP change. + +## Decisions + +1. Generate gzip byte arrays using a Python standard-library generator called by CMake; timestamp is fixed, files sorted, binary reads preserved. Keep uncompressed form only where smaller. Carry stored size, decoded size and encoding metadata. Negotiate gzip at HTTP boundary; cache immutable decoded data for clients requiring identity encoding. Existing filesystem sources remain live and uncompressed. Reuse zlib with an explicit native dependency. Keep reconfigure dependencies for existing-file changes and generator changes. +2. Extract pixel character templates, palette and deterministic look selection into one small JavaScript asset consumable both by React and the self-contained pet page. Generate portrait pixels from those templates; do not introduce new image artwork or a second appearance algorithm. Use effective expert identity from structured `expert_id`/`expert_member_id`; root team maps to its lead, referenced members map to their expert IDs. Keep custom images intact and use pixel defaults when no custom image is configured. Replace managed built-in portrait declarations and remove their obsolete PNGs, bumping the seed bundle with the standard reconciliation machinery. +3. Add a tiny native macOS CLI forwarder, stored inside `ACECode.app/Contents/MacOS` so the existing nested-code signing and app notarization cover it. The update ZIP copies just that small forwarder to root as `acecode`; the large daemon and resources exist only inside the app. The wrapper resolves its real executable location and `exec`s the bundled daemon, preserving arguments, signals and exit status. This retains the old flat validator/application path and makes later invocations use the existing signed-app update path. An app-only ZIP would break old CLI clients; symlink ZIP entries are rejected by the existing extractor. The tiny forwarder duplicate is intentional compatibility overhead, not a duplicate runtime. +4. Keep edits scoped to current checkout. Capture current file baselines and preserve concurrent skill-scan work, especially shared API documentation and generated i18n content. No release side effects. + +## Risks / Trade-offs + +- [Bad encoded bytes cause a blank UI] -> binary round-trip fixtures including control bytes, deterministic regeneration tests, HTTP gzip/identity checks and browser startup verification. +- [HTTP caches mix encodings] -> Vary on Accept-Encoding for compressed resources and explicit q=0 handling. +- [Avatar drift between surfaces] -> one shared character source and expert binding tests across catalog, subagents and office snapshots. +- [Seed upgrade overwrites custom portraits] -> existing managed-hash reconciliation, fresh/old/custom profile tests, no blanket user-directory cleanup. +- [CLI wrapper loses path or signing compatibility] -> argument/space/symlink/exit probes, old-validator package tests, nested-code signature verification; native macOS checks remain explicitly recorded if not available on this host. + +## Migration Plan + +The source change takes effect on the next normal package build. Existing desktop updates use the unchanged signed app installer. Existing flat CLI installs receive a small root entry plus the app; unrelated old files remain untouched. Custom expert packages remain preserved by seed reconciliation. Rollback is a normal previous-version package and does not require manifest schema or target-name migration. + +## Validation record (2026-10-10) + +- Generator/native-page script tests: 3 passed, including binary control bytes, deterministic gzip, CMake incremental rebuild, and exact shared-office script inlining. +- Web suite: 3087 existing `[pass]` records plus 3 new Node tests passed; production build and ES2020 regex compatibility check passed. +- Browser: 9 pixel-identity checks; existing office suite passed 70 checks with the Windows bridge fixture and 70 with the macOS bridge fixture; opt-in/welcome/polling/preview suite passed. Real catalog, subagent and office components were rendered in Edge; these are browser fixtures, not an installed Desktop update. +- macOS forwarder/package: 3 tests compiled and executed the real POSIX launcher on WSL, exercised spaces, PATH/symlinks, exact arguments and exit codes, verified one runtime/resource tree, and rejected missing/oversized forwarding entries. Portable package tests 4 passed; release-asset tests 6 passed; release shell contracts passed against an LF-normalized temporary fixture (the Windows checkout uses CRLF). Native macOS signature/notarization and `_NSGetExecutablePath` execution are not available on this Windows host; the new test is registered in the macOS CI job. +- Layer, source-size, ownership, include, doc-path and migration-map gates passed; the new identity helper is separate from the size-capped registry implementation. +- Final native-generation measurement after build-version substitution: 317 files, 13,800,341 raw bytes -> 4,480,962 stored bytes (67.53% reduction; 8.89 MiB saved). Replaced default PNGs: 3,853,891 bytes removed. Seed version is `2026-10-10.1`; no built-in expert PNG remains. +- Windows `acecode`, `acecode-desktop`, and `acecode_unit_tests` built successfully in `build/refactor-phase1-windows`. The first long build predated the added source-file inventory and failed at link; CMake regeneration and the final 27-step incremental build passed. +- Focused native suite: 141 executed, 0 skipped, 0 failed (19.2 s), including `DesktopOfficeHttp.ExpertMemberIdentitySurvivesOfficeProjection`, both `PixelPortraitUpgrade*` tests, `UpgradeApply.LegacyMacFlatInstallAcceptsOneAppAndSmallForwarder`, packaged seed manifest hashes, and gzip negotiation/content/cache tests. +- Real staged daemon: 8 HTTP checks passed for gzip, identity, explicit gzip quality zero, concurrent fallback, SPA fallback and versioned SVG caching. Served HTML is 12,875,655 bytes decoded / 3,866,414 bytes gzip; decoded SHA-256 is `53833d3fc42a01a61e99829dfaf301490b5d2bd79efb26156789158d06dc95a8` and matches current dist with version substitution. Edge successfully started the actual compressed frontend. Isolated test processes/profiles were cleaned up; the existing installed Desktop processes were left running. +- Browser test maintenance: the existing office preference test now reads the label span instead of concatenating shortcut badges; no production menu behavior was changed. The HTTP browser harness explicitly writes UTF-8 to Node stdin on Windows. diff --git a/openspec/changes/slim-release-payloads/proposal.md b/openspec/changes/slim-release-payloads/proposal.md new file mode 100644 index 000000000..94b5fd7ee --- /dev/null +++ b/openspec/changes/slim-release-payloads/proposal.md @@ -0,0 +1,26 @@ +## Why + +v1.0.3 Windows 包解压后 42.75 MiB,其中前端 HTML 原文 12.26 MiB、内置专家 PNG 头像约 3.68 MiB;macOS 更新 ZIP 为兼容独立 CLI 重复携带完整 daemon 和资源,增加约 18.3 MiB 下载量。用户要求压缩前端、改用像素办公室头像并关联专家子 agent、去除 macOS 更新包重复载荷。 + +## What Changes + +- 构建时确定性压缩可获益的内嵌前端资源,保留原始内容兼容路径和开发目录服务。 +- 将办公室现有人物模板和稳定外观算法提取为共享资源;内置专家使用同一像素头像身份,对应专家团子 agent 沿结构化专家绑定取得相同外观。 +- 移除已替换的内置专家大 PNG,升级种子清单,并保留用户自定义头像。 +- macOS 更新 ZIP 只携带一份完整应用和资源,以小型、签名且随应用公证的 CLI 转发入口维持旧版独立 CLI 的平铺更新兼容性。 +- 增加体积、资源完整性、头像身份关联和旧版更新路径回归检查。 + +## Capabilities + +### New Capabilities + +- `compressed-web-assets`: 内嵌前端压缩存储、HTTP 编码协商和无损兼容。 +- `expert-office-avatars`: 专家和其子 agent 使用与像素办公室一致的稳定头像。 + +### Modified Capabilities + +- `self-upgrade`: macOS 更新包去重,同时保留桌面与独立 CLI 升级入口和签名验证。 + +## Impact + +涉及 CMake 资源生成器、静态资源接口与 HTTP 路由、前端专家和子 agent 展示、办公室内嵌页面、专家种子资源和元数据投影、macOS CLI 转发入口及打包验证。沿用现有 zlib 依赖;生成器使用 Python 标准库。保留当前分支、工作区其他改动,不发布或改变既有标签。 diff --git a/openspec/changes/slim-release-payloads/specs/compressed-web-assets/spec.md b/openspec/changes/slim-release-payloads/specs/compressed-web-assets/spec.md new file mode 100644 index 000000000..c00931ed9 --- /dev/null +++ b/openspec/changes/slim-release-payloads/specs/compressed-web-assets/spec.md @@ -0,0 +1,24 @@ +## Purpose + +Reduce installed application size and static-resource transfer costs while preserving the complete offline frontend, existing URLs, MIME types, cache behavior, and development directory overrides. + +## ADDED Requirements + +### Requirement: Lossless compressed frontend delivery +The packaged application SHALL store compressible frontend assets in compressed form when that reduces size, and SHALL deliver their complete original content with correct content types. Generation MUST be deterministic and preserve binary bytes and version substitution. + +#### Scenario: Browser accepts gzip +- **WHEN** a browser requests a compressed embedded asset with gzip accepted +- **THEN** the response carries gzip encoding and an appropriate Vary header, and decompresses to the original asset + +#### Scenario: Client does not accept gzip +- **WHEN** a client omits gzip support or explicitly assigns gzip quality zero +- **THEN** the same resource is returned uncompressed without a Content-Encoding header + +#### Scenario: Existing asset changes +- **WHEN** a frontend file is modified without changing its name +- **THEN** the next native build refreshes its embedded content + +#### Scenario: Development directory and incompressible assets +- **WHEN** an asset comes from the configured development directory or compression would increase its size +- **THEN** its original bytes remain available through the same resource URL diff --git a/openspec/changes/slim-release-payloads/specs/expert-office-avatars/spec.md b/openspec/changes/slim-release-payloads/specs/expert-office-avatars/spec.md new file mode 100644 index 000000000..f997832e7 --- /dev/null +++ b/openspec/changes/slim-release-payloads/specs/expert-office-avatars/spec.md @@ -0,0 +1,31 @@ +## Purpose + +Give built-in experts a compact recognizable pixel identity shared with the existing virtual office, so users can recognize the same expert across the catalog, team execution, and its live subagent without downloading separate portraits. + +## ADDED Requirements + +### Requirement: Shared pixel expert identity +Built-in experts SHALL use portraits derived from the existing office character templates. An expert and a subagent bound to that expert SHALL have the same stable appearance in the catalog, subagent presentation, and virtual office. Identity MUST use structured expert and member bindings rather than display-name matching or session order. + +#### Scenario: Team delegates to an expert +- **WHEN** a team spawns a subagent for a declared expert member +- **THEN** its portrait and office character match that member's catalog identity even when the session ID changes + +#### Scenario: Expert is the session leader +- **WHEN** an expert-bound root session is shown in the office +- **THEN** its character uses the expert identity rather than the generic conductor appearance + +#### Scenario: Custom expert portrait +- **WHEN** a user has configured a custom portrait or state portrait +- **THEN** the configured image is preserved and remains available + +### Requirement: Compact built-in expert resources +Release seed resources SHALL omit the replaced built-in photographic PNG portraits. Managed seed upgrades MUST retain user modifications and update only managed defaults under the existing seed reconciliation contract. + +#### Scenario: Fresh installation +- **WHEN** built-in experts are seeded in an empty profile +- **THEN** all experts have pixel identities without requiring the old portrait files + +#### Scenario: Existing modified expert +- **WHEN** seed reconciliation encounters a user-modified expert package +- **THEN** it preserves that package and its custom image resources diff --git a/openspec/changes/slim-release-payloads/specs/self-upgrade/spec.md b/openspec/changes/slim-release-payloads/specs/self-upgrade/spec.md new file mode 100644 index 000000000..0f5e03b0d --- /dev/null +++ b/openspec/changes/slim-release-payloads/specs/self-upgrade/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Deduplicated macOS update payload +The macOS self-update archive SHALL contain a single complete signed application payload and a lightweight CLI forwarding entry, without a second copy of the daemon, Computer Use runtime, model catalog, or seed tree outside the application. It MUST remain consumable by the existing desktop and flat CLI updater, preserve executable permissions and argument boundaries, and keep strict signature and notarization checks for official releases. + +#### Scenario: Existing desktop installation updates +- **WHEN** an existing desktop client consumes the deduplicated archive +- **THEN** it finds, verifies and atomically installs the complete app through the existing app update path + +#### Scenario: Existing flat CLI installation updates +- **WHEN** a client expecting a root acecode executable consumes the archive +- **THEN** it installs the forwarding entry and app, and subsequent CLI invocations execute the bundled daemon with unchanged arguments and its bundled resources + +#### Scenario: Forwarding entry is invoked through PATH or a symbolic link +- **WHEN** a user launches the CLI outside the installation directory +- **THEN** it locates the app relative to the real launcher location and returns the daemon's exit behavior + +#### Scenario: Incomplete archive +- **WHEN** the application, forwarding entry, required runtime, or bundled resources are missing +- **THEN** package verification fails before publication diff --git a/openspec/changes/slim-release-payloads/tasks.md b/openspec/changes/slim-release-payloads/tasks.md new file mode 100644 index 000000000..47e6cd02f --- /dev/null +++ b/openspec/changes/slim-release-payloads/tasks.md @@ -0,0 +1,20 @@ +## 1. Compressed frontend + +- [x] 1.1 Implement deterministic compressed resource generation and verify binary/control-byte round trips and incremental regeneration tests (2 Python tests passed). +- [x] 1.2 Serve negotiated gzip with identity fallback and verify MIME, caching, q=0 handling and concurrent lookups in native tests (focused native suite and 8 real HTTP checks passed; actual bundled page started in Edge). + +## 2. Shared pixel expert identities + +- [x] 2.1 Extract the existing office character renderer and identity helpers; verify the same seed produces identical portrait and office pixels (unit tests, 9 avatar browser checks, 70 office regression checks, native-page inline-byte verification). +- [x] 2.2 Carry expert/member identity through session and office projections and subagent UI; verify team lead/member/repeated-session associations and custom avatar preservation (C++ office HTTP/catalog identity test, pure JS and browser tests passed). +- [x] 2.3 Replace built-in expert PNG resources with pixel defaults and update the seed manifest; verify fresh install, managed upgrade and user-modified package preservation (native seed suite passed, including both new portrait migration cases). + +## 3. macOS update deduplication + +- [x] 3.1 Build the small bundled CLI forwarder and verify paths, arguments, exit status and missing-payload failures with focused tests (3 real POSIX compiler/exec and package tests passed on WSL). +- [x] 3.2 Package the app once plus the forwarding entry; update package/signature/resource checks and verify the old flat updater contract and desktop app discovery (real POSIX launcher/package tests, Windows old-flat-apply test and existing Mac bundle tests passed; native Apple trust checks remain in release CI). + +## 4. Integration and evidence + +- [x] 4.1 Run web tests/build, focused native tests/build, script tests, strict OpenSpec validation and scoped diff checks; record exact results and any unavailable native platform checks (all applicable Windows/WSL/browser gates passed; native Mac trust checks are explicitly pending the normal release environment). +- [x] 4.2 Verify rendered expert/subagent/office identity, gzip HTTP/browser startup and before/after payload sizes; document the final behavior and measured savings (see design.md validation record and the daemon API/release-artifact documentation). diff --git a/scripts/embed_web_assets.py b/scripts/embed_web_assets.py new file mode 100644 index 000000000..a33297c45 --- /dev/null +++ b/scripts/embed_web_assets.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate deterministic, losslessly compressed native frontend resources.""" +import argparse +import gzip +import io +import json +from pathlib import Path + + +def encode_asset(data): + buffer = io.BytesIO() + with gzip.GzipFile(filename="", mode="wb", fileobj=buffer, + compresslevel=9, mtime=0) as stream: + stream.write(data) + compressed = buffer.getvalue() + return (compressed, True) if len(compressed) < len(data) else (data, False) + + +def generate(root, output, version): + entries = [] + original_size = stored_size = 0 + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w", encoding="utf-8", newline="\n") as stream: + stream.write('// Generated by embed_web_assets.py. DO NOT EDIT.\n' + '#include "web/static_assets.hpp"\n' + 'namespace acecode::web {\n') + for path in sorted(root.rglob("*")) if root.exists() else []: + if not path.is_file() or path.name in {"README.md", ".gitignore"}: + continue + relative = path.relative_to(root).as_posix() + raw = path.read_bytes() + if relative == "index.html": + raw = raw.replace(b"__VERSION__", version.encode("utf-8")) + data, compressed = encode_asset(raw) + name = f"asset_{len(entries)}" + stream.write(f"static const unsigned char {name}[] = {{\n") + for offset in range(0, len(data), 24): + stream.write("".join(f"0x{byte:02x}," for byte in data[offset:offset + 24]) + "\n") + if not data: + stream.write("0\n") + stream.write("};\n") + entries.append(f" {{{json.dumps(relative)}, {{{name}, {len(data)}, " + f"{len(raw)}, {'true' if compressed else 'false'}}}}},\n") + original_size += len(raw) + stored_size += len(data) + stream.write("const std::map& embedded_asset_map() {\n" + " static const std::map assets = {\n") + stream.writelines(entries) + stream.write(" };\n return assets;\n}\n}\n") + print(f"[acecode_embed_assets] {len(entries)} assets: {original_size} -> {stored_size} bytes") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--version", required=True) + args = parser.parse_args() + generate(args.root, args.output, args.version) diff --git a/scripts/macos_create_update_zip.sh b/scripts/macos_create_update_zip.sh index 26bc8f42c..36466d9a0 100755 --- a/scripts/macos_create_update_zip.sh +++ b/scripts/macos_create_update_zip.sh @@ -8,11 +8,10 @@ Usage: --output [--require-trusted] Creates a self-update ZIP with Apple's ditto tool. The archive contains the -complete app plus a root `acecode` copied from its notarized bundled daemon and -a root `share/acecode` resource tree so standalone CLI installs retain their -existing flat upgrade path, offline model catalog, and default seed bundle. The -command extracts into a clean temporary directory and verifies executable -permissions/resources. +complete app plus a small root `acecode` copied from its notarized bundled CLI +forwarder. The daemon, helper and resources exist only inside the app; existing +flat CLI updaters still find a root executable. The command extracts into a clean +temporary directory and verifies executable permissions and bundled resources. --require-trusted also requires strict signatures, a stapled app ticket, and Gatekeeper acceptance; tagged release jobs use this mode after app notarization. USAGE @@ -72,6 +71,16 @@ if [[ ! -d "$app_path" || "$(basename "$app_path")" != "ACECode.app" ]]; then exit 1 fi computer_use_helper="$app_path/Contents/MacOS/acecode-computer-use" +cli_launcher="$app_path/Contents/MacOS/acecode-cli" +if [[ ! -x "$cli_launcher" || ! -s "$cli_launcher" ]]; then + echo "ACECode.app is missing the CLI forwarder: $cli_launcher" >&2 + exit 1 +fi +# A copied full daemon would silently restore the old duplicate payload. +if [[ "$(wc -c < "$cli_launcher" | tr -d '[:space:]')" -gt 262144 ]]; then + echo "CLI forwarder exceeds the 256 KiB package budget" >&2 + exit 1 +fi if [[ ! -x "$computer_use_helper" || ! -s "$computer_use_helper" ]]; then echo "ACECode.app is missing the Computer Use helper: $computer_use_helper" >&2 exit 1 @@ -112,22 +121,11 @@ trap cleanup EXIT /usr/bin/ditto -c -k --keepParent "$app_path" "$temporary_zip" # The manifest target is shared by desktop and standalone CLI installations. -# Reuse the already signed/notarized bundled daemon as the flat CLI payload. -/usr/bin/ditto "$app_path/Contents/MacOS/acecode-daemon" \ - "$temporary_root/acecode" -/usr/bin/ditto "$computer_use_helper" "$temporary_root/acecode-computer-use" -mkdir -p "$temporary_root/share/acecode" -/usr/bin/ditto "$models_dev_dir" \ - "$temporary_root/share/acecode/models_dev" -/usr/bin/ditto "$seed_dir" \ - "$temporary_root/share/acecode/seed" -if [[ -d "$app_path/Contents/Resources/channels" ]]; then - /usr/bin/ditto "$app_path/Contents/Resources/channels" "$temporary_root/channels" -fi +# Reuse only the signed/notarized tiny launcher. All runtime data stays in the app. +/usr/bin/ditto "$cli_launcher" "$temporary_root/acecode" ( cd "$temporary_root" - /usr/bin/zip -qr "$temporary_zip" acecode acecode-computer-use share - if [[ -d channels ]]; then /usr/bin/zip -qr "$temporary_zip" channels; fi + /usr/bin/zip -q "$temporary_zip" acecode ) mkdir -p "$verify_root" /usr/bin/ditto -x -k "$temporary_zip" "$verify_root" @@ -137,24 +135,26 @@ app_main="$verified_app/Contents/MacOS/ACECode" app_daemon="$verified_app/Contents/MacOS/acecode-daemon" verified_cli="$verify_root/acecode" verified_app_models="$verified_app/Contents/Resources/share/acecode/models_dev" -verified_cli_models="$verify_root/share/acecode/models_dev" verified_app_seed="$verified_app/Contents/Resources/share/acecode/seed" -verified_cli_seed="$verify_root/share/acecode/seed" if [[ ! -d "$verified_app" || ! -x "$app_main" || ! -x "$app_daemon" || - ! -x "$verified_cli" || ! -x "$verify_root/acecode-computer-use" || + ! -x "$verified_cli" || ! -x "$verified_app/Contents/MacOS/acecode-cli" || ! -x "$verified_app/Contents/MacOS/acecode-computer-use" ]]; then echo "Extracted update archive is missing executable ACECode app or CLI binaries." >&2 exit 1 fi -if ! /usr/bin/cmp -s "$verify_root/acecode-computer-use" "$verified_app/Contents/MacOS/acecode-computer-use"; then - echo "Extracted Computer Use helpers differ between CLI and app payloads." >&2 +if ! /usr/bin/cmp -s "$verified_cli" "$verified_app/Contents/MacOS/acecode-cli"; then + echo "Extracted CLI forwarder differs from the signed app copy." >&2 exit 1 fi +for duplicate in share channels acecode-computer-use; do + if [[ -e "$verify_root/$duplicate" ]]; then + echo "Duplicated macOS runtime resource: $duplicate" >&2 + exit 1 + fi +done for models_dev_file in "${models_dev_files[@]}"; do - if [[ ! -f "$verified_app_models/$models_dev_file" || - ! -f "$verified_cli_models/$models_dev_file" ]] || - ! /usr/bin/cmp -s "$verified_app_models/$models_dev_file" \ - "$verified_cli_models/$models_dev_file"; then + if [[ ! -f "$verified_app_models/$models_dev_file" ]] || + ! /usr/bin/cmp -s "$verified_app_models/$models_dev_file" "$models_dev_dir/$models_dev_file"; then echo "Extracted update archive has missing or mismatched models.dev resource: $models_dev_file" >&2 exit 1 fi @@ -162,14 +162,11 @@ done python3 "$repo_root/scripts/verify_seed_bundle.py" \ --source "$repo_root/assets/seed" \ --packaged "$verified_app_seed" -python3 "$repo_root/scripts/verify_seed_bundle.py" \ - --source "$repo_root/assets/seed" \ - --packaged "$verified_cli_seed" if [[ "$require_trusted" == true ]]; then /usr/bin/codesign --verify --deep --strict --verbose=2 "$verified_app" /usr/bin/codesign --verify --strict --verbose=2 "$verified_cli" - /usr/bin/codesign --verify --strict --verbose=2 "$verify_root/acecode-computer-use" + /usr/bin/codesign --verify --strict --verbose=2 "$verified_app/Contents/MacOS/acecode-computer-use" xcrun stapler validate "$verified_app" /usr/sbin/spctl --assess --type execute --verbose=4 "$verified_app" fi diff --git a/src/adapters/upgrade/macos_app_installer.mm b/src/adapters/upgrade/macos_app_installer.mm index bb208d4a2..31745df91 100644 --- a/src/adapters/upgrade/macos_app_installer.mm +++ b/src/adapters/upgrade/macos_app_installer.mm @@ -413,12 +413,13 @@ bool install_macos_app_update(const fs::path& installed_bundle, NSFileManager* file_manager = [NSFileManager defaultManager]; NSURL* installed_url = file_url(installed_bundle, YES); NSURL* candidate_url = file_url(candidate_bundle, YES); - const fs::path temporary_path = applications_dir / - (".ACECode-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]) + - ".updating.app"); - const fs::path previous_path = applications_dir / - (".ACECode-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]) + - ".previous.app"); + // The old bundle may still own running processes until restart. Keep + // its actual app name stable for macOS permission/display-name lookup. + MacosAppUpdateWorkspace workspace(applications_dir / + (".acecode-update-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]))); + if (!workspace.create(error)) return false; + const fs::path temporary_path = workspace.staged_bundle(); + const fs::path previous_path = workspace.previous_bundle(); NSURL* temporary_url = file_url(temporary_path, YES); NSURL* previous_url = file_url(previous_path, YES); if (!installed_url || !candidate_url || !temporary_url || !previous_url) { @@ -481,9 +482,7 @@ bool install_macos_app_update(const fs::path& installed_bundle, !authenticate_candidate(previous_path, installed_bundle, expected_version, &final_error)) { std::string cleanup_error; - const fs::path failed_path = applications_dir / - (".ACECode-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]) + - ".failed.app"); + const fs::path failed_path = workspace.failed_bundle(); NSURL* failed_url = file_url(failed_path, YES); bool moved_failed = failed_url && move_item(file_manager, installed_url, failed_url, &cleanup_error); diff --git a/src/adapters/upgrade/macos_bundle.cpp b/src/adapters/upgrade/macos_bundle.cpp index e192a3108..280f6dfeb 100644 --- a/src/adapters/upgrade/macos_bundle.cpp +++ b/src/adapters/upgrade/macos_bundle.cpp @@ -3,9 +3,15 @@ #include "utils/encoding.hpp" #include +#include #include +#include #include +#ifndef _WIN32 +#include +#endif + namespace fs = std::filesystem; namespace acecode::upgrade { @@ -35,10 +41,62 @@ bool is_complete_bundle_layout(const fs::path& bundle) { } // namespace +MacosAppUpdateWorkspace::MacosAppUpdateWorkspace(fs::path root) + : root_(std::move(root)) {} + +MacosAppUpdateWorkspace::~MacosAppUpdateWorkspace() { + if (!owns_root_) return; + // remove (not remove_all) deliberately leaves every nonempty directory. + for (const auto& directory : {staged_bundle().parent_path(), + previous_bundle().parent_path(), failed_bundle().parent_path(), root_}) { + std::error_code ec; + if (is_real_directory(directory)) fs::remove(directory, ec); + } +} + +fs::path MacosAppUpdateWorkspace::staged_bundle() const { + return root_ / "staging" / "ACECode.app"; +} + +fs::path MacosAppUpdateWorkspace::previous_bundle() const { + return root_ / "previous" / "ACECode.app"; +} + +fs::path MacosAppUpdateWorkspace::failed_bundle() const { + return root_ / "failed" / "ACECode.app"; +} + +bool MacosAppUpdateWorkspace::create(std::string* error) { + if (owns_root_) { + if (error) *error = "macOS update directory is already in use"; + return false; + } + for (const auto& directory : {root_, staged_bundle().parent_path(), + previous_bundle().parent_path(), failed_bundle().parent_path()}) { + std::error_code ec; +#ifdef _WIN32 + const bool created = fs::create_directory(directory, ec); +#else + // Atomically require a new private directory; never adopt a preexisting + // directory or follow a symlink at the selected update root. + const bool created = ::mkdir(directory.c_str(), S_IRWXU) == 0; + if (!created) ec = std::error_code(errno, std::generic_category()); +#endif + if (!created) { + if (error) *error = "cannot create private macOS update directory: " + + directory.u8string() + ": " + + (ec ? ensure_utf8(ec.message()) : "path already exists"); + return false; + } + if (directory == root_) owns_root_ = true; + } + return true; +} + bool macos_app_install_path_is_safe(const fs::path& bundle, std::string* error) { auto reject = [&]() { if (error) *error = "macOS self-update requires an absolute real ACECode.app " - "with a real parent, outside App Translocation and other app bundles"; + "with a real parent, outside App Translocation, update copies and other app bundles"; return false; }; if (!bundle.is_absolute() || bundle != bundle.lexically_normal() || @@ -47,7 +105,7 @@ bool macos_app_install_path_is_safe(const fs::path& bundle, std::string* error) std::string name = component.string(); std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (name == "apptranslocation" || + if (name == "apptranslocation" || name.rfind(".acecode-update-", 0) == 0 || fs::path(name).extension() == ".app") return reject(); } std::error_code ec; diff --git a/src/adapters/upgrade/macos_bundle.hpp b/src/adapters/upgrade/macos_bundle.hpp index 6ff37f191..28f73147c 100644 --- a/src/adapters/upgrade/macos_bundle.hpp +++ b/src/adapters/upgrade/macos_bundle.hpp @@ -6,8 +6,28 @@ namespace acecode::upgrade { +// Owns only newly created update directories. Destruction removes empty +// containers, never application copies needed by a running process or rollback. +class MacosAppUpdateWorkspace { +public: + explicit MacosAppUpdateWorkspace(std::filesystem::path root); + ~MacosAppUpdateWorkspace(); + MacosAppUpdateWorkspace(const MacosAppUpdateWorkspace&) = delete; + MacosAppUpdateWorkspace& operator=(const MacosAppUpdateWorkspace&) = delete; + + bool create(std::string* error = nullptr); + std::filesystem::path staged_bundle() const; + std::filesystem::path previous_bundle() const; + std::filesystem::path failed_bundle() const; + +private: + std::filesystem::path root_; + bool owns_root_ = false; +}; + // Require an absolute, canonical, real ACECode.app and parent; reject App -// Translocation and bundles nested in another application. Does not check trust. +// Translocation, update copies and bundles nested in another application. +// Does not check trust. bool macos_app_install_path_is_safe( const std::filesystem::path& bundle, std::string* error = nullptr); diff --git a/src/apps/daemon/im_channels.cpp b/src/apps/daemon/im_channels.cpp index 587d60c17..ec913c878 100644 --- a/src/apps/daemon/im_channels.cpp +++ b/src/apps/daemon/im_channels.cpp @@ -57,6 +57,26 @@ std::unique_ptr make_im_channel_host(ImChannelDeps } return registry->switch_model(id, *profile, nullptr, error); }; + conversation.set_permission_mode = [registry](const std::string& id, const std::string& mode) { + const auto parsed = PermissionManager::parse_mode_name(mode); + return parsed && registry->set_permission_mode(id, *parsed); + }; + conversation.model_state = [registry](const std::string& id) { return registry->current_model_state(id); }; + conversation.set_reasoning_effort = [registry](const std::string& id, + const std::optional& effort, + std::string* error) -> std::optional { + const auto result = registry->set_reasoning_effort(id, effort); + if (result.status == SessionReasoningStatus::Updated) return result.state; + if (error) { + switch (result.status) { + case SessionReasoningStatus::InvalidEffort: *error = "当前模型不支持该 effort 档位"; break; + case SessionReasoningStatus::UnknownSession: *error = "会话不存在"; break; + case SessionReasoningStatus::Unavailable: *error = "当前会话模型不可用"; break; + default: *error = "更新思考深度失败,原设置已保留"; break; + } + } + return std::nullopt; + }; // 与 Web 输入相同的展开顺序:先 opencode 命令,再技能命令。 conversation.expand_skill = [config, config_mu, registry](const std::string& id, const std::string& text) { if (auto entry = registry->acquire(id); entry && !entry->cwd.empty()) { diff --git a/src/apps/desktop/macos_cli_launcher.cpp b/src/apps/desktop/macos_cli_launcher.cpp new file mode 100644 index 000000000..a288a4860 --- /dev/null +++ b/src/apps/desktop/macos_cli_launcher.cpp @@ -0,0 +1,52 @@ +// Small, signed CLI entry for the deduplicated macOS update archive. +// exec preserves terminal I/O, argument boundaries, signals and exit status. +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#endif + +namespace { +std::filesystem::path launcher_path() { +#ifdef __APPLE__ + std::uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::vector path(size); + if (_NSGetExecutablePath(path.data(), &size) != 0) return {}; + return std::filesystem::canonical(path.data()); +#else + // Linux permits exercising the actual exec path in portable packaging tests. + return std::filesystem::canonical("/proc/self/exe"); +#endif +} +} // namespace + +int main(int argc, char** argv) { + try { + const auto parent = launcher_path().parent_path(); + const bool bundled = parent.filename() == "MacOS" && + parent.parent_path().filename() == "Contents" && + parent.parent_path().parent_path().filename() == "ACECode.app"; + const auto daemon = bundled ? parent / "acecode-daemon" + : parent / "ACECode.app" / "Contents" / "MacOS" / "acecode-daemon"; + const std::string executable = daemon.string(); + std::vector arguments; + arguments.reserve(static_cast(argc) + 1); + arguments.push_back(const_cast(executable.c_str())); + for (int i = 1; i < argc; ++i) arguments.push_back(argv[i]); + arguments.push_back(nullptr); + ::execv(executable.c_str(), arguments.data()); + const int code = errno; + std::fprintf(stderr, "ACECode CLI: cannot start %s: %s\n", + executable.c_str(), std::strerror(code)); + return code == ENOENT ? 127 : 126; + } catch (const std::exception& error) { + std::fprintf(stderr, "ACECode CLI: cannot locate the installed application: %s\n", error.what()); + return 126; + } +} diff --git a/src/apps/desktop/main.cpp b/src/apps/desktop/main.cpp index 564a8e44c..85f5d1270 100644 --- a/src/apps/desktop/main.cpp +++ b/src/apps/desktop/main.cpp @@ -2053,6 +2053,9 @@ int main(int argc, char** argv) { bring_window_foreground(); return nlohmann::json{{"ok", true}}.dump(); }); + host.bind("aceDesktop_focusActiveWebview", [&host](const std::string& /*req*/) -> std::string { + return nlohmann::json{{"ok", host.focus_active_webview()}}.dump(); + }); #ifdef _WIN32 host.bind("aceDesktop_setTaskbarBadge", [&host](const std::string& req) -> std::string { const auto badge = parse_taskbar_badge_args(req); @@ -2074,9 +2077,7 @@ int main(int argc, char** argv) { return nlohmann::json{{"ok", host.focus_after_file_drop()}}.dump(); }); - // WM_SIZE 时如果最大化状态变化(被 web_host.cpp 内部 g_last_known_maximized 去重过), - // eval 一段 JS 调前端 window.aceDesktop_onMaximizeStateChanged(bool),让 TopBar 切换 - // 图标。前端没注册回调时静默吞,避免 webview 异常 ts 上报。 + // 去重后的原生最大化状态同步到 TopBar;前端尚未注册回调时忽略。 host.set_window_state_change_handler([&host, &agent_browser](bool maximized) { agent_browser.refresh_layout(); const std::string js = std::string( @@ -2100,9 +2101,7 @@ int main(int argc, char** argv) { agent_browser.set_parent_visible(visible); }); - // Native window activation does not consistently reach WebView2 as a DOM - // focus event. Send an explicit event after WM_ACTIVATE so a visible chat - // composer can restore keyboard focus without requiring another click. + // Explicit native activation lets the visible composer restore keyboard focus. host.set_window_focus_handler([&host, &agent_browser]() { agent_browser.set_parent_visible(true); host.eval("(function(){try{window.dispatchEvent(new Event(" diff --git a/src/apps/desktop/web_host.cpp b/src/apps/desktop/web_host.cpp index 84b9e1135..2bcc0c0e9 100644 --- a/src/apps/desktop/web_host.cpp +++ b/src/apps/desktop/web_host.cpp @@ -70,6 +70,8 @@ # undef Success #endif +#include "web_host_focus.hpp" + namespace acecode::desktop { namespace { @@ -86,6 +88,8 @@ WebHost::FileDropHandler g_file_drop_handler; // main.cpp uses this GUI-thread callback to consume its one-shot open request. std::function g_existing_instance_focus_handler; +using detail::window_focus_handler; + #ifdef __APPLE__ std::function g_mac_window_state_handler; bool g_mac_last_known_maximized = false; @@ -98,11 +102,7 @@ MacApplicationReopenImp g_mac_original_reopen_imp = nullptr; NSString* const kAceCodeFocusExistingNotification = @"dev.acecode.desktop.focusExisting.v1"; -NSWindow* mac_window_from_host(webview::webview& w) { - auto window_result = w.window(); - if (!window_result.ok() || !window_result.value()) return nil; - return static_cast(window_result.value()); -} +using detail::mac_window_from_host; void notify_mac_window_state_if_changed(NSWindow* window) { if (!window) return; @@ -112,6 +112,7 @@ void notify_mac_window_state_if_changed(NSWindow* window) { if (g_mac_window_state_handler) { g_mac_window_state_handler(maximized); } + if ([window isKeyWindow] && window_focus_handler) window_focus_handler(); } bool mac_window_is_fullscreen(NSWindow* window) { @@ -174,6 +175,7 @@ void notify_mac_window_fullscreen_if_changed(NSWindow* window) { if (g_mac_window_fullscreen_handler) { g_mac_window_fullscreen_handler(fullscreen); } + if ([window isKeyWindow] && window_focus_handler) window_focus_handler(); } id install_mac_window_fullscreen_observer(webview::webview& w, @@ -711,11 +713,6 @@ void notify_window_visibility(bool visible) { if (g_window_visibility_handler) g_window_visibility_handler(visible); } -// WebView2 does not reliably turn a native top-level activation into a DOM -// window.focus event. The Desktop main process uses this callback to dispatch -// an explicit frontend event after the host has completed WM_ACTIVATE handling. -std::function g_window_focus_handler; - // 单例联动:第二次启动 acecode-desktop 会向已有 host window 派 focus msg, // 让我们把窗口拉前 + 显示。同名 RegisterWindowMessageW 在两端拿到一致 UINT, // 见 single_instance_win.cpp 头注。第一次访问时 lazily register,缓存到 static。 @@ -1373,8 +1370,8 @@ LRESULT CALLBACK host_window_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lpa case WM_ACTIVATE: { const LRESULT result = call_host_default_proc(hwnd, msg, wparam, lparam); if (LOWORD(wparam) != WA_INACTIVE && !::IsIconic(hwnd) && - g_window_focus_handler) { - g_window_focus_handler(); + window_focus_handler) { + window_focus_handler(); } return result; } @@ -1406,13 +1403,17 @@ LRESULT CALLBACK host_window_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lpa // 拉满整个客户区,所以先让它跑完,再按顶边线让位重新摆放。 const LRESULT result = call_host_default_proc(hwnd, msg, wparam, lparam); resize_webview_widget(hwnd); + const bool was_visible = g_last_known_window_visible; + const bool maximized = ::IsZoomed(hwnd) != FALSE; + const bool state_changed = maximized != g_last_known_maximized; + g_last_known_maximized = maximized; notify_window_visibility(wparam != SIZE_MINIMIZED); - if (g_window_state_handler) { - const bool maximized = ::IsZoomed(hwnd) != FALSE; - if (maximized != g_last_known_maximized) { - g_last_known_maximized = maximized; - g_window_state_handler(maximized); - } + if (state_changed && g_window_state_handler) { + g_window_state_handler(maximized); + } + if (wparam != SIZE_MINIMIZED && (state_changed || !was_visible) && + ::GetForegroundWindow() == hwnd && window_focus_handler) { + window_focus_handler(); } return result; } @@ -1905,7 +1906,7 @@ extern "C" int linux_window_delete_event(void*, void*, void*) { return dispatch_wm_close(g_close_handler) == CloseDispatch::ConsumedByHandler ? 1 : 0; } -extern "C" int linux_window_state_event(void*, GdkEventWindowStateCompat* event, void*) { +extern "C" int linux_window_state_event(void* window, GdkEventWindowStateCompat* event, void*) { const bool maximized = event && ((event->new_window_state & kGdkWindowStateMaximized) != 0); if (maximized != g_linux_last_known_maximized) { @@ -1914,6 +1915,12 @@ extern "C" int linux_window_state_event(void*, GdkEventWindowStateCompat* event, g_linux_window_state_handler(maximized); } } + if (event && (event->changed_mask & (GDK_WINDOW_STATE_ICONIFIED | GDK_WINDOW_STATE_MAXIMIZED | + GDK_WINDOW_STATE_FULLSCREEN)) && + !(event->new_window_state & GDK_WINDOW_STATE_ICONIFIED) && + gtk_window_is_active(GTK_WINDOW(window)) && window_focus_handler) { + window_focus_handler(); + } return 0; } @@ -1933,6 +1940,7 @@ void install_linux_close_handler(webview::webview& w) { void install_linux_window_state_handler(webview::webview& w) { auto window = w.window(); if (!window.ok() || !window.value()) return; + detail::install_linux_window_focus_handler(w); auto& api = gtk_window_api(); if (!api.load()) return; api.signal_connect_data(window.value(), @@ -2160,16 +2168,10 @@ struct WebHost::Impl { center_on_first_show( startup_mode == StartupWindowMode::OffscreenUntilReady), com(custom_window != nullptr) { - // 三段式构造: - // (1) 默认 Loader 路径,优先 offscreen custom_window;失败 → 切 - // 自管 nullptr 父窗口再试一次(沿用现有降级)。 - // (2) (1) 整段还是抛 → 探测 Edge 浏览器 / EdgeWebView Runtime - // 自带的 msedgewebview2.exe 目录,通过 - // WEBVIEW2_BROWSER_EXECUTABLE_FOLDER 环境变量(WebView2Loader.dll - // 公开的覆盖钩子)指过去再试。 - // (3) Edge fallback 仍失败或没找到 Edge → 抛 WebHostInitializationError - // 给 desktop main。main 还有最后一层 Edge --app= - // 兜底,不能在 WebHost 构造函数里直接 ExitProcess。 + // 优先使用离屏宿主;失败后切为 webview 自有窗口重试。 + // 默认 Loader 仍失败时,探测系统 Edge/WebView Runtime 目录, + // 通过 WEBVIEW2_BROWSER_EXECUTABLE_FOLDER 重新装载。 + // 最后抛 WebHostInitializationError,由 main 的 Edge --app 兜底;禁止直接 ExitProcess。 // WebView2 创建之前把默认打底色钉进环境变量。官方文档:仅靠 // put_DefaultBackgroundColor 属性,首帧之前仍会闪默认白;环境变量是 @@ -2260,6 +2262,7 @@ struct WebHost::Impl { configure_mac_window_chrome(*w); install_mac_edit_menu(*w); mac_focus_observer = install_mac_focus_existing_observer(*w); + mac_window_focus = std::make_unique(*w); mac_enter_fullscreen_observer = install_mac_window_fullscreen_observer( *w, NSWindowDidEnterFullScreenNotification); mac_exit_fullscreen_observer = install_mac_window_fullscreen_observer( @@ -2298,6 +2301,7 @@ struct WebHost::Impl { #endif ~Impl() { + window_focus_handler = {}; #ifdef _WIN32 taskbar_badge.reset(); HWND hwnd = custom_window; @@ -2315,6 +2319,7 @@ struct WebHost::Impl { [[NSDistributedNotificationCenter defaultCenter] removeObserver:mac_focus_observer]; mac_focus_observer = nil; } + mac_window_focus.reset(); if (mac_enter_fullscreen_observer) { [[NSNotificationCenter defaultCenter] removeObserver:mac_enter_fullscreen_observer]; @@ -2363,6 +2368,7 @@ struct WebHost::Impl { #endif #ifdef __APPLE__ id mac_focus_observer = nil; + std::unique_ptr mac_window_focus; id mac_enter_fullscreen_observer = nil; id mac_exit_fullscreen_observer = nil; #endif @@ -2500,19 +2506,17 @@ bool WebHost::focus_after_file_drop() { ::AttachThreadInput(current_thread, foreground_thread, TRUE) != FALSE; set_visible(true); if (attached) ::AttachThreadInput(current_thread, foreground_thread, FALSE); - if (::GetForegroundWindow() != hwnd) return false; - - auto controller_result = impl_->w->browser_controller(); - if (!controller_result.ok()) return false; - auto* controller = static_cast(controller_result.value()); - return controller && SUCCEEDED( - controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC)); + return focus_active_webview(); #else set_visible(true); return true; #endif } +bool WebHost::focus_active_webview() { + return detail::focus_active_webview(*impl_->w); +} + bool WebHost::open_dev_tools() { #ifdef _WIN32 auto controller_result = impl_->w->browser_controller(); @@ -2740,11 +2744,7 @@ void WebHost::set_window_visibility_handler(WindowVisibilityHandler handler) { #endif } void WebHost::set_window_focus_handler(WindowFocusHandler handler) { -#ifdef _WIN32 - g_window_focus_handler = std::move(handler); -#else - (void)handler; -#endif + window_focus_handler = std::move(handler); } void WebHost::set_existing_instance_focus_handler( ExistingInstanceFocusHandler handler) { diff --git a/src/apps/desktop/web_host.hpp b/src/apps/desktop/web_host.hpp index 9e563e149..f977435ad 100644 --- a/src/apps/desktop/web_host.hpp +++ b/src/apps/desktop/web_host.hpp @@ -67,6 +67,9 @@ class WebHost { // Called once when a file drop is accepted, before asynchronous work. // Restore OS keyboard focus without the notification TOPMOST fallback. bool focus_after_file_drop(); + + // Transfer keyboard focus inside the already-active window; never foreground it. + bool focus_active_webview(); acecode::ClipboardPathsReadResult read_clipboard_paths(); // 注入一段 JS 在每次 navigate 前执行(`window.__ACECODE_INITIAL_*` 之类常量 @@ -146,8 +149,8 @@ class WebHost { using WindowVisibilityHandler = std::function; void set_window_visibility_handler(WindowVisibilityHandler handler); - // Windows host activation is not guaranteed to surface as a DOM window.focus - // event in WebView2. Register a native activation callback so the frontend can + // Native activation is not guaranteed to surface as a DOM window.focus + // event. Register a GUI-thread activation callback so the frontend can // explicitly restore the chat composer focus after the Desktop window returns. using WindowFocusHandler = std::function; void set_window_focus_handler(WindowFocusHandler handler); diff --git a/src/apps/desktop/web_host_focus.hpp b/src/apps/desktop/web_host_focus.hpp new file mode 100644 index 000000000..73cb4aa7f --- /dev/null +++ b/src/apps/desktop/web_host_focus.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#ifdef __APPLE__ +#import +#elif !defined(_WIN32) +#include +#endif + +namespace acecode::desktop::detail { + +// Single GUI-thread host callback, cleared before the native window is destroyed. +inline std::function window_focus_handler; + +inline bool focus_active_webview(webview::webview& host) { + auto window = host.window(); + if (!window.ok() || !window.value()) return false; +#ifdef _WIN32 + auto hwnd = static_cast(window.value()); + if (::GetForegroundWindow() != hwnd || ::IsIconic(hwnd) || !::IsWindowVisible(hwnd)) return false; + auto controller_result = host.browser_controller(); + if (!controller_result.ok()) return false; + auto* controller = static_cast(controller_result.value()); + return controller && SUCCEEDED(controller->MoveFocus(COREWEBVIEW2_MOVE_FOCUS_REASON_PROGRAMMATIC)); +#else + auto widget = host.widget(); + if (!widget.ok() || !widget.value()) return false; +#ifdef __APPLE__ + auto* native_window = static_cast(window.value()); + if (![native_window isKeyWindow] || ![native_window isVisible] || [native_window isMiniaturized]) return false; + return [native_window makeFirstResponder:static_cast(widget.value())] == YES; +#else + if (!gtk_window_is_active(GTK_WINDOW(window.value())) || + !gtk_widget_get_mapped(GTK_WIDGET(window.value()))) return false; + gtk_widget_grab_focus(GTK_WIDGET(widget.value())); + return gtk_widget_has_focus(GTK_WIDGET(widget.value())) != FALSE; +#endif +#endif +} + +#ifdef __APPLE__ +inline NSWindow* mac_window_from_host(webview::webview& host) { + auto window = host.window(); + return window.ok() ? static_cast(window.value()) : nil; +} + +class MacWindowFocusObservers { +public: + explicit MacWindowFocusObservers(webview::webview& host) { + auto* window = mac_window_from_host(host); + if (!window) return; + key_ = observe(window, NSWindowDidBecomeKeyNotification); + restore_ = observe(window, NSWindowDidDeminiaturizeNotification); + } + ~MacWindowFocusObservers() { + if (key_) [[NSNotificationCenter defaultCenter] removeObserver:key_]; + if (restore_) [[NSNotificationCenter defaultCenter] removeObserver:restore_]; + } + MacWindowFocusObservers(const MacWindowFocusObservers&) = delete; + MacWindowFocusObservers& operator=(const MacWindowFocusObservers&) = delete; + +private: + static id observe(NSWindow* window, NSNotificationName name) { + return [[NSNotificationCenter defaultCenter] + addObserverForName:name object:window queue:[NSOperationQueue mainQueue] + usingBlock:^(__unused NSNotification* note) { + if ([window isKeyWindow] && window_focus_handler) window_focus_handler(); + }]; + } + id key_ = nil; + id restore_ = nil; +}; +#elif !defined(_WIN32) +inline void install_linux_window_focus_handler(webview::webview& host) { + auto window = host.window(); + if (!window.ok() || !window.value()) return; + // The signal and its stateless callback are destroyed with the GTK window. + g_signal_connect(window.value(), "focus-in-event", + G_CALLBACK(+[](GtkWidget*, GdkEventFocus*, gpointer) -> gboolean { + if (window_focus_handler) window_focus_handler(); + return FALSE; + }), nullptr); +} +#endif + +} // namespace acecode::desktop::detail diff --git a/src/apps/web/handlers/skills_handler.cpp b/src/apps/web/handlers/skills_handler.cpp index d2737ad53..35939a4c4 100644 --- a/src/apps/web/handlers/skills_handler.cpp +++ b/src/apps/web/handlers/skills_handler.cpp @@ -1,4 +1,5 @@ #include "skills_handler.hpp" +#include "config/config_mutation.hpp" #include "skills/skill_init.hpp" #include "skills/skill_activation.hpp" @@ -83,13 +84,63 @@ SkillRootSelection select_skill_root(const fs::path& workspace_cwd, return {global_norm, "global_acecode", global_norm}; } -SkillRootSelection resolve_skill_root_for_cwd(const std::string& workspace_cwd_utf8) { +SkillRootSelection resolve_skill_root_for_cwd(const std::string& workspace_cwd_utf8, + const AppConfig* config) { + if (config && config->skills.scan_paths) { + const auto projects = project_skill_scan_roots(*config, workspace_cwd_utf8); + const auto globals = global_skill_scan_roots(*config); + const fs::path global = globals.empty() ? fs::path{} : globals.front(); + return {projects.empty() ? global : projects.front(), + projects.empty() ? "global_configured" : "project_configured", global}; + } fs::path cwd; if (!workspace_cwd_utf8.empty()) cwd = acecode::path_from_utf8(workspace_cwd_utf8); const auto global_root = acecode::path_from_utf8(get_acecode_dir()) / "skills"; return select_skill_root(cwd, global_root, true); } +nlohmann::json skill_scan_paths_payload(const AppConfig& config) { + const auto defaults = default_skill_scan_paths(config); + return {{"paths", config.skills.scan_paths.value_or(defaults)}, + {"default_paths", defaults}, + {"customized", config.skills.scan_paths.has_value()}}; +} + +SkillToggleResult set_skill_scan_paths(const nlohmann::json& body, + AppConfig& config, + const std::string& config_path) { + auto invalid = [](const char* message) -> SkillToggleResult { + return {false, 400, {{"error", message}}}; + }; + if (!body.is_object() || !body.contains("paths")) { + return invalid("paths (string array or null) required"); + } + std::optional> paths; + if (!body["paths"].is_null()) { + if (!body["paths"].is_array()) return invalid("paths must be a string array or null"); + paths.emplace(); + for (const auto& value : body["paths"]) { + if (!value.is_string()) return invalid("each scan path must be a string"); + const auto raw = value.get(); + if (raw.find_first_of(std::string("\0\r\n", 3)) != std::string::npos) { + return invalid("scan paths must not contain NUL or line breaks"); + } + const auto begin = raw.find_first_not_of(" \t"); + if (begin == std::string::npos) return invalid("scan paths must not be blank"); + paths->push_back(raw.substr(begin, raw.find_last_not_of(" \t") - begin + 1)); + } + } + const auto result = mutate_config([paths](AppConfig& candidate, std::string&) { + if (candidate.skills.scan_paths == paths) return false; + candidate.skills.scan_paths = paths; + return true; + }, config_path, &config); + if (!result.ok) return {false, 500, {{"error", result.error}}}; + // Keep environment/runtime-only settings in the active config untouched. + config.skills.scan_paths = result.config.skills.scan_paths; + return {true, 200, skill_scan_paths_payload(config)}; +} + SkillToggleResult set_skill_enabled(const std::string& name, bool enabled, AppConfig& cfg, @@ -146,7 +197,8 @@ nlohmann::json build_skills_payload_with_roots( const std::vector& disabled_list, const SkillUsageStore* skill_usage, std::int64_t now_epoch_ms, - int idle_days) { + int idle_days, + const std::vector* ordered_roots) { // 扫描时 disabled 置空 —— 禁用中的 skill 也保留完整元数据(描述/来源), // 而不是像旧实现那样只剩一个名字。 std::unordered_set project_root_keys; @@ -156,6 +208,7 @@ nlohmann::json build_skills_payload_with_roots( std::vector roots = project_roots; roots.insert(roots.end(), global_roots.begin(), global_roots.end()); + if (ordered_roots) roots = *ordered_roots; SkillRegistry registry; registry.set_scan_roots(std::move(roots)); @@ -178,7 +231,6 @@ nlohmann::json build_skills_payload_with_roots( } nlohmann::json arr = nlohmann::json::array(); - std::unordered_set listed; for (const auto& s : registry.list()) { nlohmann::json o; o["name"] = s.name; @@ -203,25 +255,6 @@ nlohmann::json build_skills_payload_with_roots( o["pinned"] = false; o["dormant"] = false; } - listed.insert(s.name); - arr.push_back(std::move(o)); - } - - // 幽灵条目:disabled 里残留、但磁盘上已经找不到的名字。仍然列出, - // 让用户能从禁用列表里清掉它。 - for (const auto& name : disabled_list) { - if (listed.count(name)) continue; - nlohmann::json o; - o["name"] = name; - o["command_key"] = name; - o["description"] = ""; - o["category"] = ""; - o["enabled"] = false; - o["source"] = ""; - o["useCount"] = 0; - o["lastUsedAt"] = ""; - o["pinned"] = false; - o["dormant"] = false; arr.push_back(std::move(o)); } return arr; @@ -237,13 +270,14 @@ nlohmann::json build_skills_payload(const AppConfig& cfg, std::chrono::system_clock::now().time_since_epoch()) .count(); } + const auto roots = skill_scan_roots(cfg, workspace_cwd_utf8); return build_skills_payload_with_roots( project_skill_scan_roots(cfg, workspace_cwd_utf8), global_skill_scan_roots(cfg), cfg.skills.disabled, skill_usage, now_epoch_ms, - cfg.skills.idle_days); + cfg.skills.idle_days, &roots); } } // namespace acecode::web diff --git a/src/apps/web/handlers/skills_handler.hpp b/src/apps/web/handlers/skills_handler.hpp index ea48789fd..5e93ca829 100644 --- a/src/apps/web/handlers/skills_handler.hpp +++ b/src/apps/web/handlers/skills_handler.hpp @@ -42,7 +42,13 @@ SkillRootSelection select_skill_root(const std::filesystem::path& workspace_cwd, const std::filesystem::path& global_acecode_skills_root, bool create_missing_roots); -SkillRootSelection resolve_skill_root_for_cwd(const std::string& workspace_cwd_utf8); +SkillRootSelection resolve_skill_root_for_cwd(const std::string& workspace_cwd_utf8, + const AppConfig* config = nullptr); + +nlohmann::json skill_scan_paths_payload(const AppConfig& config); +SkillToggleResult set_skill_scan_paths(const nlohmann::json& body, + AppConfig& config, + const std::string& config_path); // 切换 skill 启停。config_path 空 = 用 save_config 默认路径(~/.acecode/config.json)。 // lookup_registry:可选的"已知性"校验 registry(如某个 workspace 的临时扫描 @@ -64,15 +70,16 @@ std::optional get_skill_body(const std::string& name, // disabled),每条带 // - enabled:name 不在 disabled 中 // - source:"project"(命中 project_roots)/ "global"(其余根) -// disabled 中磁盘上已不存在的名字(幽灵条目)仍以 enabled=false、source="" -// 列出,让用户能把它从禁用列表里放出来。 +// 只列出实际扫描到的技能。disabled 中未扫描到的名字不会生成条目; +// 刷新不清理禁用偏好,以便其他工作区或恢复后的同名技能仍保持禁用。 nlohmann::json build_skills_payload_with_roots( const std::vector& project_roots, const std::vector& global_roots, const std::vector& disabled, const SkillUsageStore* skill_usage = nullptr, std::int64_t now_epoch_ms = 0, - int idle_days = 0); + int idle_days = 0, + const std::vector* ordered_roots = nullptr); // 便捷封装:扫描根取自 skill_init 的 project/global 根构成(与 // initialize_skill_registry 一致),disabled 取自 cfg.skills.disabled。 diff --git a/src/apps/web/routes/routes_desktop_office.cpp b/src/apps/web/routes/routes_desktop_office.cpp index bc00f7ef3..d25d9b3b0 100644 --- a/src/apps/web/routes/routes_desktop_office.cpp +++ b/src/apps/web/routes/routes_desktop_office.cpp @@ -7,7 +7,8 @@ namespace { json office_item(const json& source) { json item = json::object(); for (const auto* key : {"id", "active", "status", "workspace_hash", "workspace_name", - "parent_session_id", "agent_path", "archived", "no_workspace", "title", "summary", + "parent_session_id", "agent_path", "expert_id", "expert_member_id", "expert", + "archived", "no_workspace", "title", "summary", "last_user_message_at", "last_turn_outcome", "updated_at", "context_window", "token_usage"}) { if (source.contains(key)) item[key] = source[key]; } diff --git a/src/apps/web/routes/routes_files.cpp b/src/apps/web/routes/routes_files.cpp index ae88f4e44..054db4d13 100644 --- a/src/apps/web/routes/routes_files.cpp +++ b/src/apps/web/routes/routes_files.cpp @@ -414,6 +414,43 @@ void WebServer::Impl::register_files() { } void WebServer::Impl::register_skills() { + CROW_ROUTE(app, "/api/config/skill-paths").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/config/skill-paths").methods(crow::HTTPMethod::GET) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (!deps.app_config) return with_cors(req, crow::response(503)); + std::shared_lock lock(app_config_mu); + crow::response response(skill_scan_paths_payload(*deps.app_config).dump()); + response.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(response)); + }); + + CROW_ROUTE(app, "/api/config/skill-paths").methods(crow::HTTPMethod::PUT) + ([this](const crow::request& req) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (auto rej = reject_if_migrating(req)) return std::move(*rej); + if (!deps.app_config) return with_cors(req, crow::response(503)); + const auto body = json::parse(req.body, nullptr, false); + std::lock_guard lock(app_config_mu); + auto result = set_skill_scan_paths(body, *deps.app_config, deps.config_path); + if (result.ok) { + if (deps.skill_registry) { + const auto workspace = compatibility_workspace(); + initialize_skill_registry(*deps.skill_registry, *deps.app_config, + workspace.cwd); + } + if (deps.session_registry) { + deps.session_registry->refresh_skill_policy(*deps.app_config); + } + } + crow::response response(result.http_status); + response.body = result.body.dump(); + response.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(response)); + }); + CROW_ROUTE(app, "/api/skills/root").methods(crow::HTTPMethod::Options) ([this](const crow::request& req) { return cors_preflight(req); @@ -438,7 +475,8 @@ void WebServer::Impl::register_skills() { ws = compatibility_workspace(); } - auto selected = resolve_skill_root_for_cwd(ws->cwd); + std::shared_lock lock(app_config_mu); + auto selected = resolve_skill_root_for_cwd(ws->cwd, deps.app_config); json body{ {"path", path_to_utf8(selected.path)}, {"source", selected.source}, diff --git a/src/apps/web/routes/routes_misc.cpp b/src/apps/web/routes/routes_misc.cpp index 3bfd0c431..e690a0c65 100644 --- a/src/apps/web/routes/routes_misc.cpp +++ b/src/apps/web/routes/routes_misc.cpp @@ -825,12 +825,19 @@ void WebServer::Impl::register_static() { auto qpos = path.find('?'); if (qpos != std::string::npos) path.resize(qpos); + const bool gzip = accepts_gzip(req.get_header_value("Accept-Encoding")); + const auto encoding_headers = [](crow::response& response, const AssetLookupResult& asset) { + if (asset.vary_accept_encoding) response.add_header("Vary", "Accept-Encoding"); + if (asset.gzip) response.add_header("Content-Encoding", "gzip"); + }; + if (!path.empty()) { - auto r = assets->lookup(path); + auto r = assets->lookup(path, gzip); if (r.has_value()) { crow::response resp(200); resp.body.assign(reinterpret_cast(r->data), r->size); resp.add_header("Content-Type", r->content_type); + encoding_headers(resp, *r); if (req.url_params.get("v")) { resp.add_header("Cache-Control", "public, max-age=31536000, immutable"); } else { @@ -842,7 +849,7 @@ void WebServer::Impl::register_static() { } // SPA fallback: 任何不存在的资源都返回 index.html(让前端 hash 路由处理) - auto idx = assets->lookup("index.html"); + auto idx = assets->lookup("index.html", gzip); if (!idx.has_value()) { crow::response resp(503); resp.body = "index.html missing — front-end not bundled"; @@ -851,6 +858,7 @@ void WebServer::Impl::register_static() { crow::response resp(200); resp.body.assign(reinterpret_cast(idx->data), idx->size); resp.add_header("Content-Type", idx->content_type); + encoding_headers(resp, *idx); resp.add_header("Cache-Control", "no-cache"); resp.add_header("Referrer-Policy", "no-referrer"); return resp; diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index ece7bad1c..c51525eee 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -22,6 +22,21 @@ namespace acecode::web { using nlohmann::json; +namespace { +void append_expert_avatar(json& presentation, const ExpertDefinition& expert, + const std::string& member_id, const std::string& workspace, + const ExpertRegistry& registry, const std::string& cwd) { + presentation["avatar_identity"] = expert.avatar_identity(member_id); + std::optional referenced; + if (expert.references_existing_experts && (!member_id.empty() || expert.avatar_path.empty())) { + referenced = registry.find(cwd, member_id.empty() ? expert.lead_expert_id : member_id); + } + const auto& portrait = referenced ? *referenced : expert; + presentation["avatar_url"] = portrait.avatar_path.empty() ? std::string{} + : "/api/experts/" + portrait.id + "/avatar?workspace=" + workspace; +} +} // namespace + // ===================================================================== // Free functions (formerly anonymous-namespace, now shared across route TUs) // ===================================================================== @@ -923,12 +938,15 @@ json WebServer::Impl::session_info_to_json(const SessionInfo& s, const SessionMe expert_json["display_name"] = s.expert_display_name; expert_json["type"] = s.expert_type; expert_json["source"] = s.expert_source; - } else if (deps.expert_registry && m) { - if (auto resolved = deps.expert_registry->find(m->cwd, expert_id)) { + } + if (deps.expert_registry) { + if (auto resolved = deps.expert_registry->find(storage_cwd, expert_id)) { expert_json["display_name"] = resolved->display_name; expert_json["type"] = to_string(resolved->type); expert_json["source"] = resolved->source; expert_json["missing"] = false; + append_expert_avatar(expert_json, *resolved, expert_member_id, + workspace_hash, *deps.expert_registry, storage_cwd); } else { expert_json["missing"] = true; } @@ -995,6 +1013,8 @@ json WebServer::Impl::session_meta_to_json(const SessionMeta& m, const std::stri expert_json["type"] = to_string(resolved->type); expert_json["source"] = resolved->source; expert_json["missing"] = false; + append_expert_avatar(expert_json, *resolved, m.expert_member_id, + workspace_hash, *deps.expert_registry, m.cwd); } } o["expert"] = std::move(expert_json); diff --git a/src/apps/web/static_assets.cpp b/src/apps/web/static_assets.cpp index e32269542..f82e23352 100644 --- a/src/apps/web/static_assets.cpp +++ b/src/apps/web/static_assets.cpp @@ -5,7 +5,9 @@ #include #include +#include #include +#include namespace acecode::web { @@ -47,8 +49,66 @@ bool ends_with(const std::string& s, const std::string& suffix) { return std::equal(suffix.rbegin(), suffix.rend(), s.rbegin()); } +std::string_view trim(std::string_view value) { + const auto first = value.find_first_not_of(" \t"); + if (first == std::string_view::npos) return {}; + return value.substr(first, value.find_last_not_of(" \t") - first + 1); +} + +bool ascii_equal(std::string_view a, std::string_view b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; }; + if (lower(a[i]) != lower(b[i])) return false; + } + return true; +} + +bool positive_quality(std::string_view value) { + value = trim(value); + if (value.empty() || (value[0] != '0' && value[0] != '1')) return false; + bool positive = value[0] == '1'; + if (value.size() == 1) return positive; + if (value[1] != '.' || value.size() > 5) return false; + for (std::size_t i = 2; i < value.size(); ++i) { + if (value[i] < '0' || value[i] > '9' || (value[0] == '1' && value[i] != '0')) return false; + positive = positive || value[i] != '0'; + } + return positive; +} + } // namespace +bool accepts_gzip(std::string_view header) { + std::optional gzip, wildcard; + while (!header.empty()) { + const auto comma = header.find(','); + auto item = trim(header.substr(0, comma)); + const auto semi = item.find(';'); + const auto name = trim(item.substr(0, semi)); + bool allowed = true; + if (semi != std::string_view::npos) { + auto parameters = item.substr(semi + 1); + while (!parameters.empty()) { + const auto next = parameters.find(';'); + const auto parameter = trim(parameters.substr(0, next)); + const auto equals = parameter.find('='); + if (ascii_equal(trim(parameter.substr(0, equals)), "q")) { + allowed = allowed && equals != std::string_view::npos && + positive_quality(parameter.substr(equals + 1)); + } + if (next == std::string_view::npos) break; + parameters.remove_prefix(next + 1); + } + } + if (ascii_equal(name, "gzip")) gzip = allowed && gzip.value_or(true); + else if (name == "*") wildcard = allowed && wildcard.value_or(true); + if (comma == std::string_view::npos) break; + header.remove_prefix(comma + 1); + } + return gzip.value_or(wildcard.value_or(false)); +} + std::string mime_type_for_path(const std::string& path) { for (const auto& row : kMimeTable) { if (ends_with(path, row.ext)) return row.mime; @@ -56,18 +116,54 @@ std::string mime_type_for_path(const std::string& path) { return "application/octet-stream"; } -std::optional EmbeddedAssetSource::lookup(const std::string& path) const { +std::optional EmbeddedAssetSource::lookup(const std::string& path, + bool accept_gzip) const { const auto& m = embedded_asset_map(); auto it = m.find(path); if (it == m.end()) return std::nullopt; AssetLookupResult r; r.content_type = mime_type_for_path(path); - r.data = it->second.first; - r.size = it->second.second; + const auto& asset = it->second; + r.vary_accept_encoding = asset.gzip; + r.gzip = asset.gzip && accept_gzip; + r.data = asset.data; + r.size = asset.size; + if (asset.gzip && !accept_gzip) { + std::lock_guard lock(decoded_mutex_); + auto cached = decoded_.find(path); + if (cached == decoded_.end()) { + if (asset.size > std::numeric_limits::max() || + asset.decoded_size >= std::numeric_limits::max()) { + throw std::runtime_error("embedded asset exceeds decompression limit"); + } + auto decoded = std::make_shared(asset.decoded_size + 1, '\0'); + z_stream stream{}; + stream.next_in = const_cast(asset.data); + stream.avail_in = static_cast(asset.size); + stream.next_out = reinterpret_cast(decoded->data()); + stream.avail_out = static_cast(decoded->size()); + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + throw std::runtime_error("cannot initialize embedded asset decompression"); + } + // Own zlib's internal allocation; the stream itself has stack storage. + const auto release = [](z_stream* state) { inflateEnd(state); }; + const std::unique_ptr cleanup(&stream, release); + const int result = inflate(&stream, Z_FINISH); + const bool valid = result == Z_STREAM_END && stream.total_in == asset.size && + stream.total_out == asset.decoded_size; + if (!valid) throw std::runtime_error("corrupt compressed embedded asset: " + path); + decoded->resize(asset.decoded_size); + cached = decoded_.emplace(path, std::move(decoded)).first; + } + r.owned_buffer = cached->second; + r.data = reinterpret_cast(r.owned_buffer->data()); + r.size = r.owned_buffer->size(); + } return r; } -std::optional FileSystemAssetSource::lookup(const std::string& path) const { +std::optional FileSystemAssetSource::lookup(const std::string& path, + bool /*accept_gzip*/) const { fs::path full = path_from_utf8(root_) / path_from_utf8(path); // 安全:确保 full 在 root_ 之下(防 ".." 穿越)。weakly_canonical 可能解析失败, diff --git a/src/apps/web/static_assets.hpp b/src/apps/web/static_assets.hpp index d6fcf305e..11bd58e60 100644 --- a/src/apps/web/static_assets.hpp +++ b/src/apps/web/static_assets.hpp @@ -13,8 +13,10 @@ #include #include #include +#include #include #include +#include #include namespace acecode::web { @@ -23,9 +25,10 @@ struct AssetLookupResult { std::string content_type; const unsigned char* data = nullptr; std::size_t size = 0; - // 当 source = FileSystem 时,持有一份从磁盘读出来的 buffer 用于保活。 - // EmbeddedAssetSource 不需要,data 直接指向静态数组。 - std::shared_ptr owned_buffer; + bool gzip = false; + bool vary_accept_encoding = false; + // Shared by the source cache and in-flight responses; immutable after publication. + std::shared_ptr owned_buffer; }; class AssetSource { @@ -33,26 +36,40 @@ class AssetSource { virtual ~AssetSource() = default; // path 是相对路径(如 "index.html" / "components/ace-app.js"), // 不带 leading "/"。Caller 调用前已剥掉 "/static/" 前缀。 - virtual std::optional lookup(const std::string& path) const = 0; + virtual std::optional lookup(const std::string& path, + bool accept_gzip = false) const = 0; }; class EmbeddedAssetSource : public AssetSource { public: - std::optional lookup(const std::string& path) const override; + std::optional lookup(const std::string& path, + bool accept_gzip = false) const override; +private: + mutable std::mutex decoded_mutex_; + // Shared immutable decoded assets are retained for identity-only clients. + mutable std::map> decoded_; }; class FileSystemAssetSource : public AssetSource { public: explicit FileSystemAssetSource(std::string root) : root_(std::move(root)) {} - std::optional lookup(const std::string& path) const override; + std::optional lookup(const std::string& path, + bool accept_gzip = false) const override; private: std::string root_; }; // 由 build 期生成的 static_assets_data.cpp 提供。tests 也可以引用它直接验证 // 嵌入完整性。 -const std::map>& - embedded_asset_map(); +struct EmbeddedAsset { + const unsigned char* data; + std::size_t size; + std::size_t decoded_size; + bool gzip; +}; +const std::map& embedded_asset_map(); + +bool accepts_gzip(std::string_view accept_encoding); // 工厂:根据 web.static_dir 决定走嵌入还是磁盘。空 = 嵌入。non-empty + // 路径不存在 → 抛 std::runtime_error,worker.cpp 启动期捕获后 fail-fast。 diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index e67bb3594..5448ddd79 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -926,6 +926,11 @@ static AppConfig load_config_from_path_once( } if (j.contains("skills") && j["skills"].is_object()) { const auto& sj = j["skills"]; + if (sj.contains("scan_paths") && sj["scan_paths"].is_array() && + std::all_of(sj["scan_paths"].begin(), sj["scan_paths"].end(), + [](const auto& value) { return value.is_string(); })) { + cfg.skills.scan_paths = sj["scan_paths"].get>(); + } if (sj.contains("disabled") && sj["disabled"].is_array()) { for (const auto& v : sj["disabled"]) { if (v.is_string()) cfg.skills.disabled.push_back(v.get()); @@ -2259,12 +2264,13 @@ nlohmann::json build_config_json(const AppConfig& cfg) { } SkillsConfig skills_d; - if (!cfg.skills.disabled.empty() || + if (cfg.skills.scan_paths || !cfg.skills.disabled.empty() || !cfg.skills.external_dirs.empty() || cfg.skills.reuse_opencode != skills_d.reuse_opencode || cfg.skills.idle_days != skills_d.idle_days) { nlohmann::json sj = nlohmann::json::object(); if (!cfg.skills.disabled.empty()) sj["disabled"] = cfg.skills.disabled; + if (cfg.skills.scan_paths) sj["scan_paths"] = *cfg.skills.scan_paths; if (!cfg.skills.external_dirs.empty()) sj["external_dirs"] = cfg.skills.external_dirs; if (cfg.skills.reuse_opencode != skills_d.reuse_opencode) sj["reuse_opencode"] = cfg.skills.reuse_opencode; diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index 10f6a490c..96d0ca45f 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -69,6 +69,8 @@ struct McpServerConfig { }; struct SkillsConfig { + // Missing/null preserves legacy roots; an explicit array replaces them. + std::optional> scan_paths; std::vector disabled; // skill names to hide even if present on disk std::vector external_dirs; // extra directories to scan (supports ~ and ${ENV}) bool reuse_opencode = true; // reuse opencode-compatible skill roots by default diff --git a/src/domain/experts/expert_identity.cpp b/src/domain/experts/expert_identity.cpp new file mode 100644 index 000000000..9c566d380 --- /dev/null +++ b/src/domain/experts/expert_identity.cpp @@ -0,0 +1,19 @@ +#include "expert_registry.hpp" + +namespace acecode { + +const ExpertAgent* ExpertDefinition::selected_agent(const std::string& member_id) const { + return agent(member_id.empty() ? lead_agent_id : member_id); +} + +std::string ExpertDefinition::avatar_identity(const std::string& member_id) const { + if (references_existing_experts) { + return "expert:" + (member_id.empty() ? lead_expert_id : member_id); + } + if (type == ExpertType::Team) { + return "expert:" + id + "/" + (member_id.empty() ? lead_agent_id : member_id); + } + return "expert:" + id; +} + +} // namespace acecode diff --git a/src/domain/experts/expert_registry.cpp b/src/domain/experts/expert_registry.cpp index 5faa83190..0fdfc30d4 100644 --- a/src/domain/experts/expert_registry.cpp +++ b/src/domain/experts/expert_registry.cpp @@ -1450,10 +1450,6 @@ const ExpertAgent* ExpertDefinition::agent(const std::string& agent_id) const { return it == agents.end() ? nullptr : &*it; } -const ExpertAgent* ExpertDefinition::selected_agent(const std::string& member_id) const { - return agent(member_id.empty() ? lead_agent_id : member_id); -} - std::vector ExpertDefinition::selected_skill_roots( const std::string& member_id) const { std::vector roots = skill_roots; @@ -1841,12 +1837,14 @@ json expert_definition_to_json(const ExpertDefinition& expert, {"id", agent.id}, {"display_name", agent.display_name}, {"profession", agent.profession}, + {"avatar_identity", expert.avatar_identity(agent.id)}, }; if (include_instructions) item["instructions"] = agent.instructions; agents.push_back(std::move(item)); } json result = { {"id", expert.id}, + {"avatar_identity", expert.avatar_identity()}, {"version", expert.version}, {"type", to_string(expert.type)}, {"display_name", expert.display_name}, diff --git a/src/domain/experts/expert_registry.hpp b/src/domain/experts/expert_registry.hpp index eab316441..277a0c3ea 100644 --- a/src/domain/experts/expert_registry.hpp +++ b/src/domain/experts/expert_registry.hpp @@ -91,6 +91,7 @@ struct ExpertDefinition { const ExpertAgent* agent(const std::string& agent_id) const; const ExpertAgent* selected_agent(const std::string& member_id = {}) const; + std::string avatar_identity(const std::string& member_id = {}) const; std::vector selected_skill_roots( const std::string& member_id = {}) const; ExpertCapabilityScopes selected_capabilities( diff --git a/src/domain/skills/skill_init.cpp b/src/domain/skills/skill_init.cpp index 3e6198eb9..350165ace 100644 --- a/src/domain/skills/skill_init.cpp +++ b/src/domain/skills/skill_init.cpp @@ -18,6 +18,57 @@ namespace { namespace fs = std::filesystem; +// Expand before determining scope, so ${ENV} can name an absolute global root. +// Unknown variables must not accidentally become relative workspace paths. +std::optional expanded_scan_path(const std::string& raw) { + std::string expanded; + for (std::size_t pos = 0; pos < raw.size();) { + if (raw.compare(pos, 2, "${") != 0) { + expanded += raw[pos++]; + continue; + } + const auto end = raw.find('}', pos + 2); + if (end == std::string::npos) return std::nullopt; + const auto name = raw.substr(pos + 2, end - pos - 2); + std::string value; + if (name == "ACECODE_DATA_DIR") value = get_acecode_dir(); + else value = getenv_utf8(name.c_str()); + if (value.empty() && name == "XDG_CONFIG_HOME") value = expand_path("~/.config"); + if (value.empty() && name == "XDG_CACHE_HOME") value = expand_path("~/.cache"); + if (value.empty()) return std::nullopt; + expanded += value; + pos = end + 1; + } + expanded = expand_path(expanded); + if (expanded.empty() || expanded.front() == '~' || + expanded.find("${") != std::string::npos) return std::nullopt; + return path_from_utf8(expanded); +} + +std::vector configured_scan_roots( + const AppConfig& config, const std::string& working_dir, + std::optional project_only = std::nullopt) { + std::vector roots; + const auto dirs = get_project_dirs_up_to_home(working_dir); + for (const auto& raw : *config.skills.scan_paths) { + const auto path = expanded_scan_path(raw); + if (!path) continue; + const bool project = !path->is_absolute(); + if (project_only && project != *project_only) continue; + if (project) { + // Reject drive-relative/root-relative paths on Windows: they depend + // on process state, not the selected workspace. + if (path->has_root_path()) continue; + for (const auto& dir : dirs) { + roots.push_back((path_from_utf8(dir) / *path).lexically_normal()); + } + } else { + roots.push_back(path->lexically_normal()); + } + } + return roots; +} + std::string env_or_expanded_path(const char* name, const std::string& fallback) { std::string value = getenv_utf8(name); if (!value.empty()) return value; @@ -61,8 +112,54 @@ void append_opencode_global_skill_roots(std::vector& roots) { } // namespace +std::vector default_skill_scan_paths(const AppConfig& config) { + std::vector paths = {".acecode/skills", ".agent/skills"}; + if (config.skills.reuse_opencode) { + for (const char* path : {".opencode/skills", ".opencode/skill", + ".agents/skills", ".claude/skills"}) { + paths.emplace_back(path); + } + } + paths.emplace_back("${ACECODE_DATA_DIR}/skills"); + paths.emplace_back("~/.agent/skills"); + if (config.skills.reuse_opencode) { + for (const char* path : {"${XDG_CONFIG_HOME}/opencode/skills", + "${XDG_CONFIG_HOME}/opencode/skill", + "~/.opencode/skills", "~/.opencode/skill", + "${OPENCODE_CONFIG_DIR}/skills", + "${OPENCODE_CONFIG_DIR}/skill", + "~/.agents/skills", "~/.claude/skills", + "${XDG_CACHE_HOME}/opencode/skills"}) { + paths.emplace_back(path); + } + } + for (const auto& raw : config.skills.external_dirs) { + // Legacy external dirs are global, even when written relatively. + const auto expanded = expand_path(raw); + if (expanded.empty()) continue; + auto path = path_from_utf8(expanded); + if (path.is_relative()) { + std::error_code ec; + const auto absolute = fs::absolute(path, ec); + if (!ec) path = absolute; + } + paths.push_back(path_to_utf8(path)); + } + return paths; +} + +std::vector skill_scan_roots( + const AppConfig& config, const std::string& working_dir) { + if (config.skills.scan_paths) return configured_scan_roots(config, working_dir); + auto roots = project_skill_scan_roots(config, working_dir); + auto global = global_skill_scan_roots(config); + roots.insert(roots.end(), global.begin(), global.end()); + return roots; +} + std::vector project_skill_scan_roots( const AppConfig& config, const std::string& working_dir) { + if (config.skills.scan_paths) return configured_scan_roots(config, working_dir, true); std::vector roots; for (const auto& dir : get_project_dirs_up_to_home(working_dir)) { @@ -80,6 +177,7 @@ std::vector project_skill_scan_roots( } std::vector global_skill_scan_roots(const AppConfig& config) { + if (config.skills.scan_paths) return configured_scan_roots(config, {}, false); std::vector roots; roots.emplace_back(path_from_utf8(get_acecode_dir()) / "skills"); @@ -106,8 +204,9 @@ void initialize_skill_registry(SkillRegistry& skill_registry, std::error_code ec; std::vector roots = prepended_roots; - auto project_roots = project_skill_scan_roots(config, working_dir); - for (auto& root : project_roots) roots.emplace_back(std::move(root)); + for (auto& root : skill_scan_roots(config, working_dir)) { + roots.emplace_back(std::move(root)); + } // ~/.acecode/skills 是历史上自动创建的全局根,保持行为不变。 std::filesystem::path default_acecode_skills_dir = @@ -116,10 +215,6 @@ void initialize_skill_registry(SkillRegistry& skill_registry, std::filesystem::create_directories(default_acecode_skills_dir, ec); } - for (auto& root : global_skill_scan_roots(config)) { - roots.emplace_back(std::move(root)); - } - std::optional> effective_allowed; std::unordered_set effective_disabled( config.skills.disabled.begin(), config.skills.disabled.end()); diff --git a/src/domain/skills/skill_init.hpp b/src/domain/skills/skill_init.hpp index 0e7f86094..2d645fcc2 100644 --- a/src/domain/skills/skill_init.hpp +++ b/src/domain/skills/skill_init.hpp @@ -3,7 +3,7 @@ // 共享 SkillRegistry 初始化逻辑:扫描根目录顺序与 main.cpp 历史实现保持一致, // 让 TUI(main.cpp)与 daemon(worker.cpp)看到同一组 skills。 // -// 扫描根顺序(first-wins by skill name): +// 未配置 skills.scan_paths 时的扫描根顺序(first-wins by skill name): // 1) project walk — /.acecode/skills,深度优先回溯到 HOME // 2) project walk — /.agent/skills,深度优先回溯到 HOME // 3) opencode project compat roots — when config.skills.reuse_opencode is true @@ -22,6 +22,14 @@ namespace acecode { class SkillRegistry; struct AppConfig; +// Editable default templates (legacy compatibility flags/external dirs apply). +std::vector default_skill_scan_paths(const AppConfig& config); + +// Ordered ordinary roots. Explicit scan_paths replaces all legacy roots; +// relative paths expand along the workspace chain, absolute paths are global. +std::vector skill_scan_roots( + const AppConfig& config, const std::string& working_dir); + void initialize_skill_registry(SkillRegistry& skill_registry, const AppConfig& config, const std::string& working_dir, @@ -30,12 +38,13 @@ void initialize_skill_registry(SkillRegistry& skill_registry, const std::optional>& expert_allowed = std::nullopt); -// 项目链扫描根(上表 1-3 段):working_dir 沿目录树向上(不含 HOME 本身)。 +// 项目链扫描根(默认上表 1-3 段,自定义时为相对路径条目): +// working_dir 沿目录树向上(不含 HOME 本身)。 // 命中这些根的 skill 在 web UI 里归类为「项目技能」。纯枚举,不创建目录。 std::vector project_skill_scan_roots( const AppConfig& config, const std::string& working_dir); -// 全局扫描根(上表 4-7 段):用户全局目录 + opencode 全局兼容根 + external_dirs。 +// 全局扫描根(默认上表 4-7 段,自定义时为绝对路径条目)。 // 纯枚举,不创建目录(~/.acecode/skills 的自动创建留在 initialize_skill_registry)。 std::vector global_skill_scan_roots(const AppConfig& config); diff --git a/src/host/channels/core/commands.cpp b/src/host/channels/core/commands.cpp index 4f8fab464..9cb5e728c 100644 --- a/src/host/channels/core/commands.cpp +++ b/src/host/channels/core/commands.cpp @@ -1,5 +1,7 @@ #include "commands.hpp" +#include "config/vocab/permission_mode.hpp" + #include #include @@ -35,6 +37,9 @@ Command parse_command(const std::string& raw) { else if (name == "/sessions" || name == "/session") command.kind = CommandKind::Sessions; else if (name == "/resume") command.kind = CommandKind::Resume; else if (name == "/model" || name == "/models") command.kind = CommandKind::Model; + else if (name == "/mode") command.kind = CommandKind::Mode; + else if (name == "/yolo") command.kind = CommandKind::Yolo; + else if (name == "/effort") command.kind = CommandKind::Effort; else if (name == "/approve") command.kind = CommandKind::Approve; else if (name == "/deny") command.kind = CommandKind::Deny; else if (name == "/aq") command.kind = CommandKind::Question; @@ -50,6 +55,21 @@ Command parse_command(const std::string& raw) { if (lowered != "more" && lowered != "all" && lowered.rfind("search ", 0) != 0) command.error = "用法:/sessions、/sessions more 或 /sessions search <关键词>。"; } + if (command.kind == CommandKind::Mode && !command.argument.empty()) { + auto mode = parse_permission_mode_name(command.argument); + if (!mode) mode = parse_permission_mode_name(lower(command.argument)); + if (!mode || *mode == PermissionMode::Plan) + command.error = "用法:/mode [default|auto|yolo]。分别为默认权限、自动模式、完全访问权限。"; + else + command.argument = permission_mode_name(*mode); + } + if (command.kind == CommandKind::Yolo && !command.argument.empty()) + command.error = "用法:/yolo。直接切换到完全访问权限,无需参数。"; + if (command.kind == CommandKind::Effort) { + command.argument = lower(command.argument); + if (command.argument.find_first_of(" \t\r\n") != std::string::npos) + command.error = "用法:/effort [档位|default]。发送 /effort 查看当前模型支持的档位。"; + } return command; } @@ -66,6 +86,9 @@ std::string help(bool owner) { "/sessions 列出可切换的会话(more 查看更多,search <关键词> 搜索)\n" "/resume <编号或会话 ID> 切换到某个会话\n" "/model [名称] 查看或切换模型\n" + "/mode [default|auto|yolo] 查看或切换默认权限、自动模式、完全访问权限\n" + "/yolo 直接切换到完全访问权限(跳过工具权限确认)\n" + "/effort [档位|default] 查看或切换思考深度,default 恢复模型预设\n" "/approve <编号>、/deny <编号> 处理权限请求\n" "/aq 回答提问\n" "其它文字会直接发给当前会话。"; diff --git a/src/host/channels/core/commands.hpp b/src/host/channels/core/commands.hpp index adc264ecb..6560471b3 100644 --- a/src/host/channels/core/commands.hpp +++ b/src/host/channels/core/commands.hpp @@ -8,7 +8,9 @@ namespace acecode::channels::core { -enum class CommandKind { None, Help, Status, Stop, New, Sessions, Resume, Model, Approve, Deny, Question }; +enum class CommandKind { + None, Help, Status, Stop, New, Sessions, Resume, Model, Mode, Yolo, Effort, Approve, Deny, Question +}; struct Command { CommandKind kind = CommandKind::None; diff --git a/src/host/channels/core/conversations.cpp b/src/host/channels/core/conversations.cpp index 4a49e4f7d..762b2bd31 100644 --- a/src/host/channels/core/conversations.cpp +++ b/src/host/channels/core/conversations.cpp @@ -53,6 +53,9 @@ const char* command_name(CommandKind kind) { case CommandKind::Sessions: return "/sessions"; case CommandKind::Resume: return "/resume"; case CommandKind::Model: return "/model"; + case CommandKind::Mode: return "/mode"; + case CommandKind::Yolo: return "/yolo"; + case CommandKind::Effort: return "/effort"; case CommandKind::Approve: return "/approve"; case CommandKind::Deny: return "/deny"; case CommandKind::Question: return "/aq"; @@ -61,6 +64,33 @@ const char* command_name(CommandKind kind) { return "(text)"; } +std::string permission_label(const std::string& mode) { + if (mode == "default") return "默认权限 (default)"; + if (mode == "auto" || mode == "accept-edits") return "自动模式 (auto)"; + if (mode == "yolo") return "完全访问权限 (yolo)"; + if (mode == "plan") return "计划模式 (plan)"; + return mode; +} + +std::string effort_status(const std::optional& state, bool list_options) { + if (!state || state->deleted) return "思考深度:模型不可用"; + if (!state->reasoning || !state->reasoning->supported || + !(state->reasoning->mandatory || state->reasoning->enabled.value_or(state->reasoning->default_enabled)) || + state->reasoning->supported_efforts.empty() || + (state->provider != "openai" && state->provider != "anthropic")) + return "思考深度:当前模型未启用或不支持 effort 档位"; + const auto& reasoning = *state->reasoning; + std::string text = "思考深度:" + state->reasoning_effort.value_or( + reasoning.effort.value_or(reasoning.default_effort.value_or("默认"))); + if (!state->reasoning_effort) text += " (模型预设)"; + if (list_options) { + text += "\n可用档位:"; + for (const auto& effort : reasoning.supported_efforts) text += " " + effort; + text += "\n用法:/effort <档位>,/effort default 恢复模型预设。"; + } + return text; +} + } // namespace Conversations::Conversations(std::string platform, ChannelStore& store, AccessControl& access, @@ -184,6 +214,8 @@ void Conversations::handle_bound_command(const im::Inbound& inbound, const Comma location_label(*record) + "\n模型:" + (info && !info->model_name.empty() ? info->model_name : std::string("默认")) + "\n状态:" + (info && info->busy ? "执行中" : "空闲"); + text += "\n权限模式:" + (info ? permission_label(info->permission_mode) : "不可用"); + text += "\n" + effort_status(deps_.model_state ? deps_.model_state(id) : std::nullopt, false); const auto permissions = projection->pending_permission_ids(); for (const auto& request : permissions) text += "\n待确认权限:" + request; for (const auto& line : projection->pending_question_texts()) text += "\n" + line; @@ -212,6 +244,41 @@ void Conversations::handle_bound_command(const im::Inbound& inbound, const Comma reply(inbound, texts::model_switched(command.argument)); return; } + case CommandKind::Mode: + case CommandKind::Yolo: { + if (command.kind == CommandKind::Mode && command.argument.empty()) { + const auto info = session_info(id); + reply(inbound, "权限模式:" + (info ? permission_label(info->permission_mode) : "不可用") + + "\n用法:/mode default|auto|yolo,或 /yolo 直接切换到完全访问权限。"); + return; + } + const auto mode = command.kind == CommandKind::Yolo ? std::string("yolo") : command.argument; + if (!deps_.set_permission_mode || !deps_.set_permission_mode(id, mode)) { + reply(inbound, texts::turn_failed("切换权限模式失败")); + return; + } + reply(inbound, "已切换权限模式为 " + permission_label(mode) + "。"); + return; + } + case CommandKind::Effort: { + if (command.argument.empty()) { + reply(inbound, effort_status(deps_.model_state ? deps_.model_state(id) : std::nullopt, true)); + return; + } + const auto effort = command.argument == "default" ? std::nullopt + : std::optional(command.argument); + std::string error; + const auto updated = deps_.set_reasoning_effort ? deps_.set_reasoning_effort(id, effort, &error) + : std::nullopt; + if (!updated) { + reply(inbound, texts::turn_failed(error.empty() ? "切换思考深度失败" : error) + "\n" + + effort_status(deps_.model_state ? deps_.model_state(id) : std::nullopt, true)); + return; + } + reply(inbound, std::string(effort ? "已更新思考深度。" : "已恢复模型预设思考深度。") + + "从下一次模型请求起生效。\n" + effort_status(updated, false)); + return; + } case CommandKind::Approve: case CommandKind::Deny: { if (!projection->take_permission(command.argument)) { diff --git a/src/host/channels/core/conversations.hpp b/src/host/channels/core/conversations.hpp index c0f2eb0fa..9bd7c1af0 100644 --- a/src/host/channels/core/conversations.hpp +++ b/src/host/channels/core/conversations.hpp @@ -35,6 +35,11 @@ struct ConversationDeps { std::function resume_target; std::function()> model_names; std::function switch_model; + std::function set_permission_mode; + std::function(const std::string& session_id)> model_state; + std::function(const std::string& session_id, + const std::optional& effort, + std::string* error)> set_reasoning_effort; // 普通文本提交前做技能命令展开(与 Web 输入一致);返回展开后的文本。 std::function expand_skill; // 某会话即将被 taker 接管:host 让所有平台释放它(跨平台转移绑定)。 diff --git a/src/host/session_host/thread_presentation.cpp b/src/host/session_host/thread_presentation.cpp new file mode 100644 index 000000000..90564825e --- /dev/null +++ b/src/host/session_host/thread_presentation.cpp @@ -0,0 +1,88 @@ +#include "thread_presentation.hpp" +#include "session/session_client.hpp" +#include "session/session_storage.hpp" +#include "utils/encoding.hpp" + +#include + +namespace acecode::thread_presentation { + +using nlohmann::json; + +namespace { constexpr std::size_t kSummaryChars = 240; } + +std::string trim_and_limit(std::string value, + std::size_t max_chars, + bool* truncated) { + while (!value.empty() && + std::isspace(static_cast(value.front()))) { + value.erase(value.begin()); + } + while (!value.empty() && + std::isspace(static_cast(value.back()))) { + value.pop_back(); + } + const std::string limited = truncate_utf8_prefix(value, max_chars); + if (truncated) *truncated = limited.size() < value.size(); + return limited; +} + +json thread_summary(const SessionMeta& meta, + const SessionInfo* active, + bool pinned, + int pinned_index) { + const bool is_active = active != nullptr; + const bool busy = active && active->busy; + json out{ + {"threadId", meta.id}, + {"cwd", meta.cwd}, + {"noWorkspace", meta.no_workspace}, + {"title", active && !active->title.empty() ? active->title : meta.title}, + {"summary", trim_and_limit( + active && !active->summary.empty() ? active->summary : meta.summary, + kSummaryChars)}, + {"createdAt", active && !active->created_at.empty() + ? active->created_at : meta.created_at}, + {"updatedAt", active && !active->updated_at.empty() + ? active->updated_at : meta.updated_at}, + {"active", is_active}, + {"busy", busy}, + {"status", busy ? "running" : "idle"}, + {"archived", meta.archived}, + {"pinned", pinned}, + {"parentThreadId", meta.parent_session_id.empty() + ? json(nullptr) : json(meta.parent_session_id)}, + {"model", active && !active->model_name.empty() + ? active->model_name : meta.model_preset}, + }; + if (pinned_index > 0) out["pinnedIndex"] = pinned_index; + return out; +} + +std::string event_kind_name(SessionEventKind kind) { + return to_string(kind); +} + + +json compact_wait_event(const SessionEvent& event) { + json out{ + {"cursor", std::to_string(event.seq)}, + {"type", event_kind_name(event.kind)}, + }; + if (!event.payload.is_object()) return out; + for (const char* key : { + "busy", "outcome", "reason", "request_id", "title"}) { + if (event.payload.contains(key)) out[key] = event.payload[key]; + } + if (event.kind == SessionEventKind::Message) { + bool truncated = false; + out["role"] = event.payload.value("role", std::string{}); + out["content"] = trim_and_limit( + event.payload.value("content", std::string{}), + 1200, &truncated); + out["truncated"] = truncated; + } + return out; +} + +} // namespace acecode::thread_presentation diff --git a/src/host/session_host/thread_presentation.hpp b/src/host/session_host/thread_presentation.hpp new file mode 100644 index 000000000..8399183fc --- /dev/null +++ b/src/host/session_host/thread_presentation.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +namespace acecode { +struct SessionMeta; +struct SessionInfo; +struct SessionEvent; +} + +namespace acecode::thread_presentation { + +std::string trim_and_limit(std::string value, std::size_t max_chars, + bool* truncated = nullptr); +nlohmann::json thread_summary(const SessionMeta& meta, const SessionInfo* active, + bool pinned, int pinned_index = 0); +nlohmann::json compact_wait_event(const SessionEvent& event); + +} // namespace acecode::thread_presentation diff --git a/src/host/session_host/thread_service.cpp b/src/host/session_host/thread_service.cpp index 686218be4..ca8178ea5 100644 --- a/src/host/session_host/thread_service.cpp +++ b/src/host/session_host/thread_service.cpp @@ -1,4 +1,5 @@ #include "thread_service.hpp" +#include "thread_presentation.hpp" #include "session/scoped_subscription.hpp" #include "thread_wait_state.hpp" #include "agent/transcript/conversation_history.hpp" @@ -17,6 +18,7 @@ #include "utils/logger.hpp" #include "utils/paths.hpp" #include "utils/utf8_path.hpp" +#include "workspace/workspace_registry.hpp" #include #include @@ -38,11 +40,13 @@ namespace acecode { namespace { using nlohmann::json; +using thread_presentation::trim_and_limit; +using thread_presentation::thread_summary; +using thread_presentation::compact_wait_event; constexpr std::size_t kMaxListLimit = 50; constexpr std::size_t kMaxTurnLimit = 20; constexpr std::size_t kMaxItemChars = 8000; -constexpr std::size_t kSummaryChars = 240; constexpr std::size_t kMaxWaitTargets = 8; constexpr int kMaxWaitMs = 120000; @@ -59,22 +63,6 @@ struct ScopedThread { } }; -std::string trim_and_limit(std::string value, - std::size_t max_chars, - bool* truncated = nullptr) { - while (!value.empty() && - std::isspace(static_cast(value.front()))) { - value.erase(value.begin()); - } - while (!value.empty() && - std::isspace(static_cast(value.back()))) { - value.pop_back(); - } - const std::string limited = truncate_utf8_prefix(value, max_chars); - if (truncated) *truncated = limited.size() < value.size(); - return limited; -} - bool valid_title(const std::string& title) { if (title.size() > 160) return false; return std::none_of(title.begin(), title.end(), [](unsigned char ch) { @@ -334,64 +322,6 @@ std::optional parse_cursor_offset(const std::string& cursor) { } } -json thread_summary(const SessionMeta& meta, - const SessionInfo* active, - bool pinned, - int pinned_index = 0) { - const bool is_active = active != nullptr; - const bool busy = active && active->busy; - json out{ - {"threadId", meta.id}, - {"cwd", meta.cwd}, - {"noWorkspace", meta.no_workspace}, - {"title", active && !active->title.empty() ? active->title : meta.title}, - {"summary", trim_and_limit( - active && !active->summary.empty() ? active->summary : meta.summary, - kSummaryChars)}, - {"createdAt", active && !active->created_at.empty() - ? active->created_at : meta.created_at}, - {"updatedAt", active && !active->updated_at.empty() - ? active->updated_at : meta.updated_at}, - {"active", is_active}, - {"busy", busy}, - {"status", busy ? "running" : "idle"}, - {"archived", meta.archived}, - {"pinned", pinned}, - {"parentThreadId", meta.parent_session_id.empty() - ? json(nullptr) : json(meta.parent_session_id)}, - {"model", active && !active->model_name.empty() - ? active->model_name : meta.model_preset}, - }; - if (pinned_index > 0) out["pinnedIndex"] = pinned_index; - return out; -} - -std::string event_kind_name(SessionEventKind kind) { - return to_string(kind); -} - - -json compact_wait_event(const SessionEvent& event) { - json out{ - {"cursor", std::to_string(event.seq)}, - {"type", event_kind_name(event.kind)}, - }; - if (!event.payload.is_object()) return out; - for (const char* key : { - "busy", "outcome", "reason", "request_id", "title"}) { - if (event.payload.contains(key)) out[key] = event.payload[key]; - } - if (event.kind == SessionEventKind::Message) { - bool truncated = false; - out["role"] = event.payload.value("role", std::string{}); - out["content"] = trim_and_limit( - event.payload.value("content", std::string{}), - 1200, &truncated); - out["truncated"] = truncated; - } - return out; -} - std::vector completed_history_for_fork( std::vector messages, bool source_busy) { @@ -764,8 +694,9 @@ ThreadServiceResult ThreadService::create( const ThreadScope& scope, const std::string& prompt, const std::string& title, - const std::string& model_name) const { - if (!deps_.registry || !deps_.client || scope.cwd.empty()) { + const std::string& model_name, + const ThreadCreateTarget& target) const { + if (!deps_.registry || !deps_.client) { return ThreadServiceResult::fail("thread creation is unavailable"); } if (prompt.empty()) return ThreadServiceResult::fail("prompt is required"); @@ -774,6 +705,52 @@ ThreadServiceResult ThreadService::create( SessionOptions options; options.cwd = scope.cwd; options.model_name = model_name; + const bool explicit_directory = !target.cwd.empty() || !target.workspace_hash.empty(); + if ((!target.cwd.empty() && !target.workspace_hash.empty()) || + (target.no_workspace && explicit_directory)) { + return ThreadServiceResult::fail( + "choose only one of cwd, workspaceHash or noWorkspace:true"); + } + options.no_workspace = target.no_workspace || (!explicit_directory && + (scope.no_workspace || (scope.caller_manager && scope.caller_manager->is_no_workspace()))); + if (!target.workspace_hash.empty()) { + if (!valid_storage_token(target.workspace_hash)) { + return ThreadServiceResult::fail("invalid workspaceHash"); + } + const auto workspace = desktop::load_workspace_metadata( + projects_directory(), target.workspace_hash); + if (!workspace || !desktop::workspace_hash_matches_cwd( + target.workspace_hash, workspace->cwd)) { + return ThreadServiceResult::fail("workspaceHash does not identify a registered workspace"); + } + options.cwd = workspace->cwd; + } else if (!target.cwd.empty()) { + options.cwd = target.cwd; + } + if (!options.no_workspace) { + const auto path = path_from_utf8(options.cwd); + if (options.cwd.empty() || options.cwd.find('\0') != std::string::npos || + !path.is_absolute()) { + return ThreadServiceResult::fail("cwd must be an existing absolute directory"); + } + std::error_code ec; + if (!std::filesystem::is_directory(path, ec) || ec) { + return ThreadServiceResult::fail("workspace directory is unavailable: " + options.cwd); + } + if (!target.cwd.empty()) { + const auto canonical = std::filesystem::canonical(path, ec); + if (ec) return ThreadServiceResult::fail("failed to resolve cwd: " + ec.message()); + options.cwd = path_to_utf8(canonical); + } + desktop::WorkspaceRegistry workspaces; + const auto workspace = workspaces.register_new(projects_directory(), options.cwd); + const auto persisted = desktop::load_workspace_metadata(projects_directory(), workspace.hash); + if (!persisted || !persisted->desktop_visible || + !desktop::workspace_hash_matches_cwd(workspace.hash, persisted->cwd)) { + return ThreadServiceResult::fail("failed to persist workspace registration"); + } + options.workspace_hash = workspace.hash; + } std::string id; try { id = deps_.registry->create(options); @@ -788,17 +765,21 @@ ThreadServiceResult ThreadService::create( return ThreadServiceResult::fail("failed to persist new thread"); } if (!title.empty()) entry->sm->set_session_title(title); + const std::string project_dir = entry->sm->current_project_dir(); if (!deps_.client->send_input(id, prompt)) { deps_.client->destroy_session(id); std::string ignored; SessionStorage::purge_session_files( - SessionStorage::get_project_dir(scope.cwd), id, &ignored); + project_dir, id, &ignored); return ThreadServiceResult::fail("failed to queue initial prompt"); } return ThreadServiceResult::ok(json{ {"threadId", id}, {"title", title}, {"status", "running"}, + {"cwd", entry->cwd}, + {"workspaceHash", storage_workspace_hash(project_dir)}, + {"noWorkspace", entry->no_workspace}, }); } @@ -853,32 +834,49 @@ ThreadServiceResult ThreadService::fork( ThreadServiceResult ThreadService::send( const ThreadScope& scope, const std::string& thread_id, - const std::string& prompt) const { + const std::string& prompt, + const std::string& workspace_hash) const { if (thread_id.empty()) return ThreadServiceResult::fail("threadId is required"); if (thread_id == scope.caller_thread_id) { return ThreadServiceResult::fail( "the calling thread cannot send a message to itself"); } if (prompt.empty()) return ThreadServiceResult::fail("prompt is required"); - auto scoped = resolve_thread(deps_, scope, thread_id); - if (!scoped) { - return ThreadServiceResult::fail( - "thread is unavailable in the current workspace"); + std::string error; + auto scoped = resolve_discovered_thread(deps_, scope, thread_id, workspace_hash, error); + if (!scoped) return ThreadServiceResult::fail(error); + const auto expected_hash = storage_workspace_hash(scoped->project_dir); + const auto active_matches_target = [&](const std::shared_ptr& active) { + return active && active->sm && + storage_workspace_hash(active->sm->current_project_dir()) == expected_hash; + }; + if (deps_.registry) { + const auto active = deps_.registry->acquire(thread_id); + if (active && !active_matches_target(active)) { + return ThreadServiceResult::fail("threadId is active in a different workspace"); + } } if (!scoped->active) { SessionOptions options; - options.cwd = scope.cwd; + options.cwd = scoped->meta.cwd; + options.no_workspace = scoped->meta.no_workspace; + options.workspace_hash = options.no_workspace ? std::string{} : expected_hash; + if (options.cwd.empty()) return ThreadServiceResult::fail("target thread cwd is unavailable"); if (!deps_.client || !deps_.client->resume_session(thread_id, options)) { return ThreadServiceResult::fail("failed to resume target thread"); } } + if (deps_.registry && !active_matches_target(deps_.registry->acquire(thread_id))) { + return ThreadServiceResult::fail("target thread workspace changed before delivery"); + } if (!deps_.client || !deps_.client->send_input(thread_id, prompt)) { return ThreadServiceResult::fail("failed to queue thread prompt"); } return ThreadServiceResult::ok(json{ {"threadId", thread_id}, {"delivery", "queued"}, + {"workspaceHash", expected_hash}, }); } diff --git a/src/host/session_host/thread_service.hpp b/src/host/session_host/thread_service.hpp index f7faea608..fed765edd 100644 --- a/src/host/session_host/thread_service.hpp +++ b/src/host/session_host/thread_service.hpp @@ -20,6 +20,13 @@ struct ThreadScope { std::string cwd; std::string caller_thread_id; SessionManager* caller_manager = nullptr; + bool no_workspace = false; +}; + +struct ThreadCreateTarget { + std::string cwd; + std::string workspace_hash; + bool no_workspace = false; }; struct ThreadWaitTarget { @@ -44,7 +51,8 @@ struct ThreadServiceResult { // In-process thread domain service shared by model tools and host surfaces. // Discovery reads persistent and active sessions across all ACECode projects. -// Mutations retain the calling workspace scope. No operation loops back +// Creation and input delivery support explicit targets; other mutations +// retain the calling workspace scope. No operation loops back // through ACECode's HTTP API. class ThreadService { public: @@ -74,12 +82,14 @@ class ThreadService { ThreadServiceResult create(const ThreadScope& scope, const std::string& prompt, const std::string& title = {}, - const std::string& model_name = {}) const; + const std::string& model_name = {}, + const ThreadCreateTarget& target = {}) const; ThreadServiceResult fork(const ThreadScope& scope, const std::string& thread_id = {}) const; ThreadServiceResult send(const ThreadScope& scope, const std::string& thread_id, - const std::string& prompt) const; + const std::string& prompt, + const std::string& workspace_hash = {}) const; ThreadServiceResult set_title(const ThreadScope& scope, const std::string& thread_id, const std::string& title) const; diff --git a/src/host/session_host/tools/thread_tools.cpp b/src/host/session_host/tools/thread_tools.cpp index c3c44248f..80b41134a 100644 --- a/src/host/session_host/tools/thread_tools.cpp +++ b/src/host/session_host/tools/thread_tools.cpp @@ -34,6 +34,7 @@ ThreadScope scope_from_context(const ToolContext& ctx) { scope.cwd = ctx.cwd; scope.caller_manager = ctx.session_manager; if (!ctx.session_manager) return scope; + scope.no_workspace = ctx.session_manager->is_no_workspace(); scope.caller_thread_id = ctx.session_manager->current_session_id(); if (!scope.caller_thread_id.empty()) { const auto meta = ctx.session_manager->load_session_meta( @@ -103,12 +104,22 @@ json object_schema(json properties, ToolImpl create_thread_tool(std::shared_ptr deps) { return make_tool( "create_thread", - "Create a separate ACECode thread in the current workspace and send " - "its initial prompt. Creation is non-blocking and returns threadId.", + "Create a separate ACECode thread and queue its initial prompt. " + "Omit targets to inherit the caller's workspace or workspace-free " + "state. To select a target, pass workspaceHash from list_threads or " + "create_workspace, an existing absolute cwd, or noWorkspace:true. " + "Directory targets become visible workspaces. Returns threadId, " + "cwd, workspaceHash and noWorkspace for locating the new thread.", object_schema(json{ {"prompt", json{{"type", "string"}, {"minLength", 1}}}, {"title", json{{"type", "string"}, {"maxLength", 160}}}, {"model", json{{"type", "string"}}}, + {"workspaceHash", json{{"type", "string"}, {"minLength", 1}, + {"description", "Registered workspace hash; cannot combine with cwd or noWorkspace:true"}}}, + {"cwd", json{{"type", "string"}, {"minLength", 1}, + {"description", "Existing absolute directory; cannot combine with workspaceHash or noWorkspace:true"}}}, + {"noWorkspace", json{{"type", "boolean"}, + {"description", "True creates a workspace-free thread; false or omitted inherits unless a directory target is supplied"}}}, }, json::array({"prompt"})), false, [deps](const std::string& arguments, @@ -120,11 +131,25 @@ ToolImpl create_thread_tool(std::shared_ptr deps) { if (!parser) return error; auto prompt = required_arg(*parser, "prompt", &error); if (!prompt) return error; + ThreadCreateTarget target; + for (const auto* key : {"cwd", "workspaceHash"}) { + const auto value = parser->get(key); + if (!value) continue; + if (!value->is_string() || value->get().empty()) { + return tool_error(std::string(key) + " must be a non-empty string"); + } + (std::string(key) == "cwd" ? target.cwd : target.workspace_hash) = + value->get(); + } + if (const auto value = parser->get("noWorkspace")) { + if (!value->is_boolean()) return tool_error("noWorkspace must be a boolean"); + target.no_workspace = value->get(); + } return service_result(service->create( scope_from_context(ctx), *prompt, parser->get_or("title", {}), - parser->get_or("model", {}))); + parser->get_or("model", {}), target)); }); } @@ -225,11 +250,14 @@ ToolImpl read_thread_tool(std::shared_ptr deps) { ToolImpl send_message_tool(std::shared_ptr deps) { return make_tool( "send_message_to_thread", - "Send a follow-up prompt to an existing ACECode thread in the " - "background.", + "Queue a follow-up prompt to an existing ACECode thread in any " + "workspace, including workspace-free threads. Optional workspaceHash " + "from create_thread or list_threads locates the storage project and " + "disambiguates threadId. Queuing does not answer a pending user question.", object_schema(json{ {"threadId", json{{"type", "string"}, {"minLength", 1}}}, {"prompt", json{{"type", "string"}, {"minLength", 1}}}, + {"workspaceHash", json{{"type", "string"}, {"minLength", 1}}}, }, json::array({"threadId", "prompt"})), false, [deps](const std::string& arguments, @@ -244,9 +272,16 @@ ToolImpl send_message_tool(std::shared_ptr deps) { if (!thread_id) return error; auto prompt = required_arg(*parser, "prompt", &error); if (!prompt) return error; + std::string workspace_hash; + if (const auto value = parser->get("workspaceHash")) { + if (!value->is_string() || value->get().empty()) { + return tool_error("workspaceHash must be a non-empty string"); + } + workspace_hash = value->get(); + } return service_result(service->send( scope_from_context(ctx), - *thread_id, *prompt)); + *thread_id, *prompt, workspace_hash)); }); } diff --git a/tests/channels/core/commands_test.cpp b/tests/channels/core/commands_test.cpp index e12cc559d..7c65a410b 100644 --- a/tests/channels/core/commands_test.cpp +++ b/tests/channels/core/commands_test.cpp @@ -27,6 +27,16 @@ TEST(ChannelCommands, ParsesKnownCommands) { EXPECT_EQ(resume.argument, "3"); EXPECT_EQ(parse_command("/models").kind, CommandKind::Model); EXPECT_EQ(parse_command("/model gpt-5").argument, "gpt-5"); + EXPECT_EQ(parse_command("/mode").kind, CommandKind::Mode); + EXPECT_EQ(parse_command(" /MODE AUTO ").argument, "auto"); + EXPECT_EQ(parse_command("/mode accept-edits").argument, "auto"); + EXPECT_EQ(parse_command("/mode acceptEdits").argument, "auto"); + EXPECT_EQ(parse_command("/mode default").argument, "default"); + EXPECT_EQ(parse_command("/mode yolo").argument, "yolo"); + EXPECT_EQ(parse_command(" /YOLO ").kind, CommandKind::Yolo); + EXPECT_EQ(parse_command("/effort").kind, CommandKind::Effort); + EXPECT_EQ(parse_command(" /EFFORT HIGH ").argument, "high"); + EXPECT_EQ(parse_command("/effort default").argument, "default"); EXPECT_EQ(parse_command("/approve abc").kind, CommandKind::Approve); EXPECT_EQ(parse_command("/deny abc").kind, CommandKind::Deny); EXPECT_EQ(parse_command("/aq 1 2").kind, CommandKind::Question); @@ -39,6 +49,7 @@ TEST(ChannelCommands, UnknownSlashTextIsPlainInput) { EXPECT_EQ(parse_command("/review 这个文件").kind, CommandKind::None); EXPECT_EQ(parse_command("").kind, CommandKind::None); EXPECT_EQ(parse_command("/statusbar").kind, CommandKind::None); + EXPECT_EQ(parse_command("/yolo-extra").kind, CommandKind::None); } // 场景:命令缺参数或参数格式不对。 @@ -49,6 +60,12 @@ TEST(ChannelCommands, ReportsUsageErrors) { EXPECT_FALSE(parse_command("/deny a b").error.empty()); EXPECT_FALSE(parse_command("/sessions everything").error.empty()); EXPECT_TRUE(parse_command("/sessions all").error.empty()); + EXPECT_FALSE(parse_command("/mode invalid").error.empty()); + EXPECT_FALSE(parse_command("/mode auto yolo").error.empty()); + EXPECT_FALSE(parse_command("/yolo extra").error.empty()); + EXPECT_FALSE(parse_command("/effort high low").error.empty()); + EXPECT_FALSE(parse_command("/effort high\tlow").error.empty()); + EXPECT_TRUE(parse_command("/effort xhigh").error.empty()); } // 场景:会话正在执行时收到各种命令。 @@ -57,7 +74,8 @@ TEST(ChannelCommands, OnlyNewAndResumeWaitForIdle) { EXPECT_FALSE(runs_while_busy(CommandKind::New)); EXPECT_FALSE(runs_while_busy(CommandKind::Resume)); for (const auto kind : {CommandKind::Help, CommandKind::Status, CommandKind::Stop, CommandKind::Sessions, - CommandKind::Model, CommandKind::Approve, CommandKind::Deny, CommandKind::Question}) + CommandKind::Model, CommandKind::Mode, CommandKind::Yolo, CommandKind::Effort, + CommandKind::Approve, CommandKind::Deny, CommandKind::Question}) EXPECT_TRUE(runs_while_busy(kind)); } @@ -66,6 +84,10 @@ TEST(ChannelCommands, OnlyNewAndResumeWaitForIdle) { TEST(ChannelCommands, HelpAndPromptsAreChinese) { EXPECT_EQ(texts::help(true).find("只能在自己创建的会话"), std::string::npos); EXPECT_NE(texts::help(false).find("只能在自己创建的会话"), std::string::npos); + for (const auto* command : {"/mode", "/yolo", "/effort"}) { + EXPECT_NE(texts::help(true).find(command), std::string::npos); + EXPECT_NE(texts::help(false).find(command), std::string::npos); + } const auto prompt = texts::permission_prompt("p1", "bash", "{\"command\":\"ls\"}"); EXPECT_NE(prompt.find("/approve p1"), std::string::npos); EXPECT_NE(prompt.find("/deny p1"), std::string::npos); diff --git a/tests/channels/core/router_test.cpp b/tests/channels/core/router_test.cpp index 9b9515bac..a96ee118b 100644 --- a/tests/channels/core/router_test.cpp +++ b/tests/channels/core/router_test.cpp @@ -73,6 +73,26 @@ class ChannelRouter : public ::testing::Test { switched.emplace_back(id, name); return true; }; + deps.set_permission_mode = [this](const std::string& id, const std::string& mode) { + mode_changes.emplace_back(id, mode); + if (fail_controls) return false; + std::lock_guard lock(sessions.mu); + sessions.sessions.at(id).permission_mode = mode; + return true; + }; + deps.model_state = [this](const std::string& id) -> std::optional { + return model_states[id]; + }; + deps.set_reasoning_effort = [this](const std::string& id, const std::optional& effort, + std::string* error) -> std::optional { + effort_changes.emplace_back(id, effort); + if (fail_controls || (effort && *effort != "low" && *effort != "high")) { + *error = "测试更新失败"; + return std::nullopt; + } + model_states[id].reasoning_effort = effort; + return model_states[id]; + }; deps.expand_skill = [](const std::string&, const std::string& text) { return "[展开]" + text; }; deps.release_session = [this](const std::string& id, const std::string& key, const std::string& label) { if (conversations) conversations->release_session(id, key, label); @@ -107,6 +127,10 @@ class ChannelRouter : public ::testing::Test { AccessControl access; std::unique_ptr conversations; std::vector> switched; + std::vector> mode_changes; + std::vector>> effort_changes; + std::map model_states; + bool fail_controls = false; int binding_changes = 0; }; @@ -196,6 +220,98 @@ TEST_F(ChannelRouter, BusySessionRejectsSwitching) { EXPECT_TRUE(transport->wait_said("状态:执行中")); } +TEST_F(ChannelRouter, ModeCommandsStayInBoundSessionWhileBusy) { + send("1", "/new", "m1"); + send("2", "/new", "m2"); + const auto id = bound("2"); + sessions.set_busy(id, true); + send("2", "/mode", "m3"); + EXPECT_TRUE(transport->wait_said("权限模式:默认权限 (default)")); + send("2", "/mode auto", "m4"); + EXPECT_TRUE(transport->wait_said("已切换权限模式为 自动模式 (auto)")); + send("2", "/yolo", "m5"); + send("2", "/yolo", "m5"); + EXPECT_TRUE(transport->wait_said("已切换权限模式为 完全访问权限 (yolo)")); + send("2", "/status", "m6"); + EXPECT_TRUE(transport->wait_said("权限模式:完全访问权限 (yolo)")); + send("2", "/mode default", "m7"); + ASSERT_EQ(mode_changes.size(), 3u); + EXPECT_EQ(mode_changes[0], std::make_pair(id, std::string("auto"))); + EXPECT_EQ(mode_changes[1], std::make_pair(id, std::string("yolo"))); + EXPECT_EQ(mode_changes[2], std::make_pair(id, std::string("default"))); + EXPECT_TRUE(sessions.input_log().empty()); + std::lock_guard lock(sessions.mu); + EXPECT_EQ(sessions.sessions.at(bound("1")).permission_mode, "default"); + EXPECT_TRUE(sessions.sessions.at(id).busy); +} + +TEST_F(ChannelRouter, EffortQueriesChangesResetsAndReportsFailures) { + send("1", "/new", "m1"); + const auto id = bound("1"); + auto& model = model_states[id]; + model.provider = "openai"; + model.reasoning = ModelReasoningOptions{}; + model.reasoning->supported = true; + model.reasoning->enabled = true; + model.reasoning->supported_efforts = {"low", "high"}; + model.reasoning->effort = "low"; + sessions.set_busy(id, true); + send("1", "/effort", "m2"); + EXPECT_TRUE(transport->wait_said("思考深度:low (模型预设)")); + EXPECT_TRUE(transport->wait_said("可用档位: low high")); + send("1", "/effort high", "m3"); + send("1", "/effort high", "m3"); + EXPECT_TRUE(transport->wait_said("从下一次模型请求起生效")); + EXPECT_EQ(model.reasoning_effort, "high"); + send("1", "/effort invalid", "m4"); + EXPECT_TRUE(transport->wait_said("测试更新失败")); + EXPECT_EQ(model.reasoning_effort, "high"); + send("1", "/status", "m5"); + EXPECT_TRUE(transport->wait_said("思考深度:high")); + send("1", "/effort default", "m6"); + EXPECT_TRUE(transport->wait_said("已恢复模型预设思考深度")); + EXPECT_FALSE(model.reasoning_effort); + ASSERT_EQ(effort_changes.size(), 3u); + EXPECT_EQ(effort_changes.back().first, id); + EXPECT_FALSE(effort_changes.back().second); + EXPECT_TRUE(sessions.input_log().empty()); + model.reasoning->enabled = false; + send("1", "/effort", "m7"); + EXPECT_TRUE(transport->wait_said("当前模型未启用或不支持 effort 档位")); +} + +TEST_F(ChannelRouter, ControlsRejectUnboundUnauthorizedMalformedAndDeletedSessions) { + for (const auto* command : {"/mode auto", "/yolo", "/effort high"}) { + send("1", command, std::string("unbound-") + command); + send("9", command, std::string("unauthorized-") + command); + } + EXPECT_TRUE(transport->wait_said("当前还没有会话")); + EXPECT_TRUE(sessions.created_log().empty()); + send("1", "/new", "new"); + send("1", "/mode bad", "bad-mode"); + send("1", "/yolo extra", "bad-yolo"); + send("1", "/effort high low", "bad-effort"); + const auto id = bound("1"); + restart(); + sessions.remove(id); + send("1", "/yolo", "deleted"); + EXPECT_TRUE(transport->wait_said("无法打开当前绑定的会话")); + EXPECT_TRUE(mode_changes.empty()); + EXPECT_TRUE(effort_changes.empty()); + EXPECT_TRUE(sessions.input_log().empty()); +} + +TEST_F(ChannelRouter, FailedModeUpdateDoesNotReportSuccess) { + send("1", "/new", "m1"); + fail_controls = true; + send("1", "/yolo", "m2"); + EXPECT_TRUE(transport->wait_said("切换权限模式失败")); + EXPECT_FALSE(transport->said("已切换权限模式")); + send("1", "/mode", "m3"); + EXPECT_TRUE(transport->wait_said("权限模式:默认权限 (default)")); + EXPECT_TRUE(sessions.input_log().empty()); +} + // 场景:机主 /sessions 列出全部会话,然后目录里新增一个排在最前面的会话,再 /resume 1。 // 期望:编号按最近一次列表的快照解析,选中的是列表里的第 1 项而不是新会话。 TEST_F(ChannelRouter, ResumeIndexUsesLatestListingSnapshot) { diff --git a/tests/config/config_first_init_test.cpp b/tests/config/config_first_init_test.cpp index d10a476de..fb007b3bc 100644 --- a/tests/config/config_first_init_test.cpp +++ b/tests/config/config_first_init_test.cpp @@ -234,6 +234,23 @@ TEST_F(ConfigFirstInitTest, RuntimeSkillAllowlistIsNeverPersisted) { EXPECT_FALSE(reloaded.skills.allowed.has_value()); } +TEST_F(ConfigFirstInitTest, SkillScanPathsRoundTripEmptyAndReset) { + const auto path = temp_home / ".acecode/config.json"; + acecode::AppConfig cfg; + EXPECT_FALSE(cfg.skills.scan_paths); + for (const auto& paths : {std::vector{".custom/skills", "~/.codex/skills"}, + std::vector{}}) { + cfg.skills.scan_paths = paths; + acecode::save_config(cfg, path.string()); + auto loaded = acecode::load_config_from_path(path.string(), false); + ASSERT_TRUE(loaded.skills.scan_paths); + EXPECT_EQ(*loaded.skills.scan_paths, paths); + } + cfg.skills.scan_paths.reset(); + acecode::save_config(cfg, path.string()); + EXPECT_FALSE(acecode::load_config_from_path(path.string(), false).skills.scan_paths); +} + TEST_F(ConfigFirstInitTest, ExplicitEmptySavedModelsDoesNotSynthesizeCopilot) { fs::create_directories(temp_home / ".acecode"); { diff --git a/tests/cpp_source_paths.json b/tests/cpp_source_paths.json index 81258524b..57c43f4cb 100644 --- a/tests/cpp_source_paths.json +++ b/tests/cpp_source_paths.json @@ -13,6 +13,7 @@ "desktop/splash_screen.cpp": "src/apps/desktop/splash_screen.cpp", "desktop/startup_progress.hpp": "src/apps/desktop/startup_progress.hpp", "desktop/web_host.cpp": "src/apps/desktop/web_host.cpp", + "desktop/web_host_focus.hpp": "src/apps/desktop/web_host_focus.hpp", "tool/agent_browser/browser_tools.cpp": "src/adapters/tool/agent_browser/browser_tools.cpp", "tool/agent_browser/browser_tools.hpp": "src/adapters/tool/agent_browser/browser_tools.hpp", "tool/agent_browser/cdp_client.cpp": "src/adapters/tool/agent_browser/cdp_client.cpp", diff --git a/tests/daemon/im_channels_e2e_test.cpp b/tests/daemon/im_channels_e2e_test.cpp index 65563fea9..f901e449e 100644 --- a/tests/daemon/im_channels_e2e_test.cpp +++ b/tests/daemon/im_channels_e2e_test.cpp @@ -293,6 +293,124 @@ TEST_F(ImChannelsEndToEnd, TelegramValidatesTokenBindsOwnerAndChats) { if (info.id == session) EXPECT_TRUE(info.no_workspace); } +TEST_F(ImChannelsEndToEnd, TelegramModeCommandsChangeAndPersistBoundSession) { + start_host(); + connect_telegram_as_owner(); + telegram.push_update(telegram_message(42, "private", 42, 2, "/new")); + ASSERT_TRUE(wait_until([&] { return !bound("telegram", "42", "42").empty(); })); + const auto id = bound("telegram", "42", "42"); + auto entry = registry->acquire(id); + ASSERT_TRUE(entry); + const auto other = client->create_session(SessionOptions{}); + std::int64_t sequence = 3; + for (const auto& item : std::vector>{ + {"/mode auto", PermissionMode::Auto}, {"/yolo", PermissionMode::Yolo}, + {"/mode default", PermissionMode::Default}}) { + telegram.push_update(telegram_message(42, "private", 42, sequence++, item.first)); + ASSERT_TRUE(wait_until([&] { return entry->perm->mode() == item.second; })); + ASSERT_TRUE(wait_until([&] { + return SessionStorage::read_meta(SessionStorage::meta_path( + SessionStorage::get_project_dir(entry->cwd), id)).permission_mode == + PermissionManager::mode_name(item.second); + })); + // 状态先更新,回执仍经 Telegram 限速队列发送;逐条等回执再继续。 + const std::string mode_marker = std::string("(") + PermissionManager::mode_name(item.second) + ")"; + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, mode_marker); })); + EXPECT_EQ(registry->acquire(other)->perm->mode(), PermissionMode::Default); + EXPECT_FALSE(model_saw(item.first)); + } + telegram.push_update(telegram_message(42, "private", 42, sequence++, "/help")); + const bool help_received = wait_until([&] { return telegram_sent(42, "/effort") && telegram_sent(42, "/yolo"); }); + if (!help_received) { + json sent = json::array(); + for (const auto& call : telegram.calls_to("sendMessage")) sent.push_back(call.body); + ADD_FAILURE() << "Missing command help in Telegram replies: " << sent.dump(2); + } + telegram.push_update(telegram_message(42, "private", 42, sequence++, "/status")); + EXPECT_TRUE(wait_until([&] { return telegram_sent(42, "权限模式:默认权限 (default)"); })); +} + +TEST_F(ImChannelsEndToEnd, TelegramYoloResolvesPendingPermission) { + std::atomic calls{0}; + ToolDef def; + def.name = "deploy_preview"; + def.description = "deploy a preview build"; + def.parameters = json{{"type", "object"}, {"properties", json::object()}}; + ASSERT_TRUE(tools.register_tool(ToolImpl{def, [&](const std::string&, const ToolContext&) { + ++calls; + return ToolResult{"deployed", true}; + }, false})); + start_host(); + connect_telegram_as_owner(); + provider->push_tool_call("deploy_preview", "{}", "call-yolo"); + provider->push_text("部署完成"); + telegram.push_update(telegram_message(42, "private", 42, 2, "部署一下")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "需要你确认一个操作"); })); + EXPECT_EQ(calls.load(), 0); + telegram.push_update(telegram_message(42, "private", 42, 3, "/yolo")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "部署完成"); })); + EXPECT_EQ(calls.load(), 1); + EXPECT_EQ(registry->acquire(bound("telegram", "42", "42"))->perm->mode(), PermissionMode::Yolo); + EXPECT_FALSE(model_saw("/yolo")); +} + +TEST_F(ImChannelsEndToEnd, TelegramEffortCommandsUseRealModelStateAndPersistOverride) { + client.reset(); + registry.reset(); + ModelProfile profile; + profile.name = "reasoning-test"; + profile.provider = "openai"; + profile.model = "channel-test"; + profile.base_url = "http://127.0.0.1:1/v1"; + profile.api_key = "test-key"; + profile.context_window = 128000; + profile.capabilities_source = "manual"; + profile.reasoning = ModelReasoningOptions{}; + profile.reasoning->supported = true; + profile.reasoning->default_enabled = true; + profile.reasoning->supported_efforts = {"low", "high"}; + profile.reasoning->effort = "low"; + profile.reasoning->default_effort = "low"; + config.saved_models = {profile}; + config.default_model_name = profile.name; + SessionRegistryDeps deps; + deps.tools = &tools; + deps.template_permissions = &permissions; + deps.config = &config; + deps.config_mutex = &config_mu; + deps.cwd = workspace; + deps.no_workspace_cache_root = path_to_utf8(home / "no-workspace"); + registry = std::make_unique(deps); + client = std::make_unique(*registry); + start_host(); + connect_telegram_as_owner(); + telegram.push_update(telegram_message(42, "private", 42, 2, "/new")); + ASSERT_TRUE(wait_until([&] { return !bound("telegram", "42", "42").empty(); })); + const auto id = bound("telegram", "42", "42"); + const auto other = client->create_session(SessionOptions{}); + const auto entry = registry->acquire(id); + ASSERT_TRUE(entry); + const auto meta_path = SessionStorage::meta_path(SessionStorage::get_project_dir(entry->cwd), id); + telegram.push_update(telegram_message(42, "private", 42, 3, "/effort")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "可用档位: low high"); })); + telegram.push_update(telegram_message(42, "private", 42, 4, "/effort high")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "已更新思考深度"); })); + EXPECT_EQ(registry->current_model_state(id)->reasoning_effort, "high"); + EXPECT_EQ(SessionStorage::read_meta(meta_path).reasoning_effort, "high"); + EXPECT_FALSE(registry->current_model_state(other)->reasoning_effort); + EXPECT_EQ(config.saved_models.front().reasoning->effort, "low"); + telegram.push_update(telegram_message(42, "private", 42, 5, "/effort invalid")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "当前模型不支持该 effort 档位"); })); + EXPECT_EQ(registry->current_model_state(id)->reasoning_effort, "high"); + telegram.push_update(telegram_message(42, "private", 42, 6, "/effort default")); + ASSERT_TRUE(wait_until([&] { return telegram_sent(42, "已恢复模型预设思考深度"); })); + const auto reset = registry->current_model_state(id); + ASSERT_TRUE(reset && reset->reasoning); + EXPECT_FALSE(reset->reasoning_effort); + EXPECT_EQ(reset->reasoning->effort, "low"); + EXPECT_FALSE(SessionStorage::read_meta(meta_path).reasoning_effort); +} + // 场景:机器人在一个未批准的群里被 @,机主在设置页批准该群后再次 @ 提问;群里不 @ 的消息。 // 期望:未批准时只产生群的待批准请求、不回复;批准后 @ 的提问进入会话并在群里得到答复; // 不 @ 的消息被忽略。隐私模式(默认开启)反映在快照里。 diff --git a/tests/desktop/fixtures/window_focus_host.cpp b/tests/desktop/fixtures/window_focus_host.cpp new file mode 100644 index 000000000..d60d44094 --- /dev/null +++ b/tests/desktop/fixtures/window_focus_host.cpp @@ -0,0 +1,85 @@ +// Standalone Windows smoke host for web/scripts/test-desktop-focus.mjs. +// Link against the production web_host.cpp and desktop support libraries. +#include "desktop/web_host.hpp" +#include +#include +#include +#include +#include +#include + +// Test driver only: simulate an explicit user switch from a background runner. +static void activate_test_window(HWND window) { + const DWORD foreground = GetWindowThreadProcessId(GetForegroundWindow(), nullptr); + const DWORD current = GetCurrentThreadId(); + const bool attached = foreground && foreground != current && AttachThreadInput(current, foreground, TRUE); + SetForegroundWindow(window); + SetFocus(window); + if (attached) AttachThreadInput(current, foreground, FALSE); +} + +int main(int argc, char** argv) { + if (argc != 2) return 2; + if (!GetForegroundWindow()) { + std::cout << "[skip] Native keyboard test requires an active, unlocked Windows desktop" << std::endl; + return 77; + } + using acecode::desktop::WebHost; + WebHost host(false, WebHost::StartupWindowMode::OffscreenUntilReady); + host.set_title("ACECode focus verification"); + host.set_size(1170, 820); + const auto hwnd = static_cast(host.native_window()); + const HWND previous = GetForegroundWindow(); + using UniqueWindow = std::unique_ptr, decltype(&DestroyWindow)>; + UniqueWindow peer(CreateWindowExW(0, L"STATIC", L"Focus verification peer", + WS_OVERLAPPEDWINDOW, 100, 100, 300, 160, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr), + &DestroyWindow); + const HWND other = peer.get(); // Borrowed for synchronous test operations. + if (!other) return 3; + int result = 1; + host.bind("aceDesktop_focusActiveWebview", [&host](const std::string&) { + return nlohmann::json{{"ok", host.focus_active_webview()}}.dump(); + }); + host.set_window_focus_handler([&host] { + host.eval("window.dispatchEvent(new Event('acecode:desktop-window-focus'))"); + }); + host.bind("aceFocusTest", [&](const std::string& request) { + const auto args = nlohmann::json::parse(request); + const std::string op = args.at(0); + if (op == "show" || op == "return" || op == "restore") { + ShowWindow(other, SW_HIDE); + if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE); + host.set_visible(true); + activate_test_window(hwnd); + } else if (op == "away") { + ShowWindow(other, SW_SHOW); + activate_test_window(other); + } else if (op == "minimize") { + ShowWindow(hwnd, SW_MINIMIZE); + } else if (op == "maximize") { + ShowWindow(hwnd, SW_MAXIMIZE); + } else if (op == "unmaximize") { + ShowWindow(hwnd, SW_RESTORE); + } else if (op == "hide") { + host.set_visible(false); + } else if (op == "type") { + if (GetForegroundWindow() != hwnd) return std::string("{\"ok\":false}"); + INPUT input[2]{}; + input[0].type = input[1].type = INPUT_KEYBOARD; + input[0].ki.wScan = input[1].ki.wScan = L'Z'; + input[0].ki.dwFlags = KEYEVENTF_UNICODE; + input[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP; + if (SendInput(2, input, sizeof(INPUT)) != 2) return std::string("{\"ok\":false}"); + } else if (op == "done") { + result = args.at(1).get() ? 0 : 1; + std::cout << args.at(2).dump() << std::endl; + host.request_quit(); + } + return std::string("{\"ok\":true}"); + }); + host.navigate(argv[1]); + host.run(); + peer.reset(); + if (IsWindow(previous)) SetForegroundWindow(previous); + return result; +} diff --git a/tests/experts/expert_registry_test.cpp b/tests/experts/expert_registry_test.cpp index cc744b9d9..7f1ae46c7 100644 --- a/tests/experts/expert_registry_test.cpp +++ b/tests/experts/expert_registry_test.cpp @@ -25,6 +25,22 @@ namespace fs = std::filesystem; +TEST(ExpertAvatarIdentity, ReferencedMembersMatchTheirStandaloneExpert) { + acecode::ExpertDefinition team; + team.id = "team"; + team.type = acecode::ExpertType::Team; + team.references_existing_experts = true; + team.lead_expert_id = "leader"; + acecode::ExpertDefinition worker; + worker.id = "worker"; + EXPECT_EQ(team.avatar_identity("worker"), worker.avatar_identity()); + EXPECT_EQ(team.avatar_identity(), "expert:leader"); + team.references_existing_experts = false; + team.lead_agent_id = "leader"; + EXPECT_EQ(team.avatar_identity(), "expert:team/leader"); + EXPECT_EQ(team.avatar_identity("worker"), "expert:team/worker"); +} + namespace { void install_test_provider( diff --git a/tests/scripts/embedded_assets_rebuild_test.py b/tests/scripts/embedded_assets_rebuild_test.py index b09e7f772..787861328 100644 --- a/tests/scripts/embedded_assets_rebuild_test.py +++ b/tests/scripts/embedded_assets_rebuild_test.py @@ -1,7 +1,10 @@ """Verify that rebuilding after a Vite output change refreshes embedded bytes.""" from pathlib import Path +import gzip +import re import shutil import subprocess +import sys import tempfile import unittest @@ -9,6 +12,55 @@ class EmbeddedAssetsRebuildTest(unittest.TestCase): + @unittest.skipUnless(shutil.which("cmake"), "requires CMake") + def test_native_office_inlines_the_shared_character_source(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "pet.cpp" + source = ROOT / 'assets/desktop_pet/agent_office_pet.html' + shared = ROOT / 'assets/desktop_pet/office_characters.js' + subprocess.run(['cmake', f'-DBIN2CPP_INPUT={source}', f'-DBIN2CPP_OUTPUT={output}', + '-DBIN2CPP_SYMBOL=desktop_pet_page', f'-DBIN2CPP_INLINE_JS={shared}', + '-P', str(ROOT / 'cmake/acecode_bin2cpp.cmake')], check=True, capture_output=True) + decoded = bytes(int(value, 16) for value in re.findall(r'0x([0-9a-f]{2})', output.read_text())) + expected = source.read_bytes().replace(b'', + b'') + self.assertEqual(decoded, expected) + self.assertNotIn(b''; +const server = await createServer({root:web, logLevel:'error', server:{host:'127.0.0.1',port:0,hmr:false}, + plugins:[{name:'focus-selection-fixture',enforce:'pre', + resolveId(id) { if (id === 'virtual:focus-selection') return '\0virtual:focus-selection'; }, + async load(id) { + if (id === '\0virtual:focus-selection') return (await transformWithEsbuild(entry,'fixture.jsx',{loader:'jsx'})).code; + }, + transform(code,id) { + // Read-only probes for the actual editor; production exports stay unchanged. + if (id.replaceAll('\\','/').endsWith('/components/RichComposer.jsx')) { + return code.replace('const editableRef = useRef(null);', + 'const editableRef = useRef(null); window.composerProbe = {editor, renderPendingRef, compositionState: () => compositionStateRef.current};'); + } + }, + configureServer(vite) { vite.middlewares.use('/focus-selection', async (_req,res) => { + res.setHeader('Content-Type','text/html'); res.end(await vite.transformIndexHtml('/focus-selection',html)); + }); }, + }], +}); +let browser; +let native; +try { + await server.listen(); + const url = `http://127.0.0.1:${server.httpServer.address().port}/focus-selection`; + let page; + if (process.env.ACE_FOCUS_NATIVE_HOST) { + const portServer=createNetServer(); await new Promise(resolve=>portServer.listen(0,'127.0.0.1',resolve)); + const port=portServer.address().port; await new Promise(resolve=>portServer.close(resolve)); + const profile=await mkdtemp(path.join(os.tmpdir(),'ace-caret-webview-')); + native=spawn(process.env.ACE_FOCUS_NATIVE_HOST,[url],{windowsHide:true,stdio:'pipe',env:{...process.env, + WEBVIEW2_USER_DATA_FOLDER:profile,WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS:'--remote-debugging-port='+port}}); + for(let attempt=0;attempt<100;attempt++) { + try {browser=await chromium.connectOverCDP('http://127.0.0.1:'+port);break;} catch {await new Promise(resolve=>setTimeout(resolve,100));} + } + if(!browser) throw new Error('Native WebView debug endpoint unavailable'); + page=browser.contexts()[0].pages()[0]; + await page.waitForFunction(()=>typeof window.aceFocusTest==='function'); + await page.evaluate(()=>window.aceFocusTest('show')); + } else { + browser = await chromium.launch({headless:true,executablePath:process.env.ACE_CHROMIUM_EXECUTABLE || undefined}); + page = await browser.newPage(); + } + const errors = []; + page.on('pageerror', error => errors.push(String(error))); + const editor = page.locator('[data-ace-rich-composer]'); + const cdp = await page.context().newCDPSession(page); + const state = () => page.evaluate(() => { + const el = document.querySelector('[data-ace-rich-composer]'); + const selection = getSelection(); + const offset = nodeOffset => { + const range = document.createRange(); range.selectNodeContents(el); + range.setEnd(selection[nodeOffset+'Node'],selection[nodeOffset+'Offset']); return range.toString().length; + }; + return {text:fixture.composer.current.value, dom:el.textContent, + model:composerProbe.editor.selection, + anchor:offset('anchor'),focus:offset('focus'), + pending:composerProbe.renderPendingRef.current, + focusCalls:window.focusCalls, composition:composerProbe.compositionState()}; + }); + const reset = async () => { + await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/focus-selection`); + await editor.click(); await page.keyboard.press('End'); await page.waitForTimeout(150); + await page.evaluate(() => {window.focusCalls=0;}); + }; + const compose = text => cdp.send('Input.imeSetComposition',{text,selectionStart:text.length,selectionEnd:text.length}); + const commit = text => cdp.send('Input.insertText',{text}); + const outcomes=[]; + for (const variant of ['normal','focus-before-composition','focus-during-composition','drag-then-focus', + 'imperative-focus','selected-range', ...(native ? ['window-return'] : [])]) { + await reset(); + if (variant==='window-return') { + await page.evaluate(()=>window.aceFocusTest('away')); + await page.waitForTimeout(80); + await page.evaluate(()=>window.aceFocusTest('return')); + await page.waitForTimeout(150); + } + if (variant==='drag-then-focus') await editor.dispatchEvent('dragstart',{dataTransfer:await page.evaluateHandle(()=>new DataTransfer())}); + await page.keyboard.press('Home'); + if (variant==='selected-range') { + await page.keyboard.press('Shift+ArrowRight'); + await page.keyboard.press('Shift+ArrowRight'); + } + if (variant==='focus-before-composition') { + await page.evaluate(()=>window.activate()); await page.waitForTimeout(40); + } + await compose('中文'); + if (variant==='focus-during-composition'||variant==='drag-then-focus'||variant==='selected-range') await page.evaluate(()=>window.activate()); + if (variant==='imperative-focus') await page.evaluate(()=>fixture.composer.current.focus()); + await page.waitForTimeout(140); + const during=await state(); + await commit('中文'); await page.waitForTimeout(150); + await page.keyboard.type('abc'); await page.waitForTimeout(150); + const after=await state(); + outcomes.push({variant,during,after}); + assert.equal(during.text,variant==='selected-range'?'il':'tail',variant+': activation must not commit the IME candidate'); + assert.equal(during.composition.active,true,variant+': composition remains owned by the IME'); + assert.equal(after.text,variant==='selected-range'?'中文abcil':'中文abctail',variant+': committed text must not duplicate or move'); + assert.equal(after.anchor,5,variant+': visible caret follows the typed text'); + assert.equal(after.focus,after.model.focus.offset,variant+': DOM caret matches Slate insertion point'); + } + assert.deepEqual(errors,[]); + console.log(JSON.stringify({result:'passed',native:!!native,scenarios:outcomes.map(result=>result.variant)})); + if(native) await page.evaluate(()=>window.aceFocusTest('done',true,['caret diagnostic complete'])).catch(()=>{}); +} finally { await browser?.close(); native?.kill(); await server.close(); } diff --git a/web/scripts/test-desktop-focus.mjs b/web/scripts/test-desktop-focus.mjs new file mode 100644 index 000000000..922d3b534 --- /dev/null +++ b/web/scripts/test-desktop-focus.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawn } from 'node:child_process'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const fixture = ` +import React, {useEffect, useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {RichComposer} from '/src/components/RichComposer.jsx'; +import {bindDesktopComposerAutoFocus} from '/src/lib/composerCaretRestore.js'; +import '/src/styles/globals.css'; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const events = []; +for (const type of ['focus','blur','acecode:desktop-window-focus']) { + window.addEventListener(type, () => events.push([type, document.hasFocus(), document.activeElement?.outerHTML?.slice(0,100)])); +} +function App() { + const editor = useRef(null); + const [value, setValue] = useState('draft'); + const [enabled, setEnabled] = useState(true); + useEffect(() => { + window.editor = editor; + window.setChatVisible = setEnabled; + return bindDesktopComposerAutoFocus({enabled, + onFocus: canFocus => editor.current?.focus({canFocus})}); + }, [enabled]); + useEffect(() => {window.text = value;}, [value]); + return
+
+ + {enabled && } +
; +} +createRoot(document.getElementById('root')).render(); +window.activate = () => window.dispatchEvent(new Event('acecode:desktop-window-focus')); +if (window.aceFocusTest) { + (async () => { + const checks = []; + try { + await sleep(600); await aceFocusTest('show'); await sleep(400); + for (const [name, operations] of [ + ['window switch', ['away', 'return']], + ['minimize/restore', ['minimize', 'restore']], + ['maximize', ['maximize']], + ['unmaximize', ['unmaximize']], + ['hide/show', ['hide', 'show']], + ]) { + document.querySelector('#terminal').focus(); + for (const op of operations) {await aceFocusTest(op); await sleep(250);} + const before = window.text; + if (document.activeElement?.getAttribute('data-ace-rich-composer') !== 'true') + throw new Error(name + ': composer not active ' + JSON.stringify({focused:document.hasFocus(),active:document.activeElement?.outerHTML?.slice(0,150),events})); + await aceFocusTest('type'); await sleep(100); + if (window.text !== before + 'Z') throw new Error(name + ': real keystroke missing: ' + window.text); + checks.push(name + ': real keyboard input passed'); + } + editor.current.setSelectionRange(1, 3); + await sleep(80); await aceFocusTest('away'); await sleep(150); + const background = await aceDesktop_focusActiveWebview(); + if (background.ok !== false) throw new Error('background window accepted focus'); + await aceFocusTest('return'); await sleep(250); + if (window.getSelection().toString() !== 'ra') throw new Error('selection lost'); + checks.push('background refusal and selection preservation passed'); + const modal = document.createElement('div'); modal.setAttribute('role','dialog'); + modal.innerHTML = ''; document.body.append(modal); + modal.firstChild.focus(); await aceFocusTest('away'); await sleep(150); + await aceFocusTest('return'); await sleep(250); + if (document.activeElement !== modal.firstChild) throw new Error('modal focus stolen'); + checks.push('modal protection passed'); + await aceFocusTest('done', true, checks); + } catch (error) {await aceFocusTest('done', false, {checks, error: String(error)});} + })(); +} +`; +const virtual = path.join(web, '__desktop-focus-fixture.jsx').replaceAll('\\', '/'); +const server = await createServer({ root: web, logLevel: 'error', + server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, + plugins: [{ name: 'desktop-focus-fixture', enforce: 'pre', + resolveId(id) { return id === '/__desktop-focus-fixture.jsx' ? virtual : undefined; }, + load(id) { return id.replaceAll('\\', '/') === virtual ? fixture : undefined; }, + configureServer(vite) { vite.middlewares.use(async (req, res, next) => { + if (req.url !== '/focus-fixture') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/focus-fixture', '
')); + }); }, + }], +}); +try { + await server.listen(); + const url = `http://127.0.0.1:${server.httpServer.address().port}/focus-fixture`; + if (process.env.ACE_FOCUS_NATIVE_HOST) { + const profile = await fs.mkdtemp(path.join(os.tmpdir(), 'ace-focus-webview-')); + await new Promise((resolve, reject) => { + const child = spawn(process.env.ACE_FOCUS_NATIVE_HOST, [url], { windowsHide: true, + env: { ...process.env, WEBVIEW2_USER_DATA_FOLDER: profile }, stdio: 'inherit' }); + const timer = setTimeout(() => {child.kill(); reject(new Error('Native focus test timed out'));}, 60000); + child.on('error', error => {clearTimeout(timer); reject(error);}); + child.on('exit', code => { + clearTimeout(timer); + if (code === 77) { process.exitCode = 77; resolve(); } + else if (code === 0) resolve(); + else reject(new Error(`Native focus test exit ${code}`)); + }); + }); + } else { + const playwright = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); + for (const engine of ['chromium', 'webkit']) { + const browser = await playwright[engine].launch({headless: true}); + try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(url); + const editor = page.locator('[data-ace-rich-composer="true"]'); + await editor.click(); + await page.keyboard.press('End'); + await page.keyboard.type(' test'); + await page.waitForTimeout(100); + await page.evaluate(() => { + window.nativeFocusCalls = 0; + window.aceDesktop_focusActiveWebview = async () => { + window.nativeFocusCalls += 1; + window.dispatchEvent(new Event('focus')); + return {ok: true}; + }; + }); + await page.locator('#terminal').click(); + await page.evaluate(() => window.activate()); + await page.waitForTimeout(150); + await page.keyboard.type('Z'); + assert.equal(await editor.innerText(), 'draft testZ'); + assert.equal(await page.evaluate(() => window.nativeFocusCalls), 0, 'focused pages do not repeat the native focus handoff'); + await page.evaluate(() => { delete window.aceDesktop_focusActiveWebview; }); + await page.evaluate(() => window.editor.current.setSelectionRange(1, 3)); + await page.waitForTimeout(40); + await page.locator('#other').focus(); + await page.evaluate(() => window.activate()); + await page.waitForTimeout(150); + assert.equal(await page.evaluate(() => window.getSelection().toString()), 'ra'); + await page.keyboard.type('X'); + assert.equal(await editor.innerText(), 'dXft testZ'); + await page.evaluate(() => { + const modal = document.createElement('div'); modal.setAttribute('role','dialog'); + modal.innerHTML = ''; document.body.append(modal); + modal.firstChild.focus(); window.activate(); + }); + await page.waitForTimeout(150); + await page.keyboard.type('modal'); + assert.equal(await page.locator('#modal-input').inputValue(), 'modal'); + await page.evaluate(() => { + document.querySelector('[role="dialog"]').remove(); window.activate(); + window.setChatVisible(false); + }); + await page.waitForTimeout(150); + assert.equal(await editor.count(), 0); + assert.deepEqual(errors, []); + console.log(`[pass] ${engine}: keyboard input, terminal handoff, selection, modal, unmount`); + } finally { await browser.close(); } + } + } +} finally { await server.close(); } diff --git a/web/scripts/test-expert-pixel-avatars.mjs b/web/scripts/test-expert-pixel-avatars.mjs new file mode 100644 index 000000000..2329af903 --- /dev/null +++ b/web/scripts/test-expert-pixel-avatars.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const root = path.dirname(web.replace(/[\\/]$/, '')); +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const output = await fs.mkdtemp(path.join(os.tmpdir(), 'ace-pixel-avatars-')); +const shared = await fs.readFile(path.join(root, 'assets/desktop_pet/office_characters.js'), 'utf8'); +const office = (await fs.readFile(path.join(root, 'assets/desktop_pet/agent_office_pet.html'), 'utf8')) + .replace('', ``); +const experts = []; +for (const id of await fs.readdir(path.join(root, 'assets/seed/experts'))) { + const manifest = JSON.parse(await fs.readFile(path.join(root, 'assets/seed/experts', id, 'expert.json'), 'utf8')); + experts.push({ id, display_name: manifest.displayName, type: manifest.expertType, + references_existing_experts: !!manifest.teamInfo, lead_expert_id: manifest.teamInfo?.leadExpert }); +} +const fixture = ` +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {ExpertAvatar} from '/src/components/ExpertCatalog.jsx'; +import {SubagentGroupBlock} from '/src/components/SubagentGroupBlock.jsx'; +import {expertAvatarIdentity,stableLook,stableSeed} from '/src/lib/officeCharacters.js'; +import '/src/styles/globals.css'; +const experts=${JSON.stringify(experts)}; +const member=experts.find(e=>e.id==='opc-value-designer'); +const identity=expertAvatarIdentity(member); +window.avatarIdentity=identity; +window.expectedLook=stableLook(stableSeed(identity)); +const task={id:'child',title:member.display_name,status:'running',expert:{avatar_identity:identity}}; +createRoot(document.getElementById('root')).render(
+
{experts.map(e=>
+ {e.display_name}
)}
+
+
+