diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ee49d0d..0f34b16 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,9 +14,6 @@ }, "remoteUser": "vscode", "updateRemoteUserUID": true, - "containerEnv": { - "API__HOST": "0.0.0.0" - }, "postCreateCommand": "uv sync --frozen --dev", "forwardPorts": [ 8000, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dce724b..8bb03b4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,7 +34,15 @@ updates: multi-ecosystem-group: all-dependencies - package-ecosystem: docker - directory: / + directories: + - / + - /apps/coworker-relay + patterns: + - "*" + multi-ecosystem-group: all-dependencies + + - package-ecosystem: gomod + directory: /apps/coworker-relay patterns: - "*" multi-ecosystem-group: all-dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1840fc6..1ea9a99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,6 +269,52 @@ jobs: pkg-config - run: cargo test --workspace --locked + go_relay: + name: Go Relay + needs: web_bundle + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: apps/coworker-relay + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: apps/coworker-relay/go.mod + cache-dependency-path: apps/coworker-relay/go.sum + - name: Check Go formatting + run: test -z "$(gofmt -l .)" + - name: Test Relay + run: go test -race ./... + - name: Vet Relay + run: go vet ./... + - name: Scan Relay Go dependencies + run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + - name: Build Relay binary + run: go build ./cmd/coworker-relay + - name: Build Relay container + working-directory: ${{ github.workspace }} + run: docker build --tag coworker-relay:ci --file apps/coworker-relay/Dockerfile apps/coworker-relay + - name: Scan Relay container + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: coworker-relay:ci + format: table + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" + - name: Validate initialized Compose deployment + run: | + go run ./cmd/coworker-relay init \ + --dir "$RUNNER_TEMP/relay-deployment" \ + --public-url https://relay.example.com \ + --external-port 8443 + docker compose \ + --env-file "$RUNNER_TEMP/relay-deployment/.env" \ + --file "$RUNNER_TEMP/relay-deployment/compose.yaml" \ + config --quiet + frontend: name: Frontend (${{ matrix.name }}) needs: web_bundle diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index c1fe4e0..415c901 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -64,15 +64,18 @@ jobs: fi image="ghcr.io/${GITHUB_REPOSITORY,,}" + relay_image="${image}-relay" runtime_tags=("$image:$version") embedder_tags=("$image:${version}-with-embedder") offline_tags=("$image:${version}-offline") + relay_tags=("$relay_image:$version") # Prereleases never replace the moving stable aliases. if [[ "$version" != *-* ]]; then runtime_tags+=("$image:latest") embedder_tags+=("$image:with-embedder") offline_tags+=("$image:offline") + relay_tags+=("$relay_image:latest") fi write_tags() { @@ -88,9 +91,12 @@ jobs: write_tags runtime_tags "${runtime_tags[@]}" write_tags embedder_tags "${embedder_tags[@]}" write_tags offline_tags "${offline_tags[@]}" + write_tags relay_tags "${relay_tags[@]}" { echo "image=$image" echo "version=$version" + echo "relay_image=$relay_image" + echo "build_date=$(date -u +%FT%TZ)" } >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx @@ -150,3 +156,22 @@ jobs: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.version=${{ steps.release.outputs.version }} + + - name: Build and push Relay image + uses: docker/build-push-action@v7 + with: + context: ./apps/coworker-relay + file: ./apps/coworker-relay/Dockerfile + build-args: | + RELAY_VERSION=${{ steps.release.outputs.version }} + RELAY_COMMIT=${{ github.sha }} + RELAY_BUILD_DATE=${{ steps.release.outputs.build_date }} + platforms: linux/amd64,linux/arm64 + push: true + provenance: mode=max + sbom: true + tags: ${{ steps.release.outputs.relay_tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.release.outputs.version }} diff --git a/.github/workflows/relay-release.yml b/.github/workflows/relay-release.yml new file mode 100644 index 0000000..da1a581 --- /dev/null +++ b/.github/workflows/relay-release.yml @@ -0,0 +1,126 @@ +name: Publish Relay Binaries + +on: + workflow_dispatch: + inputs: + ref: + description: "Git ref to build" + required: true + type: string + tag: + description: "Existing release tag to receive Relay assets" + required: true + type: string + version: + description: "Version without the v prefix" + required: true + type: string + +permissions: + contents: write + id-token: write + attestations: write + +concurrency: + group: relay-release-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + build: + name: Relay ${{ matrix.goos }}/${{ matrix.goarch }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + archive: tar + - goos: linux + goarch: arm64 + archive: tar + - goos: darwin + goarch: amd64 + archive: tar + - goos: darwin + goarch: arm64 + archive: tar + - goos: windows + goarch: amd64 + archive: zip + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-go@v6 + with: + go-version-file: apps/coworker-relay/go.mod + cache-dependency-path: apps/coworker-relay/go.sum + - name: Validate release version + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + test "$RELEASE_TAG" = "v$RELEASE_VERSION" + test "$(tr -d '\r\n' < VERSION)" = "$RELEASE_VERSION" + - name: Build and package + id: package + working-directory: apps/coworker-relay + shell: bash + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + RELEASE_VERSION: ${{ inputs.version }} + ARCHIVE_FORMAT: ${{ matrix.archive }} + run: | + set -euo pipefail + output="$RUNNER_TEMP/relay-assets" + package="coworker-relay-${RELEASE_VERSION}-${GOOS}-${GOARCH}" + directory="$RUNNER_TEMP/$package" + mkdir -p "$output" "$directory" + suffix="" + if [[ "$GOOS" == windows ]]; then suffix=".exe"; fi + ldflags="-s -w \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Version=$RELEASE_VERSION \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Commit=$GITHUB_SHA \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Date=$(date -u +%FT%TZ)" + CGO_ENABLED=0 go build -trimpath -ldflags="$ldflags" -o "$directory/coworker-relay$suffix" ./cmd/coworker-relay + cp ../../LICENSE "$directory/LICENSE" + go list -m all > "$directory/go-modules.txt" + if [[ "$ARCHIVE_FORMAT" == zip ]]; then + (cd "$RUNNER_TEMP" && zip -q -r "$output/$package.zip" "$package") + asset="$output/$package.zip" + else + tar -C "$RUNNER_TEMP" -czf "$output/$package.tar.gz" "$package" + asset="$output/$package.tar.gz" + fi + (cd "$output" && sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256") + echo "asset=$asset" >> "$GITHUB_OUTPUT" + - name: Attest Relay archive + uses: actions/attest-build-provenance@v3 + with: + subject-path: ${{ steps.package.outputs.asset }} + - uses: actions/upload-artifact@v7 + with: + name: relay-${{ matrix.goos }}-${{ matrix.goarch }} + path: ${{ runner.temp }}/relay-assets/* + if-no-files-found: error + + publish: + name: Attach Relay binaries + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + pattern: relay-* + merge-multiple: true + path: ${{ runner.temp }}/relay-assets + - name: Upload immutable Relay assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: gh release upload "$RELEASE_TAG" "${RUNNER_TEMP}/relay-assets/"* --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2343d71..bd34cc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,3 +245,21 @@ jobs: --field version="$VERSION" echo "Started container publishing for $RELEASE_TAG." + + - name: Start Relay binary release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + gh workflow run relay-release.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$RELEASE_TAG" \ + --field ref="$RELEASE_TAG" \ + --field tag="$RELEASE_TAG" \ + --field version="$VERSION" + + echo "Started Relay binary publishing for $RELEASE_TAG." diff --git a/CONTRIBUTING.en.md b/CONTRIBUTING.en.md index 695b61f..88b80b3 100644 --- a/CONTRIBUTING.en.md +++ b/CONTRIBUTING.en.md @@ -15,6 +15,7 @@ Requirements: - [uv](https://docs.astral.sh/uv/) - Node.js 20+ for web or desktop changes - Stable Rust for bridge or Tauri changes +- Go 1.26.5+ for Relay changes Install the Python workspace and development dependencies: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a5d31d..8ebdd4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ - [uv](https://docs.astral.sh/uv/) - 修改 Web 或桌面端时需要 Node.js 20+ - 修改 Bridge 或 Tauri 时需要稳定版 Rust +- 修改 Relay 时需要 Go 1.26.5+ 安装 Python workspace 和开发依赖: diff --git a/Cargo.lock b/Cargo.lock index 1ed412f..bf5fb93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -499,6 +522,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -606,6 +631,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -700,7 +734,8 @@ version = "0.3.5" dependencies = [ "chrono", "coworker-desktop-core", - "reqwest 0.12.28", + "http", + "reqwest 0.13.4", "serde", "serde_json", "tauri", @@ -1319,6 +1354,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -2151,6 +2192,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -3090,6 +3141,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -3343,6 +3395,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3448,6 +3501,7 @@ version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3511,6 +3565,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", diff --git a/README.en.md b/README.en.md index 49b8ee8..dc1d461 100644 --- a/README.en.md +++ b/README.en.md @@ -334,6 +334,7 @@ data, cleanup scope, and deployment boundaries. | [Data and trust boundaries](docs/architecture/data-boundaries.en.md) | Local storage, external services, permissions, and cleanup | | [API and communication channels](docs/channels/api-and-channels.en.md) | REST, SSE, WebSocket, and file messages | | [Coworker Desktop](docs/channels/desktop.en.md) | Desktop workspace connecting local users, Codex, and Claude Code, plus CLI, configuration, and build guidance | +| [Self-hosted Relay](docs/operations/relay.en.md) | Securely expose Desktop communication from a private network; deployment, pairing, backup, and operations | | [Core concepts and capabilities](docs/architecture/concepts.en.md) | Tools, directories, memory tree, restart recovery, and memory palaces | | [Development guide](docs/development/development.en.md) | Local checks and Explore Lab | diff --git a/README.md b/README.md index f3619d1..4c32760 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ remote 和分支后执行 `git push`。 | [数据与信任边界](docs/architecture/data-boundaries.md) | 本地存储、外部服务、权限与数据清理 | | [API 与通信入口](docs/channels/api-and-channels.md) | REST、SSE、WebSocket 与文件消息 | | [Coworker Desktop](docs/channels/desktop.md) | 连接本机用户、Codex 与 Claude Code 的桌面工作台,以及 CLI、配置与构建说明 | +| [自托管 Relay](docs/operations/relay.md) | 从内网安全暴露 Desktop通信、部署、配对、备份与运维 | | [核心概念与能力](docs/architecture/concepts.md) | 工具、目录、记忆树、重启恢复与记忆宫殿 | | [开发指南](docs/development/development.md) | 本地检查与 Explore Lab | diff --git a/SECURITY.en.md b/SECURITY.en.md index d585298..a0a3002 100644 --- a/SECURITY.en.md +++ b/SECURITY.en.md @@ -41,8 +41,15 @@ For the current v0.x releases: deliberately local HTTP setup; never enable it on a shared or public listener. - Do not expose port 8000 directly to the public internet or an untrusted network. The admin token protects the management API, but it is not a complete authorization boundary for every route. +- Use [self-hosted Relay](docs/operations/relay.en.md) for public Desktop access. Relay terminates + TLS and sees tokens and messages while forwarding, so its host and administrators are inside the + trust boundary. Use the non-root container, pinned image versions, a strong administrator token, + trusted certificates, backups, and a minimal `RELAY_TRUSTED_PROXY_CIDRS`. +- Relay v1 is single-node. Do not share its bbolt volume among replicas or load-balance one + instance's tunnel and requests across different replicas. - Keep `.env`, `providers.json`, runtime data, logs, exported configuration, and desktop credentials out of commits and vulnerability reports. Reports about authentication bypasses, command or path traversal, secret disclosure, unsafe update -handling, and escapes from documented permission boundaries are especially welcome. +handling, Relay cross-instance access, header-boundary confusion, ban bypasses, and escapes from +documented permission boundaries are especially welcome. diff --git a/SECURITY.md b/SECURITY.md index 77545e0..89e731a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,6 +28,13 @@ Coworker 是自主 Agent,不是安全沙箱。它的工具可以用运行进 - API 默认绑定 `127.0.0.1`,并要求 Desktop 通信 Bearer token。通过反向代理暴露服务时,应在代理层终止 TLS,显式设置 `API__HOST`,将 `API__CORS_ORIGINS` 配置为可信浏览器来源,并设置强 `API__COMMUNICATION_TOKEN`。 - `API__DEVELOPMENT_MODE=true` 会关闭 Desktop Bearer 和 HTTPS 检查。它只适用于刻意配置的本机 HTTP 环境,绝不能在共享或公开监听地址上启用。 - 不要把 8000 端口直接暴露到公网或不可信网络。管理员令牌会保护管理 API,但它并不是每个路由的完整授权边界。 +- 公网 Desktop访问应使用[自托管 Relay](docs/operations/relay.md)。Relay会终止 TLS并在 + 转发期间看到 Token和消息,因此 Relay主机及管理员属于信任边界。使用非 root容器、 + 固定版本镜像、强管理员 Token、受信任证书、备份和最小化的 + `RELAY_TRUSTED_PROXY_CIDRS`。 +- Relay v1 只支持单节点。不要让多个副本共享 bbolt数据卷;不要通过负载均衡器把同一 + 实例的隧道和请求分配到不同副本。 - 不要把 `.env`、`providers.json`、运行时数据、日志、导出的配置和桌面端凭据提交到仓库或附在漏洞报告中。 -我们尤其欢迎报告身份验证绕过、命令或路径遍历、密钥泄露、不安全的更新处理,以及逃逸已记录权限边界的问题。 +我们尤其欢迎报告身份验证绕过、命令或路径遍历、密钥泄露、不安全的更新处理、Relay +跨实例访问、Header边界混淆、封禁绕过,以及逃逸已记录权限边界的问题。 diff --git a/apps/coworker-desktop/bridge/src/command_resolver.rs b/apps/coworker-desktop/bridge/src/command_resolver.rs index 814ca30..53d86b3 100644 --- a/apps/coworker-desktop/bridge/src/command_resolver.rs +++ b/apps/coworker-desktop/bridge/src/command_resolver.rs @@ -226,10 +226,7 @@ fn default_macos_codex_app_paths(home_value: Option<&OsStr>) -> Vec { #[cfg(any(not(windows), test))] fn default_unix_runtime_bin_paths(home_value: Option<&OsStr>) -> Vec { - let mut paths = vec![ - PathBuf::from("/opt/homebrew/bin"), - PathBuf::from("/usr/local/bin"), - ]; + let mut paths = Vec::new(); if let Some(home) = home_value { let home = PathBuf::from(home); paths.extend([ @@ -241,6 +238,10 @@ fn default_unix_runtime_bin_paths(home_value: Option<&OsStr>) -> Vec { ]); paths.extend(nvm_bin_paths(&home)); } + paths.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); paths.retain(|path| path.is_dir()); paths } diff --git a/apps/coworker-desktop/desktop/src-tauri/Cargo.toml b/apps/coworker-desktop/desktop/src-tauri/Cargo.toml index fea2de9..71a0f21 100644 --- a/apps/coworker-desktop/desktop/src-tauri/Cargo.toml +++ b/apps/coworker-desktop/desktop/src-tauri/Cargo.toml @@ -18,7 +18,8 @@ tauri-build = { version = "2", features = [] } [dependencies] chrono = { version = "0.4", features = ["serde"] } coworker-desktop-core = { path = "../../bridge" } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +http = "1" +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tauri = { version = "2", features = ["tray-icon"] } diff --git a/apps/coworker-desktop/desktop/src-tauri/src/lib.rs b/apps/coworker-desktop/desktop/src-tauri/src/lib.rs index cf674a1..bd848c2 100644 --- a/apps/coworker-desktop/desktop/src-tauri/src/lib.rs +++ b/apps/coworker-desktop/desktop/src-tauri/src/lib.rs @@ -24,6 +24,7 @@ use coworker_desktop_core::{ logging::{error_chain, init_logging, log_file_path, subscribe_log_events}, runtime::{BridgeRuntime, BridgeRuntimeStatus}, }; +use http::header::{AUTHORIZATION, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::{ @@ -759,7 +760,19 @@ async fn check_desktop_update( return Err(to_message(error)); } }; - let builder = match app.updater_builder().endpoints(vec![endpoint]) { + let mut update_headers = HeaderMap::new(); + if let Some(token) = desktop_update_bearer_token(&app, &endpoint) { + let mut authorization = + HeaderValue::from_str(&format!("Bearer {token}")).map_err(to_message)?; + authorization.set_sensitive(true); + update_headers.insert(AUTHORIZATION, authorization); + } + let builder = match app + .updater_builder() + .headers(update_headers) + .configure_client(|client| client.redirect(reqwest::redirect::Policy::none())) + .endpoints(vec![endpoint]) + { Ok(builder) => builder, Err(error) => { desktop_log_warn(format!( @@ -823,6 +836,47 @@ fn desktop_update_configured_endpoint(app: &tauri::AppHandle) -> Option .map(str::to_owned) } +fn desktop_update_bearer_token(app: &tauri::AppHandle, endpoint: &Url) -> Option { + if endpoint.scheme() != "https" { + return None; + } + let desktop = load_desktop_config_or_default(app, None).ok()?; + desktop_update_bearer_token_for_config(&desktop, endpoint) +} + +fn desktop_update_bearer_token_for_config( + desktop: &DesktopConfig, + endpoint: &Url, +) -> Option { + desktop + .codex + .coworkers + .iter() + .filter_map(|coworker| { + let base = Url::parse(coworker.base_url.trim_end_matches('/')).ok()?; + let same_origin = base.scheme() == endpoint.scheme() + && base.host_str() == endpoint.host_str() + && base.port_or_known_default() == endpoint.port_or_known_default(); + let base_path = base.path().trim_end_matches('/'); + let path_matches = endpoint.path() == base_path + || endpoint + .path() + .strip_prefix(base_path) + .is_some_and(|suffix| suffix.starts_with('/')); + (same_origin && path_matches).then_some(( + base_path.len(), + desktop + .security + .bearer_tokens + .get(&coworker.coworker_id) + .cloned(), + )) + }) + .max_by_key(|(path_len, _)| *path_len) + .and_then(|(_, token)| token) + .filter(|token| !token.trim().is_empty()) +} + #[tauri::command] async fn install_desktop_update( app: tauri::AppHandle, @@ -1752,6 +1806,50 @@ mod tests { std::env::temp_dir().join(format!("coworker-desktop-app-test-{}-{name}", unique)) } + #[test] + fn relay_update_endpoint_uses_matching_coworker_bearer() { + let mut value = default_config_value_with_display_name( + "desktop-test", + "Desktop Test", + "https://relay.example.com/i/cw_abcdefgh", + ); + value["coworkers"][0]["bearer_token"] = Value::String("relay-secret".into()); + let desktop = DesktopConfig::from_value(value).expect("desktop config"); + let endpoint = Url::parse( + "https://relay.example.com/i/cw_abcdefgh/api/desktop-updates/{{target}}/{{arch}}/{{current_version}}", + ) + .expect("update endpoint"); + + assert_eq!( + desktop_update_bearer_token_for_config(&desktop, &endpoint).as_deref(), + Some("relay-secret") + ); + } + + #[test] + fn update_bearer_is_not_sent_to_a_different_origin_or_instance() { + let mut value = default_config_value_with_display_name( + "desktop-test", + "Desktop Test", + "https://relay.example.com/i/cw_abcdefgh", + ); + value["coworkers"][0]["bearer_token"] = Value::String("relay-secret".into()); + let desktop = DesktopConfig::from_value(value).expect("desktop config"); + for endpoint in [ + "https://updates.example.com/api/desktop-updates/x/y/z", + "https://relay.example.com/i/cw_other000/api/desktop-updates/x/y/z", + "http://relay.example.com/i/cw_abcdefgh/api/desktop-updates/x/y/z", + ] { + assert_eq!( + desktop_update_bearer_token_for_config( + &desktop, + &Url::parse(endpoint).expect("endpoint") + ), + None + ); + } + } + #[test] fn image_preview_formats_exclude_active_vector_content() { assert!(is_previewable_image_path(Path::new("preview.PNG"))); diff --git a/apps/coworker-relay/.dockerignore b/apps/coworker-relay/.dockerignore new file mode 100644 index 0000000..9eb37f1 --- /dev/null +++ b/apps/coworker-relay/.dockerignore @@ -0,0 +1,2 @@ +coworker-relay +coworker-relay-deploy diff --git a/apps/coworker-relay/.gitignore b/apps/coworker-relay/.gitignore new file mode 100644 index 0000000..cf8f2fa --- /dev/null +++ b/apps/coworker-relay/.gitignore @@ -0,0 +1,2 @@ +/coworker-relay +/coworker-relay-deploy/ diff --git a/apps/coworker-relay/Dockerfile b/apps/coworker-relay/Dockerfile new file mode 100644 index 0000000..973bc50 --- /dev/null +++ b/apps/coworker-relay/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.26.5-alpine AS build +ARG RELAY_VERSION=dev +ARG RELAY_COMMIT=unknown +ARG RELAY_BUILD_DATE=unknown +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN ldflags="-s -w \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Version=${RELAY_VERSION} \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Commit=${RELAY_COMMIT} \ + -X github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo.Date=${RELAY_BUILD_DATE}" \ + && CGO_ENABLED=0 go build -trimpath -ldflags="$ldflags" -o /out/coworker-relay ./cmd/coworker-relay + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates \ + && addgroup -S relay \ + && adduser -S -G relay relay +COPY --from=build /out/coworker-relay /usr/local/bin/coworker-relay +RUN mkdir -p /var/lib/coworker-relay && chown relay:relay /var/lib/coworker-relay +USER relay +VOLUME ["/var/lib/coworker-relay"] +EXPOSE 8080 8443 +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 CMD ["coworker-relay", "health"] +ENTRYPOINT ["coworker-relay"] +CMD ["serve"] diff --git a/apps/coworker-relay/cmd/coworker-relay/cli.go b/apps/coworker-relay/cmd/coworker-relay/cli.go new file mode 100644 index 0000000..50e33aa --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/cli.go @@ -0,0 +1,1062 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/netutil" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/store" + "golang.org/x/term" +) + +type client struct { + baseURL string + token string + http *http.Client +} + +func runCLI(args []string) { + if handled, err := handleHelp(args, os.Stdout); handled { + if err != nil { + fatal(err.Error()) + } + return + } + if args[0] == "init" { + initDeployment(args[1:]) + return + } + if args[0] == "version" { + if len(args) != 1 { + usage() + } + fmt.Printf( + "coworker-relay %s (%s, %s)\n", + buildinfo.Version, + buildinfo.Commit, + buildinfo.Date, + ) + return + } + if args[0] == "restore" { + restoreDatabase(args[1:]) + return + } + switch args[0] { + case "health", "instance", "bans", "cache", "backup", "gc", "metrics": + default: + usage() + } + if err := loadLocalEnvironment(); err != nil { + fatal(err.Error()) + } + httpClient, err := newHTTPClient() + if err != nil { + fatal(err.Error()) + } + c := client{ + baseURL: strings.TrimRight(firstEnvironment("RELAY_URL", "RELAY_PUBLIC_URL"), "/"), + token: os.Getenv("RELAY_ADMIN_TOKEN"), + http: httpClient, + } + if c.baseURL == "" { + fatal("RELAY_URL is required") + } + switch args[0] { + case "health": + c.print("GET", "/_relay/v1/health", nil, false) + case "instance": + c.instance(args[1:]) + case "bans": + c.bans(args[1:]) + case "cache": + c.cache(args[1:]) + case "backup": + c.backup(args[1:]) + case "gc": + c.print("POST", "/_relay/v1/admin/gc", nil, true) + case "metrics": + c.print("GET", "/_relay/v1/admin/metrics", nil, true) + default: + usage() + } +} + +func firstEnvironment(names ...string) string { + for _, name := range names { + if value := os.Getenv(name); value != "" { + return value + } + } + return "" +} + +func newHTTPClient() (*http.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + if path := strings.TrimSpace(os.Getenv("RELAY_CA_CERT")); path != "" { + certificate, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read RELAY_CA_CERT: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil || roots == nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(certificate) { + return nil, errors.New("RELAY_CA_CERT contains no valid PEM certificates") + } + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + } + } + return &http.Client{Timeout: 20 * time.Second, Transport: transport}, nil +} + +type initOptions struct { + directory string + publicURL string + externalPort int + acmeDomain string + tlsCert string + tlsKey string + image string + force bool +} + +var errInitCancelled = errors.New("initialization cancelled") + +func initDeployment(args []string) { + options := defaultInitOptions() + var err error + if len(args) == 0 { + if !isTerminal(os.Stdin) { + fatal("interactive init requires a terminal; use --public-url for non-interactive setup") + } + options, err = promptInitOptions(os.Stdin, os.Stderr, options) + if errors.Is(err, errInitCancelled) { + fmt.Fprintln(os.Stdout, "Initialization cancelled.") + return + } + } else { + options, err = parseInitOptions(args) + } + if err != nil { + fatal(err.Error()) + } + _, normalizedURL, err := initialize(options) + if err != nil { + fatal(err.Error()) + } + absolute, _ := filepath.Abs(options.directory) + fmt.Printf( + "Relay deployment initialized in %s\nPublic URL: %s\nAdministrator token: saved only in %s\n\nNext:\n cd %s\n docker compose up -d\n coworker-relay health\n coworker-relay instance create --name home-coworker\n", + absolute, + normalizedURL, + filepath.Join(absolute, ".env"), + absolute, + ) +} + +func defaultInitOptions() initOptions { + return initOptions{ + directory: "coworker-relay-deploy", + externalPort: 8443, + image: "ghcr.io/virtualbeingsresearch/coworker-relay:" + releaseTag(), + } +} + +func parseInitOptions(args []string) (initOptions, error) { + options := defaultInitOptions() + flags := flag.NewFlagSet("init", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&options.directory, "dir", "coworker-relay-deploy", "deployment directory") + flags.StringVar(&options.publicURL, "public-url", "", "public HTTPS URL") + flags.IntVar(&options.externalPort, "external-port", 8443, "published HTTPS port") + flags.StringVar(&options.acmeDomain, "acme-domain", "", "ACME certificate domain") + flags.StringVar(&options.tlsCert, "tls-cert", "", "host path to PEM certificate") + flags.StringVar(&options.tlsKey, "tls-key", "", "host path to PEM private key") + flags.StringVar( + &options.image, + "image", + options.image, + "container image", + ) + flags.BoolVar(&options.force, "force", false, "replace generated files") + if err := flags.Parse(args); err != nil { + return initOptions{}, err + } + if flags.NArg() != 0 { + return initOptions{}, fmt.Errorf("unexpected init argument %q", flags.Arg(0)) + } + return options, nil +} + +func promptInitOptions( + input io.Reader, + output io.Writer, + options initOptions, +) (initOptions, error) { + fmt.Fprintln(output, "Coworker Relay setup") + fmt.Fprintln(output, "Press Enter to accept a displayed default.") + reader, err := newInitLineReader(input, output) + if err != nil { + return initOptions{}, err + } + + options.directory, err = prompt(reader, output, "Deployment directory", options.directory) + if err != nil { + return initOptions{}, err + } + for { + rawPort, promptErr := prompt( + reader, + output, + "Published HTTPS port", + strconv.Itoa(options.externalPort), + ) + if promptErr != nil { + return initOptions{}, promptErr + } + port, parseErr := strconv.Atoi(rawPort) + if parseErr == nil && port >= 1 && port <= 65535 { + options.externalPort = port + break + } + fmt.Fprintln(output, "Enter a port between 1 and 65535.") + } + originExample := "https://relay.example.com" + if options.externalPort != 443 { + originExample = net.JoinHostPort("relay.example.com", strconv.Itoa(options.externalPort)) + originExample = "https://" + originExample + } + fmt.Fprintln( + output, + "Public HTTPS origin is the externally reachable Relay address (no path).", + ) + fmt.Fprintf(output, "Example: %s\n", originExample) + for { + options.publicURL, err = prompt(reader, output, "Public HTTPS origin", "") + if err != nil { + return initOptions{}, err + } + if options.publicURL == "" { + fmt.Fprintln(output, "A public HTTPS origin is required.") + continue + } + if _, normalized, normalizeErr := normalizePublicURL( + options.publicURL, + options.externalPort, + ); normalizeErr == nil { + options.publicURL = normalized + break + } else { + fmt.Fprintln(output, normalizeErr) + } + } + parsed, _, _ := normalizePublicURL(options.publicURL, options.externalPort) + hostname := parsed.Hostname() + ip := net.ParseIP(hostname) + if ip != nil && !netutil.IsPublicIP(ip) { + fmt.Fprintln( + output, + "Private or non-routable IP detected; use a certificate from your private CA.", + ) + options.tlsCert, err = requiredPrompt(reader, output, "TLS certificate path") + if err != nil { + return initOptions{}, err + } + options.tlsKey, err = requiredPrompt(reader, output, "TLS private key path") + if err != nil { + return initOptions{}, err + } + } else { + acmeDescription := " 1) Automatic ACME certificate (requires Internet-facing TCP port 80 for validation)\n" + + " 2) Existing PEM certificate" + if ip != nil { + acmeDescription = " 1) Automatic public-IP certificate (requires Internet-facing TCP port 80 for validation)\n" + + " 2) Existing PEM certificate" + } + mode, modeErr := promptChoice( + reader, + output, + "TLS mode", + "acme", + map[string]string{ + "1": "acme", "acme": "acme", + "2": "pem", "pem": "pem", + }, + acmeDescription, + ) + if modeErr != nil { + return initOptions{}, modeErr + } + if mode == "acme" { + options.acmeDomain = hostname + if ip == nil { + options.acmeDomain, err = prompt( + reader, + output, + "ACME certificate domain", + hostname, + ) + if err != nil { + return initOptions{}, err + } + } + } else { + options.tlsCert, err = requiredPrompt(reader, output, "TLS certificate path") + if err != nil { + return initOptions{}, err + } + options.tlsKey, err = requiredPrompt(reader, output, "TLS private key path") + if err != nil { + return initOptions{}, err + } + } + } + options.image, err = prompt(reader, output, "Container image", options.image) + if err != nil { + return initOptions{}, err + } + existing, err := generatedDeploymentExists(options.directory) + if err != nil { + return initOptions{}, err + } + if existing { + replace, confirmErr := promptConfirm( + reader, + output, + "Generated deployment files already exist. Replace them?", + false, + ) + if confirmErr != nil { + return initOptions{}, confirmErr + } + if !replace { + return initOptions{}, errInitCancelled + } + options.force = true + } + modeSummary := "ACME for " + options.acmeDomain + if options.tlsCert != "" { + modeSummary = "PEM certificate " + options.tlsCert + } + fmt.Fprintf( + output, + "\nDeployment summary:\n Directory: %s\n Public URL: %s\n TLS: %s\n Image: %s\n", + options.directory, + options.publicURL, + modeSummary, + options.image, + ) + confirmed, err := promptConfirm(reader, output, "Create deployment files?", true) + if err != nil { + return initOptions{}, err + } + if !confirmed { + return initOptions{}, errInitCancelled + } + return options, nil +} + +type initLineReader interface { + ReadLine(prompt string) (string, error) +} + +type bufferedInitLineReader struct { + reader *bufio.Reader + output io.Writer +} + +func (r *bufferedInitLineReader) ReadLine(prompt string) (string, error) { + fmt.Fprint(r.output, prompt) + value, err := r.reader.ReadString('\n') + if err != nil && !(errors.Is(err, io.EOF) && value != "") { + return "", errors.New("interactive input ended") + } + return value, nil +} + +type terminalInitLineReader struct { + terminal *term.Terminal + prepare func() (func(), error) +} + +func (r *terminalInitLineReader) ReadLine(prompt string) (string, error) { + restore := func() {} + if r.prepare != nil { + var err error + restore, err = r.prepare() + if err != nil { + return "", err + } + } + defer restore() + r.terminal.SetPrompt(prompt) + value, err := r.terminal.ReadLine() + if err != nil { + return "", errors.New("interactive input ended") + } + return value, nil +} + +type splitReadWriter struct { + io.Reader + io.Writer +} + +func newInitLineReader( + input io.Reader, + output io.Writer, +) (initLineReader, error) { + file, terminalInput := input.(*os.File) + if !terminalInput || !isTerminal(file) { + return &bufferedInitLineReader{ + reader: bufio.NewReader(input), + output: output, + }, nil + } + terminal := term.NewTerminal(splitReadWriter{Reader: input, Writer: output}, "") + prepare := func() (func(), error) { + state, err := term.MakeRaw(int(file.Fd())) + if err != nil { + return nil, fmt.Errorf("enable terminal line editing: %w", err) + } + return func() { + _ = term.Restore(int(file.Fd()), state) + }, nil + } + return &terminalInitLineReader{ + terminal: terminal, + prepare: prepare, + }, nil +} + +func prompt( + reader initLineReader, + output io.Writer, + label string, + defaultValue string, +) (string, error) { + promptText := label + ": " + if defaultValue == "" { + promptText = label + ": " + } else { + promptText = fmt.Sprintf("%s [%s]: ", label, defaultValue) + } + value, err := reader.ReadLine(promptText) + if err != nil { + return "", err + } + value = strings.TrimSpace(value) + if value == "" { + return defaultValue, nil + } + return value, nil +} + +func requiredPrompt( + reader initLineReader, + output io.Writer, + label string, +) (string, error) { + for { + value, err := prompt(reader, output, label, "") + if err != nil { + return "", err + } + if value != "" { + return value, nil + } + fmt.Fprintln(output, "A value is required.") + } +} + +func promptChoice( + reader initLineReader, + output io.Writer, + label string, + defaultValue string, + choices map[string]string, + description string, +) (string, error) { + for { + fmt.Fprintln(output, description) + value, err := prompt(reader, output, label, defaultValue) + if err != nil { + return "", err + } + if choice, ok := choices[strings.ToLower(value)]; ok { + return choice, nil + } + fmt.Fprintln(output, "Choose one of the listed options.") + } +} + +func promptConfirm( + reader initLineReader, + output io.Writer, + label string, + defaultValue bool, +) (bool, error) { + defaultLabel := "y/N" + if defaultValue { + defaultLabel = "Y/n" + } + for { + value, readErr := reader.ReadLine(fmt.Sprintf("%s [%s]: ", label, defaultLabel)) + if readErr != nil { + return false, readErr + } + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return defaultValue, nil + } + switch value { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + fmt.Fprintln(output, "Enter yes or no.") + } + } +} + +func generatedDeploymentExists(directory string) (bool, error) { + absolute, err := filepath.Abs(directory) + if err != nil { + return false, err + } + for _, name := range []string{".env", "compose.yaml", ".gitignore"} { + _, statErr := os.Stat(filepath.Join(absolute, name)) + if statErr == nil { + return true, nil + } + if !errors.Is(statErr, os.ErrNotExist) { + return false, statErr + } + } + return false, nil +} + +func isTerminal(file *os.File) bool { + return term.IsTerminal(int(file.Fd())) +} + +func initialize(options initOptions) (string, string, error) { + if options.externalPort < 1 || options.externalPort > 65535 { + return "", "", errors.New("--external-port must be between 1 and 65535") + } + parsed, normalizedURL, err := normalizePublicURL(options.publicURL, options.externalPort) + if err != nil { + return "", "", err + } + if options.image == "" || strings.ContainsAny(options.image, "\r\n") { + return "", "", errors.New("--image must not be empty or contain newlines") + } + pemMode := options.tlsCert != "" || options.tlsKey != "" + if pemMode && (options.tlsCert == "" || options.tlsKey == "") { + return "", "", errors.New("--tls-cert and --tls-key must be provided together") + } + if pemMode && options.acmeDomain != "" { + return "", "", errors.New("choose either ACME or PEM certificate mode") + } + if !pemMode { + if options.acmeDomain == "" { + options.acmeDomain = parsed.Hostname() + } + options.acmeDomain = strings.TrimSpace(options.acmeDomain) + if options.acmeDomain == "" || strings.ContainsAny(options.acmeDomain, "\r\n") { + return "", "", errors.New("--acme-domain must be a hostname or public IP address") + } + if !strings.EqualFold(options.acmeDomain, parsed.Hostname()) { + return "", "", errors.New("--acme-domain must match the public URL hostname") + } + if ip := net.ParseIP(options.acmeDomain); ip != nil && !netutil.IsPublicIP(ip) { + return "", "", errors.New( + "a private or non-routable IP address requires --tls-cert and --tls-key", + ) + } + } + directory, err := filepath.Abs(options.directory) + if err != nil { + return "", "", err + } + envPath := filepath.Join(directory, ".env") + composePath := filepath.Join(directory, "compose.yaml") + gitignorePath := filepath.Join(directory, ".gitignore") + if !options.force { + for _, path := range []string{envPath, composePath, gitignorePath} { + if _, err := os.Stat(path); err == nil { + return "", "", fmt.Errorf("%s already exists; use --force to replace generated files", path) + } else if !errors.Is(err, os.ErrNotExist) { + return "", "", err + } + } + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", "", err + } + adminToken, err := randomAdminToken() + if err != nil { + return "", "", err + } + env := strings.Join([]string{ + "RELAY_PUBLIC_URL=" + normalizedURL, + "RELAY_URL=" + normalizedURL, + "RELAY_ADMIN_TOKEN=" + adminToken, + "RELAY_LISTEN=:8443", + "RELAY_DATABASE=/var/lib/coworker-relay/relay.db", + "RELAY_CACHE_DIR=/var/lib/coworker-relay/cache", + "RELAY_CACHE_MAX_BYTES=4294967296", + "RELAY_REQUESTS_PER_MINUTE=600", + "RELAY_ANONYMOUS_PER_MINUTE=60", + "RELAY_BAN_FAILURE_LIMIT=5", + "RELAY_BAN_FAILURE_WINDOW=10m", + "RELAY_BAN_DURATION=1h", + "RELAY_MAX_REQUEST_BODY_BYTES=33554432", + "RELAY_MAX_TUNNEL_FRAME_BYTES=50331648", + "RELAY_EXTERNAL_PORT=" + strconv.Itoa(options.externalPort), + "RELAY_IMAGE=" + options.image, + }, "\n") + "\n" + composeVolumes := " - relay-data:/var/lib/coworker-relay\n" + ports := " - \"${RELAY_EXTERNAL_PORT}:8443\"\n" + if pemMode { + cert, certErr := filepath.Abs(options.tlsCert) + if certErr != nil { + return "", "", certErr + } + key, keyErr := filepath.Abs(options.tlsKey) + if keyErr != nil { + return "", "", keyErr + } + env += "RELAY_TLS_CERT=/run/tls/fullchain.pem\nRELAY_TLS_KEY=/run/tls/privkey.pem\n" + composeVolumes += " - " + strconv.Quote(cert+":/run/tls/fullchain.pem:ro") + "\n" + composeVolumes += " - " + strconv.Quote(key+":/run/tls/privkey.pem:ro") + "\n" + } else { + if net.ParseIP(options.acmeDomain) != nil { + env += "RELAY_ACME_IP=" + options.acmeDomain + "\n" + } else { + env += "RELAY_ACME_DOMAIN=" + options.acmeDomain + "\n" + } + env += "RELAY_ACME_HTTP_LISTEN=:8080\n" + env += "RELAY_ACME_CACHE=/var/lib/coworker-relay/acme\n" + ports += " - \"80:8080\"\n" + } + compose := "services:\n" + + " relay:\n" + + " image: ${RELAY_IMAGE}\n" + + " restart: unless-stopped\n" + + " stop_grace_period: 35s\n" + + " env_file:\n" + + " - .env\n" + + " ports:\n" + ports + + " volumes:\n" + composeVolumes + + " read_only: true\n" + + " pids_limit: 256\n" + + " ulimits:\n" + + " nofile:\n" + + " soft: 65536\n" + + " hard: 65536\n" + + " tmpfs:\n" + + " - /tmp\n" + + " healthcheck:\n" + + " test: [\"CMD\", \"coworker-relay\", \"health\"]\n" + + " interval: 30s\n" + + " timeout: 10s\n" + + " retries: 3\n" + + " start_period: 15s\n" + + " logging:\n" + + " driver: json-file\n" + + " options:\n" + + " max-size: \"10m\"\n" + + " max-file: \"5\"\n\n" + + "volumes:\n" + + " relay-data:\n" + flag := os.O_WRONLY | os.O_CREATE + if options.force { + flag |= os.O_TRUNC + } else { + flag |= os.O_EXCL + } + if err := writeFile(envPath, []byte(env), 0o600, flag); err != nil { + return "", "", err + } + if err := writeFile(composePath, []byte(compose), 0o644, flag); err != nil { + return "", "", err + } + if err := writeFile(gitignorePath, []byte(".env\n"), 0o644, flag); err != nil { + return "", "", err + } + return adminToken, normalizedURL, nil +} + +func releaseTag() string { + if buildinfo.Version == "" || buildinfo.Version == "dev" { + return "latest" + } + return buildinfo.Version +} + +func normalizePublicURL(raw string, externalPort int) (*url.URL, string, error) { + parsed, err := url.Parse(strings.TrimSpace(strings.TrimRight(raw, "/"))) + if err != nil || + parsed.Scheme != "https" || + parsed.Hostname() == "" || + parsed.User != nil || + parsed.Path != "" || + parsed.RawQuery != "" || + parsed.Fragment != "" { + return nil, "", errors.New("--public-url must be an HTTPS origin") + } + if parsed.Port() == "" && externalPort != 443 { + parsed.Host = net.JoinHostPort(parsed.Hostname(), strconv.Itoa(externalPort)) + } else if parsed.Port() != "" && parsed.Port() != strconv.Itoa(externalPort) { + return nil, "", errors.New("--public-url port must match --external-port") + } + return parsed, parsed.String(), nil +} + +func randomAdminToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func writeFile(path string, body []byte, mode os.FileMode, flag int) error { + file, err := os.OpenFile(path, flag, mode) + if err != nil { + return err + } + if err := file.Chmod(mode); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(body); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +func loadLocalEnvironment() error { + path := os.Getenv("RELAY_CONFIG") + explicit := path != "" + if path == "" { + path = ".env" + } + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) && !explicit { + return nil + } + return fmt.Errorf("read Relay configuration %s: %w", path, err) + } + for lineNumber, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + name, value, ok := strings.Cut(line, "=") + name = strings.TrimSpace(name) + if !ok { + return fmt.Errorf( + "invalid Relay configuration %s:%d", + path, + lineNumber+1, + ) + } + if strings.HasPrefix(name, "RELAY_") { + if _, exists := os.LookupEnv(name); exists { + continue + } + if err := os.Setenv(name, value); err != nil { + return fmt.Errorf( + "invalid Relay configuration variable %s: %w", + name, + err, + ) + } + } + } + return nil +} + +func (c client) cache(args []string) { + if len(args) != 1 { + usage() + } + switch args[0] { + case "inspect": + c.print("GET", "/_relay/v1/admin/cache", nil, true) + case "purge": + c.print("DELETE", "/_relay/v1/admin/cache", nil, true) + default: + usage() + } +} + +func (c client) instance(args []string) { + if len(args) == 0 { + usage() + } + switch args[0] { + case "create": + flags := flag.NewFlagSet("instance create", flag.ExitOnError) + name := flags.String("name", "", "display name") + _ = flags.Parse(args[1:]) + c.print("POST", "/_relay/v1/admin/instances", map[string]string{"name": *name}, true) + case "list": + c.print("GET", "/_relay/v1/admin/instances", nil, true) + case "revoke": + if len(args) != 2 { + fatal("usage: coworker-relay instance revoke ") + } + c.print("DELETE", "/_relay/v1/admin/instances/"+url.PathEscape(args[1]), nil, true) + case "rotate-credential": + if len(args) != 2 { + fatal("usage: coworker-relay instance rotate-credential ") + } + path := "/_relay/v1/admin/instances/" + url.PathEscape(args[1]) + "/rotate-credential" + c.print("POST", path, nil, true) + case "update-auth": + if len(args) != 3 || (args[2] != "optional" && args[2] != "required") { + fatal("usage: coworker-relay instance update-auth ") + } + path := "/_relay/v1/admin/instances/" + url.PathEscape(args[1]) + "/update-auth" + c.print("PATCH", path, map[string]string{"mode": args[2]}, true) + case "update-stats": + if len(args) != 2 { + fatal("usage: coworker-relay instance update-stats ") + } + path := "/_relay/v1/admin/instances/" + url.PathEscape(args[1]) + "/update-stats" + c.print("GET", path, nil, true) + default: + usage() + } +} + +func (c client) backup(args []string) { + flags := flag.NewFlagSet("backup", flag.ExitOnError) + output := flags.String("output", "", "backup output path") + _ = flags.Parse(args) + if *output == "" { + *output = "coworker-relay-" + time.Now().UTC().Format("20060102T150405Z") + ".db" + } + request, err := http.NewRequest("GET", c.baseURL+"/_relay/v1/admin/backup", nil) + if err != nil { + fatal(err.Error()) + } + if c.token == "" { + fatal("RELAY_ADMIN_TOKEN is required") + } + request.Header.Set("Authorization", "Bearer "+c.token) + response, err := c.http.Do(request) + if err != nil { + fatal(err.Error()) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + fatal(fmt.Sprintf("%s: %s", response.Status, strings.TrimSpace(string(raw)))) + } + file, err := os.OpenFile(*output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + fatal(err.Error()) + } + _, copyErr := io.Copy(file, response.Body) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(*output) + if copyErr != nil { + fatal(copyErr.Error()) + } + fatal(closeErr.Error()) + } + fmt.Println(*output) +} + +func restoreDatabase(args []string) { + flags := flag.NewFlagSet("restore", flag.ExitOnError) + source := flags.String("from", "", "backup database path") + destination := flags.String("database", "", "stopped Relay database path") + force := flags.Bool("force", false, "replace an existing database after preserving it") + _ = flags.Parse(args) + if *source == "" || *destination == "" { + fatal("--from and --database are required") + } + sourcePath, sourceErr := filepath.Abs(*source) + destinationPath, destinationErr := filepath.Abs(*destination) + if sourceErr != nil || destinationErr != nil { + fatal("unable to resolve backup or database path") + } + if sourcePath == destinationPath { + fatal("--from and --database must be different paths") + } + *source, *destination = sourcePath, destinationPath + if err := store.Validate(*source); err != nil { + fatal("backup validation failed: " + err.Error()) + } + if _, err := os.Stat(*destination); err == nil { + if !*force { + fatal("destination exists; stop Relay and pass --force to preserve and replace it") + } + preserved := *destination + ".before-restore-" + time.Now().UTC().Format("20060102T150405Z") + if err := os.Rename(*destination, preserved); err != nil { + fatal(err.Error()) + } + fmt.Fprintln(os.Stderr, "preserved previous database:", preserved) + } else if !errors.Is(err, os.ErrNotExist) { + fatal(err.Error()) + } + input, err := os.Open(*source) + if err != nil { + fatal(err.Error()) + } + defer input.Close() + output, err := os.OpenFile(*destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + fatal(err.Error()) + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(*destination) + if copyErr != nil { + fatal(copyErr.Error()) + } + fatal(closeErr.Error()) + } + fmt.Println(*destination) +} + +func (c client) bans(args []string) { + if len(args) == 0 { + usage() + } + switch args[0] { + case "list": + flags := flag.NewFlagSet("bans list", flag.ExitOnError) + instance := flags.String("instance", "", "instance ID") + _ = flags.Parse(args[1:]) + path := "/_relay/v1/admin/bans" + if *instance != "" { + path += "?instance=" + url.QueryEscape(*instance) + } + c.print("GET", path, nil, true) + case "remove": + flags := flag.NewFlagSet("bans remove", flag.ExitOnError) + instance := flags.String("instance", "", "instance ID") + ip := flags.String("ip", "", "source IP") + reason := flags.String("reason", "", "audit reason") + _ = flags.Parse(args[1:]) + if *instance == "" || *ip == "" || strings.TrimSpace(*reason) == "" { + fatal("--instance, --ip and --reason are required") + } + query := url.Values{"instance": {*instance}, "ip": {*ip}, "reason": {*reason}} + c.print("DELETE", "/_relay/v1/admin/bans?"+query.Encode(), nil, true) + default: + usage() + } +} + +func (c client) print(method, path string, body any, admin bool) { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + fatal(err.Error()) + } + reader = bytes.NewReader(raw) + } + request, err := http.NewRequest(method, c.baseURL+path, reader) + if err != nil { + fatal(err.Error()) + } + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if admin { + if c.token == "" { + fatal("RELAY_ADMIN_TOKEN is required") + } + request.Header.Set("Authorization", "Bearer "+c.token) + } + response, err := c.http.Do(request) + if err != nil { + fatal(err.Error()) + } + defer response.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if response.StatusCode < 200 || response.StatusCode >= 300 { + fatal(fmt.Sprintf("%s: %s", response.Status, strings.TrimSpace(string(raw)))) + } + if len(bytes.TrimSpace(raw)) == 0 { + fmt.Println("ok") + return + } + var pretty bytes.Buffer + if json.Indent(&pretty, raw, "", " ") == nil { + fmt.Println(pretty.String()) + } else { + fmt.Println(string(raw)) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, `usage: + coworker-relay serve + coworker-relay init --public-url [--dir ] + [--external-port 8443] + [--acme-domain | --tls-cert --tls-key ] + coworker-relay health + coworker-relay version + coworker-relay backup [--output ] + coworker-relay restore --from --database [--force] + coworker-relay gc + coworker-relay metrics + coworker-relay instance create --name + coworker-relay instance list + coworker-relay instance revoke + coworker-relay instance rotate-credential + coworker-relay instance update-auth + coworker-relay instance update-stats + coworker-relay bans list [--instance ] + coworker-relay bans remove --instance --ip --reason + coworker-relay cache inspect + coworker-relay cache purge + +configuration: + RELAY_URL=https://relay.example.com:8443 + RELAY_ADMIN_TOKEN= + RELAY_CA_CERT=/path/to/private-ca.pem + RELAY_CONFIG=.env # optional; defaults to ./.env`) + os.Exit(2) +} + +func fatal(message string) { + fmt.Fprintln(os.Stderr, "coworker-relay:", message) + os.Exit(1) +} diff --git a/apps/coworker-relay/cmd/coworker-relay/cli_test.go b/apps/coworker-relay/cmd/coworker-relay/cli_test.go new file mode 100644 index 0000000..0ce09ce --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/cli_test.go @@ -0,0 +1,342 @@ +package main + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/term" +) + +func TestLoadLocalEnvironmentUsesCurrentDirectoryAndPreservesEnvironment(t *testing.T) { + directory := t.TempDir() + if err := os.WriteFile( + filepath.Join(directory, ".env"), + []byte( + "RELAY_TEST_FROM_FILE=file-value\n"+ + "RELAY_TEST_EXPLICIT=file-value\n"+ + "UNRELATED_VALUE=ignored\n", + ), + 0o600, + ); err != nil { + t.Fatal(err) + } + workingDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(directory); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(workingDirectory) }) + + restoreEnvironment := preserveEnvironment( + t, + "RELAY_CONFIG", + "RELAY_TEST_FROM_FILE", + "RELAY_TEST_EXPLICIT", + "UNRELATED_VALUE", + ) + t.Cleanup(restoreEnvironment) + _ = os.Unsetenv("RELAY_CONFIG") + _ = os.Unsetenv("RELAY_TEST_FROM_FILE") + _ = os.Unsetenv("UNRELATED_VALUE") + if err := os.Setenv("RELAY_TEST_EXPLICIT", "process-value"); err != nil { + t.Fatal(err) + } + + if err := loadLocalEnvironment(); err != nil { + t.Fatal(err) + } + if value := os.Getenv("RELAY_TEST_FROM_FILE"); value != "file-value" { + t.Fatalf("file value = %q", value) + } + if value := os.Getenv("RELAY_TEST_EXPLICIT"); value != "process-value" { + t.Fatalf("explicit environment was replaced with %q", value) + } + if _, exists := os.LookupEnv("UNRELATED_VALUE"); exists { + t.Fatal("non-RELAY value was loaded") + } +} + +func TestLoadLocalEnvironmentReportsMissingExplicitConfig(t *testing.T) { + restoreEnvironment := preserveEnvironment(t, "RELAY_CONFIG") + t.Cleanup(restoreEnvironment) + if err := os.Setenv( + "RELAY_CONFIG", + filepath.Join(t.TempDir(), "missing.env"), + ); err != nil { + t.Fatal(err) + } + if err := loadLocalEnvironment(); err == nil { + t.Fatal("missing explicit configuration was ignored") + } +} + +func preserveEnvironment(t *testing.T, names ...string) func() { + t.Helper() + type savedValue struct { + value string + exists bool + } + saved := make(map[string]savedValue, len(names)) + for _, name := range names { + value, exists := os.LookupEnv(name) + saved[name] = savedValue{value: value, exists: exists} + } + return func() { + for name, value := range saved { + if value.exists { + _ = os.Setenv(name, value.value) + } else { + _ = os.Unsetenv(name) + } + } + } +} + +func TestInitializeCreatesSecureComposeDeployment(t *testing.T) { + directory := filepath.Join(t.TempDir(), "deployment") + token, publicURL, err := initialize(initOptions{ + directory: directory, + publicURL: "https://relay.example.com", + externalPort: 8443, + image: "coworker-relay:test", + }) + if err != nil { + t.Fatal(err) + } + if len(token) < 40 || publicURL != "https://relay.example.com:8443" { + t.Fatalf("unexpected initialization result: token=%q url=%q", token, publicURL) + } + envPath := filepath.Join(directory, ".env") + info, err := os.Stat(envPath) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf(".env mode is %o", info.Mode().Perm()) + } + env, _ := os.ReadFile(envPath) + if !strings.Contains(string(env), "RELAY_ADMIN_TOKEN="+token) || + !strings.Contains(string(env), "RELAY_URL="+publicURL) || + !strings.Contains(string(env), "RELAY_ACME_DOMAIN=relay.example.com") { + t.Fatalf("unexpected .env: %s", env) + } + compose, _ := os.ReadFile(filepath.Join(directory, "compose.yaml")) + if !strings.Contains(string(compose), `"${RELAY_EXTERNAL_PORT}:8443"`) || + !strings.Contains(string(compose), `"80:8080"`) || + !strings.Contains(string(compose), `["CMD", "coworker-relay", "health"]`) { + t.Fatalf("unexpected compose file: %s", compose) + } + if _, _, err := initialize(initOptions{ + directory: directory, + publicURL: publicURL, + externalPort: 8443, + image: "coworker-relay:test", + }); err == nil { + t.Fatal("initialization overwrote an existing deployment without --force") + } +} + +func TestInitializePublicIPUsesAutomaticShortLivedACME(t *testing.T) { + directory := filepath.Join(t.TempDir(), "deployment") + _, publicURL, err := initialize(initOptions{ + directory: directory, + publicURL: "https://221.228.203.18:8443", + externalPort: 8443, + image: "coworker-relay:test", + }) + if err != nil { + t.Fatal(err) + } + if publicURL != "https://221.228.203.18:8443" { + t.Fatalf("unexpected public URL: %s", publicURL) + } + env, err := os.ReadFile(filepath.Join(directory, ".env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(env), "RELAY_ACME_IP=221.228.203.18\n") || + strings.Contains(string(env), "RELAY_ACME_DOMAIN=") || + strings.Contains(string(env), "RELAY_TLS_CERT=") { + t.Fatalf("unexpected public-IP environment:\n%s", env) + } + compose, err := os.ReadFile(filepath.Join(directory, "compose.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(compose), `"80:8080"`) { + t.Fatalf("public-IP ACME challenge port is missing:\n%s", compose) + } +} + +func TestInitializePublicIPv6UsesCanonicalACMEIdentifier(t *testing.T) { + directory := filepath.Join(t.TempDir(), "deployment") + _, publicURL, err := initialize(initOptions{ + directory: directory, + publicURL: "https://[2001:4860:4860::8888]:8443", + externalPort: 8443, + image: "coworker-relay:test", + }) + if err != nil { + t.Fatal(err) + } + if publicURL != "https://[2001:4860:4860::8888]:8443" { + t.Fatalf("unexpected public URL: %s", publicURL) + } + env, err := os.ReadFile(filepath.Join(directory, ".env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(env), "RELAY_ACME_IP=2001:4860:4860::8888\n") { + t.Fatalf("unexpected IPv6 environment:\n%s", env) + } +} + +func TestInitializeReservedIPRequiresPEM(t *testing.T) { + for _, address := range []string{ + "10.0.0.1", + "100.64.0.1", + "192.0.2.10", + "198.51.100.10", + "203.0.113.10", + } { + address := address + t.Run(address, func(t *testing.T) { + _, _, err := initialize(initOptions{ + directory: filepath.Join(t.TempDir(), "deployment"), + publicURL: "https://" + address + ":8443", + externalPort: 8443, + image: "coworker-relay:test", + }) + if err == nil || !strings.Contains(err.Error(), "requires --tls-cert and --tls-key") { + t.Fatalf("unexpected error for %s: %v", address, err) + } + }) + } +} + +func TestPromptInitOptionsDefaultsPublicIPToAutomaticACME(t *testing.T) { + input := strings.NewReader( + "\n" + + "\n" + + "https://221.228.203.18:8443\n" + + "\n" + + "\n" + + "\n", + ) + var output bytes.Buffer + options, err := promptInitOptions(input, &output, defaultInitOptions()) + if err != nil { + t.Fatal(err) + } + if options.acmeDomain != "221.228.203.18" || + options.tlsCert != "" || + options.tlsKey != "" { + t.Fatalf("unexpected prompted TLS options: %#v", options) + } + if !strings.Contains(output.String(), "Automatic public-IP certificate") { + t.Fatalf("public-IP automatic TLS choice was not shown:\n%s", output.String()) + } + if !strings.Contains( + output.String(), + "Example: https://relay.example.com:8443", + ) { + t.Fatalf("public origin example was not shown:\n%s", output.String()) + } +} + +func TestPromptConfirmHonorsDefaultOnEnter(t *testing.T) { + for _, test := range []struct { + name string + fallback bool + }{ + {name: "yes", fallback: true}, + {name: "no", fallback: false}, + } { + t.Run(test.name, func(t *testing.T) { + reader := strings.NewReader("\n") + var output bytes.Buffer + actual, err := promptConfirm( + &bufferedInitLineReader{ + reader: bufio.NewReader(reader), + output: &output, + }, + &output, + "Continue?", + test.fallback, + ) + if err != nil { + t.Fatal(err) + } + if actual != test.fallback { + t.Fatalf("confirmation = %t, want %t", actual, test.fallback) + } + }) + } +} + +func TestTerminalLineReaderSupportsCursorMovement(t *testing.T) { + input := strings.NewReader("abc\x1b[D\x1b[DZ\r") + var output bytes.Buffer + terminal := term.NewTerminal( + splitReadWriter{Reader: input, Writer: &output}, + "", + ) + reader := &terminalInitLineReader{terminal: terminal} + + value, err := reader.ReadLine("Value: ") + if err != nil { + t.Fatal(err) + } + if value != "aZbc" { + t.Fatalf("edited value = %q, want %q", value, "aZbc") + } +} + +func TestCharacterDeviceIsNotAssumedToBeTerminal(t *testing.T) { + null, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer null.Close() + if isTerminal(null) { + t.Fatal("character device without a terminal was accepted as interactive input") + } +} + +func TestInitializePEMModeMountsCertificates(t *testing.T) { + directory := filepath.Join(t.TempDir(), "deployment") + cert := filepath.Join(t.TempDir(), "certificate.pem") + key := filepath.Join(t.TempDir(), "private-key.pem") + if _, _, err := initialize(initOptions{ + directory: directory, + publicURL: "https://192.0.2.10:8443", + externalPort: 8443, + tlsCert: cert, + tlsKey: key, + image: "coworker-relay:test", + }); err != nil { + t.Fatal(err) + } + compose, _ := os.ReadFile(filepath.Join(directory, "compose.yaml")) + if !strings.Contains(string(compose), cert+":/run/tls/fullchain.pem:ro") || + !strings.Contains(string(compose), key+":/run/tls/privkey.pem:ro") { + t.Fatalf("certificate mounts missing: %s", compose) + } +} + +func TestHTTPClientRejectsInvalidPrivateCA(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid-ca.pem") + if err := os.WriteFile(path, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("RELAY_CA_CERT", path) + if _, err := newHTTPClient(); err == nil { + t.Fatal("invalid private CA was accepted") + } +} diff --git a/apps/coworker-relay/cmd/coworker-relay/help.go b/apps/coworker-relay/cmd/coworker-relay/help.go new file mode 100644 index 0000000..c8a7be8 --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/help.go @@ -0,0 +1,338 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +var helpTopics = map[string]string{ + "": `Run and administer Coworker Relay + +Usage: + coworker-relay [options] + coworker-relay help [command [subcommand]] + coworker-relay [subcommand] --help + +Commands: + serve Start the Relay HTTPS service + init Create a Compose deployment (interactive with no options) + health Check Relay availability + version Print coworker-relay version information (--version also works) + backup Download a consistent database backup + restore Restore a backup while Relay is stopped + gc Run database garbage collection + metrics Show Relay operational metrics + instance Create and manage Coworker instances + bans Inspect and remove source-IP bans + cache Inspect and purge the update cache + help Show command help + +Configuration: + RELAY_URL=https://relay.example.com:8443 + RELAY_ADMIN_TOKEN= + RELAY_CA_CERT=/path/to/private-ca.pem + RELAY_CONFIG=.env # optional; defaults to ./.env + +Run "coworker-relay help " for command details. +`, + "serve": `Start the Coworker Relay HTTPS service + +Usage: + coworker-relay serve + +The service reads RELAY_* environment variables. It first loads missing +RELAY_* values from RELAY_CONFIG, or from ./.env when RELAY_CONFIG is unset. +Explicit process environment values take precedence over the file. +`, + "help": `Show coworker-relay help + +Usage: + coworker-relay help + coworker-relay help + coworker-relay help + +The equivalent -h and --help flags may be placed after a command or +subcommand. Help is available without Relay configuration or network access. +`, + "init": `Initialize a self-hosted Coworker Relay deployment + +Usage: + coworker-relay init + coworker-relay init --public-url [options] + +With no options, init starts an interactive setup wizard. For scripts and +non-terminal input, pass --public-url and the desired options explicitly. + +Options: + --dir Deployment directory (default: coworker-relay-deploy) + --public-url Public HTTPS origin, e.g. https://relay.example.com:8443 + --external-port Published HTTPS port (default: 8443) + --acme-domain ACME DNS name or public IP (defaults to URL host) + --tls-cert Existing PEM certificate (requires --tls-key) + --tls-key Existing PEM private key (requires --tls-cert) + --image Relay container image + --force Replace files generated by an earlier init + +Automatic ACME and an existing PEM certificate are mutually exclusive. +`, + "health": `Check Relay availability + +Usage: + coworker-relay health + +Calls the public Relay health endpoint. RELAY_URL is required. +`, + "version": `Print coworker-relay version information + +Usage: + coworker-relay version + coworker-relay --version + +This command does not require Relay configuration or network access. +`, + "backup": `Download a consistent Relay database backup + +Usage: + coworker-relay backup [--output ] + +Options: + --output Destination file; defaults to a timestamped .db file + +RELAY_URL and RELAY_ADMIN_TOKEN are required. Existing files are not replaced. +`, + "restore": `Restore a Relay database from a validated backup + +Usage: + coworker-relay restore --from --database [--force] + +Options: + --from Backup database to validate and restore + --database Destination database of a stopped Relay + --force Preserve and replace an existing destination database + +Stop Relay before restoring. This command does not contact Relay. +`, + "gc": `Run Relay database garbage collection + +Usage: + coworker-relay gc + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "metrics": `Show Relay operational metrics + +Usage: + coworker-relay metrics + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance": `Create and manage Coworker instances + +Usage: + coworker-relay instance [options] + +Subcommands: + create Create an instance and one-time pairing code + list List instances + revoke Permanently revoke an instance + rotate-credential Rotate an instance tunnel credential + update-auth Change update-download authentication mode + update-stats Show update-client compatibility statistics + +Run "coworker-relay help instance " for details. +`, + "instance create": `Create a Coworker instance + +Usage: + coworker-relay instance create [--name ] + +Options: + --name Optional display name + +Returns the instance identifier and a short-lived, one-time pairing code. +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance list": `List Coworker instances + +Usage: + coworker-relay instance list + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance revoke": `Permanently revoke a Coworker instance + +Usage: + coworker-relay instance revoke + +Revocation disconnects the instance and invalidates its credential. +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance rotate-credential": `Rotate a Coworker instance tunnel credential + +Usage: + coworker-relay instance rotate-credential + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance update-auth": `Change an instance update-download authentication mode + +Usage: + coworker-relay instance update-auth + +optional preserves anonymous compatibility for older Desktop versions. +required requires a valid communication Bearer token for every update request. +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "instance update-stats": `Show update-client compatibility statistics for an instance + +Usage: + coworker-relay instance update-stats + +Shows the anonymous and authenticated update aggregates used when deciding +whether to switch the instance to required update authentication. +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "bans": `Inspect and remove Relay source-IP bans + +Usage: + coworker-relay bans [options] + +Subcommands: + list List active bans + remove Remove a ban with an audit reason + +Run "coworker-relay help bans " for details. +`, + "bans list": `List active Relay source-IP bans + +Usage: + coworker-relay bans list [--instance ] + +Options: + --instance Limit results to one Coworker instance + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "bans remove": `Remove a Relay source-IP ban + +Usage: + coworker-relay bans remove --instance --ip --reason + +Options: + --instance Coworker instance + --ip Banned source IP + --reason Required audit-log reason + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "cache": `Inspect and purge the Relay update cache + +Usage: + coworker-relay cache + +Subcommands: + inspect Show cache entries and usage + purge Remove cached update artifacts + +Run "coworker-relay help cache " for details. +`, + "cache inspect": `Show Relay update-cache entries and usage + +Usage: + coworker-relay cache inspect + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, + "cache purge": `Remove Relay update-cache artifacts + +Usage: + coworker-relay cache purge + +RELAY_URL and RELAY_ADMIN_TOKEN are required. +`, +} + +var helpGroups = map[string]struct{}{ + "instance": {}, + "bans": {}, + "cache": {}, +} + +// handleHelp resolves all help requests before configuration loading or command +// dispatch. Its return value reports whether the invocation was a help request. +func handleHelp(args []string, output io.Writer) (bool, error) { + if len(args) == 0 { + return true, writeHelpTopic(output, "") + } + if args[0] == "help" { + if len(args) == 1 || + (len(args) == 2 && (args[1] == "-h" || args[1] == "--help")) { + return true, writeHelpTopic(output, "") + } + topic := strings.Join(args[1:], " ") + return true, writeHelpTopic(output, topic) + } + if args[0] == "-h" || args[0] == "--help" { + return true, writeHelpTopic(output, "") + } + + helpAt := -1 + for index, arg := range args { + if arg == "-h" || arg == "--help" { + helpAt = index + break + } + } + if helpAt >= 0 { + topic, err := helpTopicForInvocation(args[:helpAt]) + if err != nil { + return true, err + } + return true, writeHelpTopic(output, topic) + } + + if len(args) == 1 { + if _, group := helpGroups[args[0]]; group { + return true, writeHelpTopic(output, args[0]) + } + } + return false, nil +} + +func helpTopicForInvocation(args []string) (string, error) { + if len(args) == 0 { + return "", nil + } + command := args[0] + if _, group := helpGroups[command]; group { + if len(args) == 1 || strings.HasPrefix(args[1], "-") { + return command, nil + } + topic := command + " " + args[1] + if _, exists := helpTopics[topic]; !exists { + return "", unknownHelpTopic(topic) + } + return topic, nil + } + if _, exists := helpTopics[command]; !exists || strings.Contains(command, " ") { + return "", unknownHelpTopic(command) + } + return command, nil +} + +func writeHelpTopic(output io.Writer, topic string) error { + text, exists := helpTopics[topic] + if !exists { + return unknownHelpTopic(topic) + } + _, err := fmt.Fprint(output, text) + return err +} + +func unknownHelpTopic(topic string) error { + return fmt.Errorf( + "unknown help topic %q; run \"coworker-relay --help\" to list commands", + topic, + ) +} diff --git a/apps/coworker-relay/cmd/coworker-relay/help_test.go b/apps/coworker-relay/cmd/coworker-relay/help_test.go new file mode 100644 index 0000000..c212cb3 --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/help_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestHandleHelpRootForms(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + nil, + {"-h"}, + {"--help"}, + {"help"}, + {"help", "--help"}, + } { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + handled, err := handleHelp(args, &output) + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatal("root help was not handled") + } + if !strings.Contains(output.String(), "Run and administer Coworker Relay") { + t.Fatalf("unexpected root help:\n%s", output.String()) + } + }) + } +} + +func TestHandleHelpCoversEveryCommand(t *testing.T) { + t.Parallel() + tests := []struct { + topic string + contains string + }{ + {"help", "coworker-relay help "}, + {"serve", "coworker-relay serve"}, + {"init", "coworker-relay init"}, + {"health", "coworker-relay health"}, + {"version", "coworker-relay version"}, + {"backup", "coworker-relay backup"}, + {"restore", "coworker-relay restore"}, + {"gc", "coworker-relay gc"}, + {"metrics", "coworker-relay metrics"}, + {"instance", "coworker-relay instance "}, + {"instance create", "coworker-relay instance create"}, + {"instance list", "coworker-relay instance list"}, + {"instance revoke", "coworker-relay instance revoke"}, + {"instance rotate-credential", "coworker-relay instance rotate-credential"}, + {"instance update-auth", "coworker-relay instance update-auth"}, + {"instance update-stats", "coworker-relay instance update-stats"}, + {"bans", "coworker-relay bans "}, + {"bans list", "coworker-relay bans list"}, + {"bans remove", "coworker-relay bans remove"}, + {"cache", "coworker-relay cache "}, + {"cache inspect", "coworker-relay cache inspect"}, + {"cache purge", "coworker-relay cache purge"}, + } + for _, test := range tests { + test := test + t.Run(test.topic, func(t *testing.T) { + t.Parallel() + args := append([]string{"help"}, strings.Fields(test.topic)...) + var output bytes.Buffer + handled, err := handleHelp(args, &output) + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatal("help request was not handled") + } + if !strings.Contains(output.String(), test.contains) { + t.Fatalf( + "help for %q does not contain %q:\n%s", + test.topic, + test.contains, + output.String(), + ) + } + }) + } +} + +func TestHandleHelpAfterCommandsAndOptions(t *testing.T) { + t.Parallel() + tests := []struct { + args []string + contains string + }{ + {[]string{"init", "--help"}, "coworker-relay init"}, + {[]string{"backup", "--output", "backup.db", "-h"}, "coworker-relay backup"}, + {[]string{"instance", "--help"}, "coworker-relay instance "}, + {[]string{"instance", "revoke", "cw_example", "--help"}, "instance revoke"}, + { + []string{ + "bans", + "remove", + "--instance", + "cw_example", + "--ip", + "192.0.2.1", + "--help", + }, + "bans remove", + }, + {[]string{"cache", "inspect", "-h"}, "cache inspect"}, + } + for _, test := range tests { + test := test + t.Run(strings.Join(test.args, "_"), func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + handled, err := handleHelp(test.args, &output) + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatal("help flag was not handled") + } + if !strings.Contains(output.String(), test.contains) { + t.Fatalf("unexpected help:\n%s", output.String()) + } + }) + } +} + +func TestHandleHelpShowsBareGroupWithoutConfiguration(t *testing.T) { + t.Parallel() + for _, group := range []string{"instance", "bans", "cache"} { + group := group + t.Run(group, func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + handled, err := handleHelp([]string{group}, &output) + if err != nil { + t.Fatal(err) + } + if !handled { + t.Fatalf("bare %s group was not handled", group) + } + if !strings.Contains(output.String(), "coworker-relay "+group+" ") { + t.Fatalf("unexpected group help:\n%s", output.String()) + } + }) + } +} + +func TestHandleHelpRejectsUnknownTopics(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"help", "unknown"}, + {"help", "instance", "unknown"}, + {"unknown", "--help"}, + {"instance", "unknown", "--help"}, + } { + args := args + t.Run(strings.Join(args, "_"), func(t *testing.T) { + t.Parallel() + var output bytes.Buffer + handled, err := handleHelp(args, &output) + if !handled { + t.Fatal("unknown help request was not handled") + } + if err == nil || !strings.Contains(err.Error(), "unknown help topic") { + t.Fatalf("unexpected error: %v", err) + } + if output.Len() != 0 { + t.Fatalf("unknown help unexpectedly wrote output: %q", output.String()) + } + }) + } +} + +func TestHandleHelpIgnoresOrdinaryCommands(t *testing.T) { + t.Parallel() + for _, args := range [][]string{ + {"health"}, + {"instance", "list"}, + {"bans", "list"}, + {"cache", "inspect"}, + } { + var output bytes.Buffer + handled, err := handleHelp(args, &output) + if err != nil { + t.Fatal(err) + } + if handled { + t.Fatalf("ordinary command %v was treated as help", args) + } + if output.Len() != 0 { + t.Fatalf("ordinary command wrote help: %q", output.String()) + } + } +} diff --git a/apps/coworker-relay/cmd/coworker-relay/main.go b/apps/coworker-relay/cmd/coworker-relay/main.go new file mode 100644 index 0000000..db58588 --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/main.go @@ -0,0 +1,343 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/acmetls" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo" + relaycache "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/cache" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/server" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/store" + "golang.org/x/crypto/acme/autocert" +) + +func main() { + args := os.Args[1:] + if len(args) == 1 && args[0] == "--version" { + args[0] = "version" + } + if handled, err := handleHelp(args, os.Stdout); handled { + if err != nil { + fatal(err.Error()) + } + return + } + if args[0] != "serve" { + runCLI(args) + return + } + if len(args) != 1 { + usage() + } + if err := loadLocalEnvironment(); err != nil { + fatal(err.Error()) + } + serveRelay() +} + +func serveRelay() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + logger.Info("coworker relay build", "build", buildinfo.Values()) + publicURL, err := server.ValidatePublicURL(os.Getenv("RELAY_PUBLIC_URL")) + if err != nil { + logger.Error("invalid relay configuration", "error", err) + os.Exit(1) + } + adminToken, err := secretEnvironment("RELAY_ADMIN_TOKEN") + if err != nil { + logger.Error("unable to read Relay administrator token", "error", err) + os.Exit(1) + } + if len(adminToken) < 24 { + logger.Error("RELAY_ADMIN_TOKEN must contain at least 24 characters") + os.Exit(1) + } + trusted, err := server.ParseTrustedProxies(os.Getenv("RELAY_TRUSTED_PROXY_CIDRS")) + if err != nil { + logger.Error("invalid trusted proxy configuration", "error", err) + os.Exit(1) + } + databasePath := env("RELAY_DATABASE", "data/coworker-relay.db") + dataDirectory := filepath.Dir(databasePath) + if err := os.MkdirAll(dataDirectory, 0o700); err != nil { + logger.Error("unable to create relay data directory", "error", err) + os.Exit(1) + } + database, err := store.Open(databasePath) + if err != nil { + logger.Error("unable to open relay database", "error", err) + os.Exit(1) + } + defer database.Close() + failureLimit, err := envInteger("RELAY_BAN_FAILURE_LIMIT", 5, 1, 100) + if err != nil { + logger.Error("invalid RELAY_BAN_FAILURE_LIMIT", "error", err) + os.Exit(1) + } + failureWindow, err := envDuration("RELAY_BAN_FAILURE_WINDOW", 10*time.Minute) + if err != nil { + logger.Error("invalid RELAY_BAN_FAILURE_WINDOW", "error", err) + os.Exit(1) + } + banDuration, err := envDuration("RELAY_BAN_DURATION", time.Hour) + if err != nil { + logger.Error("invalid RELAY_BAN_DURATION", "error", err) + os.Exit(1) + } + if err := database.SetAuthPolicy(failureWindow, failureLimit, banDuration); err != nil { + logger.Error("invalid Relay authentication policy", "error", err) + os.Exit(1) + } + parallel, err := strconv.Atoi(env("RELAY_VERIFIER_CONCURRENCY", "4")) + if err != nil || parallel < 1 || parallel > 64 { + logger.Error("RELAY_VERIFIER_CONCURRENCY must be between 1 and 64") + os.Exit(1) + } + cacheBytes, err := strconv.ParseInt(env("RELAY_CACHE_MAX_BYTES", "4294967296"), 10, 64) + if err != nil || cacheBytes < 1 { + logger.Error("RELAY_CACHE_MAX_BYTES must be a positive integer") + os.Exit(1) + } + assetCache, err := relaycache.New( + env("RELAY_CACHE_DIR", filepath.Join(dataDirectory, "cache")), + cacheBytes, + ) + if err != nil { + logger.Error("unable to initialize relay cache", "error", err) + os.Exit(1) + } + requestLimit, err := envInteger("RELAY_REQUESTS_PER_MINUTE", 600, 1, 1_000_000) + if err != nil { + logger.Error("invalid RELAY_REQUESTS_PER_MINUTE", "error", err) + os.Exit(1) + } + anonymousLimit, err := envInteger("RELAY_ANONYMOUS_PER_MINUTE", 60, 1, 1_000_000) + if err != nil { + logger.Error("invalid RELAY_ANONYMOUS_PER_MINUTE", "error", err) + os.Exit(1) + } + maxRequestBody, err := envInt64( + "RELAY_MAX_REQUEST_BODY_BYTES", + 32<<20, + 1, + 32<<20, + ) + if err != nil { + logger.Error("invalid RELAY_MAX_REQUEST_BODY_BYTES", "error", err) + os.Exit(1) + } + maxTunnelFrame, err := envInt64( + "RELAY_MAX_TUNNEL_FRAME_BYTES", + 48<<20, + 1, + 48<<20, + ) + if err != nil { + logger.Error("invalid RELAY_MAX_TUNNEL_FRAME_BYTES", "error", err) + os.Exit(1) + } + minimumFrame := ((maxRequestBody + 2) / 3 * 4) + (64 << 10) + if maxTunnelFrame < minimumFrame { + logger.Error( + "RELAY_MAX_TUNNEL_FRAME_BYTES is too small for RELAY_MAX_REQUEST_BODY_BYTES", + "minimum", minimumFrame, + ) + os.Exit(1) + } + relayServer := server.New(server.Config{ + PublicURL: publicURL, AdminToken: adminToken, TrustedProxies: trusted, + VerifierParallel: parallel, Cache: assetCache, + RequestLimit: requestLimit, AnonymousLimit: anonymousLimit, + MaxRequestBody: maxRequestBody, MaxTunnelFrame: maxTunnelFrame, + }, database, logger) + httpServer := &http.Server{ + Addr: env("RELAY_LISTEN", ":8443"), + Handler: relayServer.Handler(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 32 << 10, + } + cert, key := os.Getenv("RELAY_TLS_CERT"), os.Getenv("RELAY_TLS_KEY") + logger.Info("coworker relay starting", "listen", httpServer.Addr, "public_url", publicURL) + var challengeServer *http.Server + runContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + serve := func() error { + if cert != "" || key != "" { + if cert == "" || key == "" { + return errors.New("RELAY_TLS_CERT and RELAY_TLS_KEY must be provided together") + } + return httpServer.ListenAndServeTLS(cert, key) + } + domain := strings.TrimSpace(os.Getenv("RELAY_ACME_DOMAIN")) + ipIdentifier := strings.TrimSpace(os.Getenv("RELAY_ACME_IP")) + if domain != "" && ipIdentifier != "" { + return errors.New("configure only one of RELAY_ACME_DOMAIN and RELAY_ACME_IP") + } + if domain == "" && ipIdentifier == "" { + return errors.New( + "configure RELAY_TLS_CERT/RELAY_TLS_KEY, RELAY_ACME_DOMAIN, or RELAY_ACME_IP", + ) + } + parsedPublicURL, parseErr := url.Parse(publicURL) + if parseErr != nil { + return errors.New("unable to parse RELAY_PUBLIC_URL for ACME") + } + if ipIdentifier != "" { + configuredIP := net.ParseIP(ipIdentifier) + publicIP := net.ParseIP(parsedPublicURL.Hostname()) + if configuredIP == nil { + return errors.New("RELAY_ACME_IP must be an IP address") + } + if publicIP == nil || !configuredIP.Equal(publicIP) { + return errors.New("RELAY_ACME_IP must match RELAY_PUBLIC_URL hostname") + } + manager, managerErr := acmetls.NewIPManager(acmetls.IPConfig{ + Identifier: ipIdentifier, + Email: os.Getenv("RELAY_ACME_EMAIL"), + CacheDir: env("RELAY_ACME_CACHE", filepath.Join(dataDirectory, "acme")), + HTTPListen: env("RELAY_ACME_HTTP_LISTEN", ":80"), + DirectoryURL: os.Getenv("RELAY_ACME_DIRECTORY_URL"), + Logger: logger, + }) + if managerErr != nil { + return managerErr + } + if managerErr = manager.StartHTTPChallenge(runContext); managerErr != nil { + return managerErr + } + if managerErr = manager.WaitForCertificate(runContext); managerErr != nil { + return managerErr + } + httpServer.TLSConfig = manager.TLSConfig() + go manager.Run(runContext) + return httpServer.ListenAndServeTLS("", "") + } + if net.ParseIP(domain) != nil { + return errors.New("use RELAY_ACME_IP, not RELAY_ACME_DOMAIN, for an IP address") + } + if !strings.EqualFold(parsedPublicURL.Hostname(), domain) { + return errors.New("RELAY_ACME_DOMAIN must match RELAY_PUBLIC_URL hostname") + } + manager := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Email: os.Getenv("RELAY_ACME_EMAIL"), + HostPolicy: autocert.HostWhitelist(domain), + Cache: autocert.DirCache(env("RELAY_ACME_CACHE", filepath.Join(dataDirectory, "acme"))), + } + httpServer.TLSConfig = manager.TLSConfig() + challengeServer = &http.Server{ + Addr: env("RELAY_ACME_HTTP_LISTEN", ":80"), + Handler: manager.HTTPHandler(nil), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + if challengeErr := challengeServer.ListenAndServe(); challengeErr != nil && !errors.Is(challengeErr, http.ErrServerClosed) { + logger.Error("ACME HTTP challenge listener stopped", "error", challengeErr) + } + }() + return httpServer.ListenAndServeTLS("", "") + } + + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-runContext.Done(): + return + case now := <-ticker.C: + removed, gcErr := database.GarbageCollect(now.UTC()) + if gcErr != nil { + logger.Warn("relay garbage collection failed", "error", gcErr) + } else { + logger.Info("relay garbage collection completed", "removed", removed) + } + } + } + }() + serverErrors := make(chan error, 1) + go func() { serverErrors <- serve() }() + select { + case err = <-serverErrors: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("relay stopped", "error", err) + os.Exit(1) + } + case <-runContext.Done(): + logger.Info("coworker relay draining") + relayServer.Drain() + time.Sleep(5 * time.Second) + relayServer.CloseTunnels() + shutdownContext, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if challengeServer != nil { + _ = challengeServer.Shutdown(shutdownContext) + } + if err = httpServer.Shutdown(shutdownContext); err != nil { + logger.Error("relay graceful shutdown failed", "error", err) + os.Exit(1) + } + logger.Info("coworker relay stopped") + } +} + +func env(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func envInteger(name string, fallback, minimum, maximum int) (int, error) { + value, err := strconv.Atoi(env(name, strconv.Itoa(fallback))) + if err != nil || value < minimum || value > maximum { + return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum) + } + return value, nil +} + +func envDuration(name string, fallback time.Duration) (time.Duration, error) { + value, err := time.ParseDuration(env(name, fallback.String())) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive Go duration", name) + } + return value, nil +} + +func envInt64(name string, fallback, minimum, maximum int64) (int64, error) { + value, err := strconv.ParseInt(env(name, strconv.FormatInt(fallback, 10)), 10, 64) + if err != nil || value < minimum || value > maximum { + return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum) + } + return value, nil +} + +func secretEnvironment(name string) (string, error) { + value := os.Getenv(name) + filePath := strings.TrimSpace(os.Getenv(name + "_FILE")) + if value != "" && filePath != "" { + return "", fmt.Errorf("%s and %s_FILE cannot both be set", name, name) + } + if filePath == "" { + return value, nil + } + raw, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read %s_FILE: %w", name, err) + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/apps/coworker-relay/cmd/coworker-relay/main_test.go b/apps/coworker-relay/cmd/coworker-relay/main_test.go new file mode 100644 index 0000000..d85528b --- /dev/null +++ b/apps/coworker-relay/cmd/coworker-relay/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSecretEnvironmentReadsMountedSecretFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "admin-token") + if err := os.WriteFile(path, []byte("secret-from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("RELAY_ADMIN_TOKEN", "") + t.Setenv("RELAY_ADMIN_TOKEN_FILE", path) + value, err := secretEnvironment("RELAY_ADMIN_TOKEN") + if err != nil || value != "secret-from-file" { + t.Fatalf("unexpected secret: %q %v", value, err) + } +} + +func TestSecretEnvironmentRejectsAmbiguousSources(t *testing.T) { + t.Setenv("RELAY_ADMIN_TOKEN", "environment-secret") + t.Setenv("RELAY_ADMIN_TOKEN_FILE", "/run/secrets/admin-token") + if _, err := secretEnvironment("RELAY_ADMIN_TOKEN"); err == nil { + t.Fatal("accepted both environment and file secret sources") + } +} diff --git a/apps/coworker-relay/docker-compose.example.yml b/apps/coworker-relay/docker-compose.example.yml new file mode 100644 index 0000000..b8fc1f1 --- /dev/null +++ b/apps/coworker-relay/docker-compose.example.yml @@ -0,0 +1,48 @@ +services: + relay: + build: . + restart: unless-stopped + stop_grace_period: 35s + environment: + RELAY_PUBLIC_URL: https://relay.example.com:8443 + RELAY_LISTEN: :8443 + RELAY_DATABASE: /var/lib/coworker-relay/relay.db + RELAY_CACHE_DIR: /var/lib/coworker-relay/cache + RELAY_CACHE_MAX_BYTES: "4294967296" + RELAY_REQUESTS_PER_MINUTE: "600" + RELAY_ANONYMOUS_PER_MINUTE: "60" + RELAY_BAN_FAILURE_LIMIT: "5" + RELAY_BAN_FAILURE_WINDOW: 10m + RELAY_BAN_DURATION: 1h + RELAY_MAX_REQUEST_BODY_BYTES: "33554432" + RELAY_MAX_TUNNEL_FRAME_BYTES: "50331648" + RELAY_ADMIN_TOKEN: ${RELAY_ADMIN_TOKEN:?set RELAY_ADMIN_TOKEN} + RELAY_TLS_CERT: /run/tls/fullchain.pem + RELAY_TLS_KEY: /run/tls/privkey.pem + ports: + - "8443:8443" + volumes: + - relay-data:/var/lib/coworker-relay + - ./tls:/run/tls:ro + read_only: true + pids_limit: 256 + ulimits: + nofile: + soft: 65536 + hard: 65536 + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "coworker-relay", "health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: 10m + max-file: "5" + +volumes: + relay-data: diff --git a/apps/coworker-relay/go.mod b/apps/coworker-relay/go.mod new file mode 100644 index 0000000..624f1e2 --- /dev/null +++ b/apps/coworker-relay/go.mod @@ -0,0 +1,21 @@ +module github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay + +go 1.26.5 + +require ( + github.com/coder/websocket v1.8.14 + github.com/mholt/acmez/v3 v3.1.6 + go.etcd.io/bbolt v1.4.0 + golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect +) diff --git a/apps/coworker-relay/go.sum b/apps/coworker-relay/go.sum new file mode 100644 index 0000000..4578fae --- /dev/null +++ b/apps/coworker-relay/go.sum @@ -0,0 +1,40 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/coworker-relay/internal/acmetls/ip_manager.go b/apps/coworker-relay/internal/acmetls/ip_manager.go new file mode 100644 index 0000000..2f8a816 --- /dev/null +++ b/apps/coworker-relay/internal/acmetls/ip_manager.go @@ -0,0 +1,634 @@ +package acmetls + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log/slog" + mathrand "math/rand/v2" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/netutil" + "github.com/mholt/acmez/v3" + "github.com/mholt/acmez/v3/acme" +) + +const ( + defaultDirectoryURL = "https://acme-v02.api.letsencrypt.org/directory" + defaultCheckInterval = time.Hour + defaultRetryInitial = 30 * time.Second + defaultRetryMaximum = 30 * time.Minute + shortLivedProfile = "shortlived" +) + +type IPConfig struct { + Identifier string + Email string + CacheDir string + HTTPListen string + DirectoryURL string + Logger *slog.Logger +} + +type IPManager struct { + config IPConfig + checkInterval time.Duration + retryInitial time.Duration + retryMaximum time.Duration + now func() time.Time + certificate atomic.Pointer[tls.Certificate] + renewMu sync.Mutex + challengeMu sync.RWMutex + challenges map[string]string + challengeServer *http.Server + issue func(context.Context) ([]byte, []byte, error) +} + +type storedAccount struct { + DirectoryURL string `json:"directory_url"` + Account acme.Account `json:"account"` +} + +func NewIPManager(config IPConfig) (*IPManager, error) { + ip := net.ParseIP(config.Identifier) + if !netutil.IsPublicIP(ip) { + return nil, errors.New("ACME IP identifier must be a public, globally routable IP address") + } + config.Identifier = ip.String() + if config.CacheDir == "" { + return nil, errors.New("ACME cache directory is required") + } + if config.HTTPListen == "" { + config.HTTPListen = ":80" + } + if _, _, err := net.SplitHostPort(config.HTTPListen); err != nil { + return nil, fmt.Errorf("invalid ACME HTTP listen address: %w", err) + } + if config.DirectoryURL == "" { + config.DirectoryURL = defaultDirectoryURL + } + directory, err := url.Parse(config.DirectoryURL) + if err != nil || directory.Scheme != "https" || directory.Host == "" { + return nil, errors.New("ACME directory URL must be HTTPS") + } + if config.Logger == nil { + config.Logger = slog.Default() + } + if err := os.MkdirAll(config.CacheDir, 0o700); err != nil { + return nil, fmt.Errorf("create ACME cache: %w", err) + } + if err := os.Chmod(config.CacheDir, 0o700); err != nil { + return nil, fmt.Errorf("secure ACME cache: %w", err) + } + + manager := &IPManager{ + config: config, + checkInterval: defaultCheckInterval, + retryInitial: defaultRetryInitial, + retryMaximum: defaultRetryMaximum, + now: time.Now, + challenges: make(map[string]string), + } + manager.issue = manager.obtain + loaded, loadErr := manager.loadCertificate() + if loadErr != nil { + return nil, loadErr + } + if loaded != nil { + manager.certificate.Store(loaded) + } + return manager, nil +} + +// StartHTTPChallenge binds the HTTP-01 listener before any order is created. +// The same listener remains available for later renewals. +func (m *IPManager) StartHTTPChallenge(ctx context.Context) error { + listener, err := net.Listen("tcp", m.config.HTTPListen) + if err != nil { + return fmt.Errorf("listen for public-IP ACME HTTP-01 challenge: %w", err) + } + server := &http.Server{ + Addr: m.config.HTTPListen, + Handler: m, + ReadHeaderTimeout: 10 * time.Second, + } + m.challengeMu.Lock() + if m.challengeServer != nil { + m.challengeMu.Unlock() + _ = listener.Close() + return errors.New("public-IP ACME HTTP-01 listener is already running") + } + m.challengeServer = server + m.challengeMu.Unlock() + go func() { + <-ctx.Done() + shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdownContext) + }() + go func() { + if serveErr := server.Serve(listener); serveErr != nil && + !errors.Is(serveErr, http.ErrServerClosed) { + m.config.Logger.Error( + "public-IP ACME HTTP-01 listener stopped", + "error", + serveErr, + ) + } + }() + return nil +} + +func (m *IPManager) ServeHTTP(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || !m.matchesChallengeHost(request.Host) { + http.NotFound(response, request) + return + } + m.challengeMu.RLock() + keyAuthorization, ok := m.challenges[request.URL.EscapedPath()] + m.challengeMu.RUnlock() + if !ok { + http.NotFound(response, request) + return + } + response.Header().Set("Content-Type", "text/plain") + response.Header().Set("Cache-Control", "no-store") + _, _ = response.Write([]byte(keyAuthorization)) +} + +func (m *IPManager) matchesChallengeHost(rawHost string) bool { + host := rawHost + if parsedHost, _, err := net.SplitHostPort(rawHost); err == nil { + host = parsedHost + } + host = strings.Trim(host, "[]") + requestIP := net.ParseIP(host) + configuredIP := net.ParseIP(m.config.Identifier) + return requestIP != nil && configuredIP != nil && requestIP.Equal(configuredIP) +} + +func (m *IPManager) Present( + _ context.Context, + challenge acme.Challenge, +) error { + if challenge.Type != acme.ChallengeTypeHTTP01 || + !identifierMatchesIP(challenge.Identifier, m.config.Identifier) || + challenge.Token == "" || + challenge.KeyAuthorization == "" { + return errors.New("invalid public-IP ACME HTTP-01 challenge") + } + m.challengeMu.Lock() + m.challenges[challenge.HTTP01ResourcePath()] = challenge.KeyAuthorization + m.challengeMu.Unlock() + return nil +} + +func (m *IPManager) CleanUp( + _ context.Context, + challenge acme.Challenge, +) error { + m.challengeMu.Lock() + delete(m.challenges, challenge.HTTP01ResourcePath()) + m.challengeMu.Unlock() + return nil +} + +func identifierMatchesIP(identifier acme.Identifier, expected string) bool { + if identifier.Type != "ip" { + return false + } + actual := net.ParseIP(identifier.Value) + configured := net.ParseIP(expected) + return actual != nil && configured != nil && actual.Equal(configured) +} + +func (m *IPManager) TLSConfig() *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: m.GetCertificate, + } +} + +func (m *IPManager) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + current := m.certificate.Load() + if current == nil { + return nil, errors.New("public-IP ACME certificate is not available") + } + return current, nil +} + +// EnsureCertificate obtains a certificate when none is available. If a cached +// certificate is still valid but due for renewal, Relay keeps serving it when +// the renewal attempt fails and the background loop retries later. +func (m *IPManager) EnsureCertificate(ctx context.Context) error { + current := m.certificate.Load() + if current != nil && validateCertificateTime(current, m.now()) == nil { + if !certificateNeedsRenewal(current, m.now()) { + return nil + } + if err := m.renew(ctx); err != nil { + m.config.Logger.Warn( + "public-IP ACME renewal failed; continuing with cached certificate", + "identifier", + m.config.Identifier, + "error", + err, + ) + } + return nil + } + return m.renew(ctx) +} + +// WaitForCertificate keeps a Relay without a usable cache alive while ACME is +// unavailable. This prevents a container restart loop from rapidly repeating +// new-order attempts. HTTPS remains unavailable until a valid certificate has +// been installed. +func (m *IPManager) WaitForCertificate(ctx context.Context) error { + delay := m.retryInitial + for { + if err := m.EnsureCertificate(ctx); err == nil { + return nil + } else { + retryIn := jitteredDelay(delay) + m.config.Logger.Warn( + "public-IP ACME certificate unavailable; HTTPS is waiting", + "identifier", + m.config.Identifier, + "retry_in", + retryIn, + "error", + err, + ) + timer := time.NewTimer(retryIn) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return ctx.Err() + case <-timer.C: + } + if delay < m.retryMaximum { + delay *= 2 + if delay > m.retryMaximum { + delay = m.retryMaximum + } + } + } + } +} + +func (m *IPManager) Run(ctx context.Context) { + ticker := time.NewTicker(m.checkInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + current := m.certificate.Load() + if current != nil && + validateCertificateTime(current, m.now()) == nil && + !certificateNeedsRenewal(current, m.now()) { + continue + } + if err := m.renew(ctx); err != nil { + m.config.Logger.Warn( + "public-IP ACME renewal failed", + "identifier", + m.config.Identifier, + "error", + err, + ) + } + } + } +} + +func jitteredDelay(delay time.Duration) time.Duration { + if delay <= 0 { + return 0 + } + window := delay / 2 + if window <= 0 { + return delay + } + return delay*3/4 + time.Duration(mathrand.Int64N(int64(window)+1)) +} + +func (m *IPManager) renew(ctx context.Context) error { + m.renewMu.Lock() + defer m.renewMu.Unlock() + + current := m.certificate.Load() + if current != nil && + validateCertificateTime(current, m.now()) == nil && + !certificateNeedsRenewal(current, m.now()) { + return nil + } + certPEM, keyPEM, err := m.issue(ctx) + if err != nil { + return err + } + loaded, err := parseCertificate(certPEM, keyPEM, m.config.Identifier) + if err != nil { + return fmt.Errorf("validate issued public-IP certificate: %w", err) + } + if err := validateCertificateTime(loaded, m.now()); err != nil { + return fmt.Errorf("validate issued public-IP certificate: %w", err) + } + bundle := make([]byte, 0, len(certPEM)+len(keyPEM)) + bundle = append(bundle, certPEM...) + bundle = append(bundle, keyPEM...) + if err := atomicWrite( + filepath.Join(m.config.CacheDir, "ip-certificate.pem"), + bundle, + 0o600, + ); err != nil { + return fmt.Errorf("store public-IP certificate bundle: %w", err) + } + m.certificate.Store(loaded) + m.config.Logger.Info( + "public-IP ACME certificate ready", + "identifier", + m.config.Identifier, + "not_after", + loaded.Leaf.NotAfter.UTC(), + ) + return nil +} + +func (m *IPManager) obtain(ctx context.Context) ([]byte, []byte, error) { + account, err := m.loadOrCreateAccount(ctx) + if err != nil { + return nil, nil, err + } + certificateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate certificate key: %w", err) + } + parameters, err := m.orderParameters(account, certificateKey) + if err != nil { + return nil, nil, err + } + client := m.acmeClient() + chains, err := client.ObtainCertificate(ctx, parameters) + if err != nil { + return nil, nil, fmt.Errorf("obtain public-IP ACME certificate: %w", err) + } + if len(chains) == 0 || len(chains[0].ChainPEM) == 0 { + return nil, nil, errors.New("ACME server returned no certificate chain") + } + keyPEM, err := encodePrivateKey(certificateKey) + if err != nil { + return nil, nil, fmt.Errorf("encode certificate key: %w", err) + } + return chains[0].ChainPEM, keyPEM, nil +} + +func (m *IPManager) orderParameters( + account acme.Account, + certificateKey crypto.Signer, +) (acmez.OrderParameters, error) { + csr, err := acmez.NewCSR(certificateKey, []string{m.config.Identifier}) + if err != nil { + return acmez.OrderParameters{}, fmt.Errorf("create public-IP certificate request: %w", err) + } + parameters, err := acmez.OrderParametersFromCSR(account, csr) + if err != nil { + return acmez.OrderParameters{}, fmt.Errorf("create public-IP ACME order: %w", err) + } + parameters.Profile = shortLivedProfile + return parameters, nil +} + +func (m *IPManager) acmeClient() *acmez.Client { + return &acmez.Client{ + Client: &acme.Client{ + Directory: m.config.DirectoryURL, + UserAgent: "coworker-relay", + Logger: m.config.Logger, + }, + ChallengeSolvers: map[string]acmez.Solver{ + acme.ChallengeTypeHTTP01: m, + }, + } +} + +func (m *IPManager) loadCertificate() (*tls.Certificate, error) { + bundle, err := os.ReadFile(filepath.Join(m.config.CacheDir, "ip-certificate.pem")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read cached public-IP ACME certificate: %w", err) + } + loaded, err := parseCertificate(bundle, bundle, m.config.Identifier) + if err != nil { + return nil, fmt.Errorf("load cached public-IP ACME certificate: %w", err) + } + return loaded, nil +} + +func parseCertificate( + certPEM []byte, + keyPEM []byte, + identifier string, +) (*tls.Certificate, error) { + loaded, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, err + } + if len(loaded.Certificate) == 0 { + return nil, errors.New("certificate chain is empty") + } + loaded.Leaf, err = x509.ParseCertificate(loaded.Certificate[0]) + if err != nil { + return nil, err + } + if err := loaded.Leaf.VerifyHostname(identifier); err != nil { + return nil, err + } + return &loaded, nil +} + +func validateCertificateTime(loaded *tls.Certificate, now time.Time) error { + if !now.Before(loaded.Leaf.NotAfter) { + return errors.New("certificate is expired") + } + if now.Before(loaded.Leaf.NotBefore) { + return errors.New("certificate is not valid yet") + } + return nil +} + +func (m *IPManager) loadOrCreateAccount(ctx context.Context) (acme.Account, error) { + privateKey, err := m.loadOrCreateAccountKey() + if err != nil { + return acme.Account{}, err + } + accountPath := filepath.Join(m.config.CacheDir, "ip-account.json") + raw, readErr := os.ReadFile(accountPath) + if readErr == nil { + var stored storedAccount + if err := json.Unmarshal(raw, &stored); err != nil { + return acme.Account{}, fmt.Errorf("parse ACME account: %w", err) + } + if stored.DirectoryURL == m.config.DirectoryURL && + stored.Account.Location != "" && + stored.Account.Status == acme.StatusValid { + stored.Account.PrivateKey = privateKey + return stored.Account, nil + } + } else if !errors.Is(readErr, os.ErrNotExist) { + return acme.Account{}, fmt.Errorf("read ACME account: %w", readErr) + } + + contact := []string(nil) + if email := strings.TrimSpace(m.config.Email); email != "" { + contact = []string{"mailto:" + email} + } + account, err := m.acmeClient().Client.NewAccount(ctx, acme.Account{ + Contact: contact, + TermsOfServiceAgreed: true, + PrivateKey: privateKey, + }) + if err != nil { + return acme.Account{}, fmt.Errorf("register ACME account: %w", err) + } + if err := m.saveAccount(storedAccount{ + DirectoryURL: m.config.DirectoryURL, + Account: account, + }); err != nil { + return acme.Account{}, err + } + return account, nil +} + +func (m *IPManager) loadOrCreateAccountKey() (crypto.Signer, error) { + keyPath := filepath.Join(m.config.CacheDir, "ip-account.key") + keyPEM, err := os.ReadFile(keyPath) + switch { + case err == nil: + privateKey, parseErr := parsePrivateKey(keyPEM) + if parseErr != nil { + return nil, fmt.Errorf("parse ACME account key: %w", parseErr) + } + return privateKey, nil + case errors.Is(err, os.ErrNotExist): + privateKey, generateErr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if generateErr != nil { + return nil, fmt.Errorf("generate ACME account key: %w", generateErr) + } + encoded, encodeErr := encodePrivateKey(privateKey) + if encodeErr != nil { + return nil, fmt.Errorf("encode ACME account key: %w", encodeErr) + } + if err := atomicWrite(keyPath, encoded, 0o600); err != nil { + return nil, fmt.Errorf("store ACME account key: %w", err) + } + return privateKey, nil + default: + return nil, fmt.Errorf("read ACME account key: %w", err) + } +} + +func parsePrivateKey(raw []byte) (crypto.Signer, error) { + block, _ := pem.Decode(raw) + if block == nil { + return nil, errors.New("private key is not PEM") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + signer, ok := parsed.(crypto.Signer) + if !ok { + return nil, errors.New("private key does not implement crypto.Signer") + } + return signer, nil +} + +func encodePrivateKey(privateKey crypto.Signer) ([]byte, error) { + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, err + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), nil +} + +func (m *IPManager) saveAccount(stored storedAccount) error { + raw, err := json.MarshalIndent(stored, "", " ") + if err != nil { + return fmt.Errorf("encode ACME account: %w", err) + } + raw = append(raw, '\n') + if err := atomicWrite( + filepath.Join(m.config.CacheDir, "ip-account.json"), + raw, + 0o600, + ); err != nil { + return fmt.Errorf("store ACME account: %w", err) + } + return nil +} + +func certificateNeedsRenewal(certificate *tls.Certificate, now time.Time) bool { + if certificate == nil || certificate.Leaf == nil { + return true + } + if !now.Before(certificate.Leaf.NotAfter) { + return true + } + validity := certificate.Leaf.NotAfter.Sub(certificate.Leaf.NotBefore) + return !now.Before(certificate.Leaf.NotBefore.Add(validity * 2 / 3)) +} + +func atomicWrite(path string, body []byte, mode os.FileMode) (resultErr error) { + file, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + temporary := file.Name() + defer func() { + if resultErr != nil { + _ = os.Remove(temporary) + } + }() + if err := file.Chmod(mode); err != nil { + _ = file.Close() + return err + } + if _, err := file.Write(body); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return os.Rename(temporary, path) +} diff --git a/apps/coworker-relay/internal/acmetls/ip_manager_test.go b/apps/coworker-relay/internal/acmetls/ip_manager_test.go new file mode 100644 index 0000000..a7536d9 --- /dev/null +++ b/apps/coworker-relay/internal/acmetls/ip_manager_test.go @@ -0,0 +1,480 @@ +package acmetls + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "io" + "log/slog" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mholt/acmez/v3/acme" +) + +const testPublicIP = "221.228.203.18" + +func TestIPManagerLoadsCacheAndServesWithoutSNI(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + certPEM, keyPEM, root := makeIPCertificate( + t, + testPublicIP, + now.Add(-time.Hour), + now.Add(72*time.Hour), + ) + cache := t.TempDir() + writeCertificatePair(t, cache, certPEM, keyPEM) + + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: cache, + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + if err := manager.EnsureCertificate(context.Background()); err != nil { + t.Fatalf("valid cache unexpectedly caused issuance: %v", err) + } + + serverConfig := manager.TLSConfig() + originalGetCertificate := serverConfig.GetCertificate + serverConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if hello.ServerName != "" { + t.Errorf("IP client unexpectedly sent SNI %q", hello.ServerName) + } + return originalGetCertificate(hello) + } + listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer connection.Close() + serverDone <- connection.(*tls.Conn).Handshake() + }() + + roots := x509.NewCertPool() + roots.AddCert(root) + connection, err := tls.Dial("tcp", listener.Addr().String(), &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + ServerName: testPublicIP, + }) + if err != nil { + t.Fatal(err) + } + _ = connection.Close() + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} + +func TestIPManagerUsesShortLivedIPOrder(t *testing.T) { + t.Parallel() + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: t.TempDir(), + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + request, err := manager.orderParameters(acme.Account{ + Status: acme.StatusValid, + }, key) + if err != nil { + t.Fatal(err) + } + if len(request.Identifiers) != 1 || + request.Identifiers[0].Type != "ip" || + request.Identifiers[0].Value != testPublicIP { + t.Fatalf("unexpected identifiers: %#v", request.Identifiers) + } + if request.Profile != shortLivedProfile { + t.Fatalf("unexpected ACME order: %#v", request) + } +} + +func TestIPManagerServesOnlyPresentedChallengeForConfiguredIP(t *testing.T) { + t.Parallel() + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: t.TempDir(), + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + challenge := acme.Challenge{ + Type: acme.ChallengeTypeHTTP01, + Token: "test-token", + KeyAuthorization: "test-token.thumbprint", + Identifier: acme.Identifier{Type: "ip", Value: testPublicIP}, + } + if err := manager.Present(context.Background(), challenge); err != nil { + t.Fatal(err) + } + + request := httptest.NewRequest( + http.MethodGet, + "http://"+testPublicIP+challenge.HTTP01ResourcePath(), + nil, + ) + response := httptest.NewRecorder() + manager.ServeHTTP(response, request) + if response.Code != http.StatusOK || + strings.TrimSpace(response.Body.String()) != challenge.KeyAuthorization { + t.Fatalf("unexpected challenge response: %d %q", response.Code, response.Body.String()) + } + + wrongHost := httptest.NewRequest( + http.MethodGet, + "http://203.0.113.1"+challenge.HTTP01ResourcePath(), + nil, + ) + wrongHostResponse := httptest.NewRecorder() + manager.ServeHTTP(wrongHostResponse, wrongHost) + if wrongHostResponse.Code != http.StatusNotFound { + t.Fatalf("wrong host response = %d", wrongHostResponse.Code) + } + + if err := manager.CleanUp(context.Background(), challenge); err != nil { + t.Fatal(err) + } + cleanedResponse := httptest.NewRecorder() + manager.ServeHTTP(cleanedResponse, request) + if cleanedResponse.Code != http.StatusNotFound { + t.Fatalf("cleaned challenge response = %d", cleanedResponse.Code) + } +} + +func TestIPManagerRenewalFailureKeepsValidCertificate(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + certPEM, keyPEM, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(-48*time.Hour), + now.Add(12*time.Hour), + ) + cache := t.TempDir() + writeCertificatePair(t, cache, certPEM, keyPEM) + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: cache, + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + before, err := manager.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + manager.issue = func(context.Context) ([]byte, []byte, error) { + return nil, nil, errors.New("issuer unavailable") + } + if err := manager.EnsureCertificate(context.Background()); err != nil { + t.Fatalf("renewal failure should keep an unexpired certificate: %v", err) + } + after, err := manager.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if before != after { + t.Fatal("failed renewal replaced the cached certificate") + } +} + +func TestIPManagerRenewalAtomicallySwitchesAndPersistsCertificate(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + oldCert, oldKey, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(-48*time.Hour), + now.Add(12*time.Hour), + ) + newNotAfter := now.Add(160 * time.Hour) + newCert, newKey, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(-time.Hour), + newNotAfter, + ) + cache := t.TempDir() + writeCertificatePair(t, cache, oldCert, oldKey) + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: cache, + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + before, err := manager.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + manager.issue = func(context.Context) ([]byte, []byte, error) { + return newCert, newKey, nil + } + if err := manager.EnsureCertificate(context.Background()); err != nil { + t.Fatal(err) + } + after, err := manager.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if before == after || !after.Leaf.NotAfter.Equal(newNotAfter) { + t.Fatal("successful renewal did not switch the in-memory certificate") + } + info, err := os.Stat(filepath.Join(cache, "ip-certificate.pem")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("certificate bundle mode is %o", info.Mode().Perm()) + } + restarted, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: cache, + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + reloaded, err := restarted.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if !reloaded.Leaf.NotAfter.Equal(newNotAfter) { + t.Fatal("renewed certificate was not loaded after restart") + } +} + +func TestIPManagerInitialIssuanceFailureIsFatal(t *testing.T) { + t.Parallel() + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: t.TempDir(), + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + manager.issue = func(context.Context) ([]byte, []byte, error) { + return nil, nil, errors.New("issuer unavailable") + } + if err := manager.EnsureCertificate(context.Background()); err == nil { + t.Fatal("initial issuance failure was ignored") + } +} + +func TestIPManagerReplacesCachedCertificateThatIsNotYetValid(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + futureCert, futureKey, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(time.Hour), + now.Add(161*time.Hour), + ) + validCert, validKey, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(-time.Hour), + now.Add(159*time.Hour), + ) + cache := t.TempDir() + writeCertificatePair(t, cache, futureCert, futureKey) + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: cache, + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + manager.issue = func(context.Context) ([]byte, []byte, error) { + return validCert, validKey, nil + } + if err := manager.EnsureCertificate(context.Background()); err != nil { + t.Fatal(err) + } + current, err := manager.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + if current.Leaf.NotBefore.After(now) { + t.Fatal("future-dated cached certificate was retained") + } +} + +func TestIPManagerWaitRetriesInitialIssuanceInProcess(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + certPEM, keyPEM, _ := makeIPCertificate( + t, + testPublicIP, + now.Add(-time.Hour), + now.Add(160*time.Hour), + ) + manager, err := NewIPManager(IPConfig{ + Identifier: testPublicIP, + CacheDir: t.TempDir(), + HTTPListen: ":8080", + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatal(err) + } + manager.retryInitial = time.Millisecond + manager.retryMaximum = 2 * time.Millisecond + attempts := 0 + manager.issue = func(context.Context) ([]byte, []byte, error) { + attempts++ + if attempts < 3 { + return nil, nil, errors.New("issuer unavailable") + } + return certPEM, keyPEM, nil + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := manager.WaitForCertificate(ctx); err != nil { + t.Fatal(err) + } + if attempts != 3 { + t.Fatalf("issuance attempts = %d, want 3", attempts) + } +} + +func TestIPManagerRejectsUnsafeConfiguration(t *testing.T) { + t.Parallel() + tests := []IPConfig{ + {Identifier: "192.0.2.10", CacheDir: t.TempDir(), HTTPListen: ":8080"}, + {Identifier: testPublicIP, CacheDir: t.TempDir(), HTTPListen: "8080"}, + { + Identifier: testPublicIP, + CacheDir: t.TempDir(), + HTTPListen: ":8080", + DirectoryURL: "http://acme.example.test/directory", + }, + } + for index, config := range tests { + if _, err := NewIPManager(config); err == nil { + t.Fatalf("unsafe configuration %d was accepted", index) + } + } +} + +func TestJitteredDelayStaysWithinBounds(t *testing.T) { + t.Parallel() + const delay = time.Minute + for range 100 { + actual := jitteredDelay(delay) + if actual < delay*3/4 || actual > delay*5/4 { + t.Fatalf("jittered delay %s is outside expected bounds", actual) + } + } +} + +func TestCertificateNeedsRenewalAfterTwoThirdsOfLifetime(t *testing.T) { + t.Parallel() + notBefore := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + certificate := &tls.Certificate{Leaf: &x509.Certificate{ + NotBefore: notBefore, + NotAfter: notBefore.Add(6 * 24 * time.Hour), + }} + if certificateNeedsRenewal(certificate, notBefore.Add(95*time.Hour)) { + t.Fatal("certificate was renewed before two thirds of its lifetime") + } + if !certificateNeedsRenewal(certificate, notBefore.Add(96*time.Hour)) { + t.Fatal("certificate was not renewed at two thirds of its lifetime") + } +} + +func makeIPCertificate( + t *testing.T, + ip string, + notBefore time.Time, + notAfter time.Time, +) ([]byte, []byte, *x509.Certificate) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: ip}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP(ip)}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + key, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + root, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: key}), + root +} + +func writeCertificatePair(t *testing.T, directory string, certPEM []byte, keyPEM []byte) { + t.Helper() + bundle := append(append([]byte{}, certPEM...), keyPEM...) + if err := os.WriteFile( + filepath.Join(directory, "ip-certificate.pem"), + bundle, + 0o600, + ); err != nil { + t.Fatal(err) + } +} diff --git a/apps/coworker-relay/internal/auth/argon2.go b/apps/coworker-relay/internal/auth/argon2.go new file mode 100644 index 0000000..7625427 --- /dev/null +++ b/apps/coworker-relay/internal/auth/argon2.go @@ -0,0 +1,83 @@ +package auth + +import ( + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +type argonParams struct { + memory uint32 + iterations uint32 + parallelism uint8 +} + +func parseArgon2id(encoded string) (argonParams, []byte, []byte, error) { + var params argonParams + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return params, nil, nil, errors.New("invalid Argon2id verifier") + } + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", ¶ms.memory, ¶ms.iterations, ¶ms.parallelism); err != nil { + return params, nil, nil, errors.New("invalid Argon2id parameters") + } + if fmt.Sprintf( + "m=%d,t=%d,p=%d", + params.memory, + params.iterations, + params.parallelism, + ) != parts[3] { + return params, nil, nil, errors.New("invalid Argon2id parameters") + } + if params.memory < 8*1024 || params.memory > 64*1024 || params.iterations < 1 || params.iterations > 5 || params.parallelism < 1 || params.parallelism > 4 { + return params, nil, nil, errors.New("Argon2id parameters outside Relay limits") + } + decode := base64.RawStdEncoding.DecodeString + salt, err := decode(parts[4]) + if err != nil || len(salt) < 8 || len(salt) > 64 { + return params, nil, nil, errors.New("invalid Argon2id salt") + } + expected, err := decode(parts[5]) + if err != nil || len(expected) < 16 || len(expected) > 64 { + return params, nil, nil, errors.New("invalid Argon2id digest") + } + return params, salt, expected, nil +} + +func ValidateArgon2id(encoded string) error { + _, _, _, err := parseArgon2id(encoded) + return err +} + +func VerifyArgon2id(encoded, password string) (bool, error) { + params, salt, expected, err := parseArgon2id(encoded) + if err != nil { + return false, err + } + actual := argon2.IDKey([]byte(password), salt, params.iterations, params.memory, params.parallelism, uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1, nil +} + +func ParseBearer(value string) (string, bool) { + if !strings.HasPrefix(value, "Bearer ") { + return "", false + } + token := strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) + if token == "" || len(token) > 4096 || strings.ContainsAny(token, " \t\r\n") { + return "", false + } + return token, true +} + +func RetryAfterSeconds(untilUnix, nowUnix int64) string { + remaining := untilUnix - nowUnix + if remaining < 1 { + remaining = 1 + } + return strconv.FormatInt(remaining, 10) +} diff --git a/apps/coworker-relay/internal/auth/argon2_test.go b/apps/coworker-relay/internal/auth/argon2_test.go new file mode 100644 index 0000000..6c7a0b0 --- /dev/null +++ b/apps/coworker-relay/internal/auth/argon2_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "golang.org/x/crypto/argon2" +) + +func verifier(password string) string { + salt := []byte("0123456789abcdef") + digest := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return fmt.Sprintf( + "$argon2id$v=19$m=19456,t=2,p=1$%s$%s", + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(digest), + ) +} + +func TestVerifyArgon2id(t *testing.T) { + encoded := verifier("desktop-secret") + if err := ValidateArgon2id(encoded); err != nil { + t.Fatalf("valid verifier was rejected: %v", err) + } + ok, err := VerifyArgon2id(encoded, "desktop-secret") + if err != nil || !ok { + t.Fatalf("expected verifier to match: ok=%v err=%v", ok, err) + } + ok, err = VerifyArgon2id(encoded, "wrong") + if err != nil || ok { + t.Fatalf("expected wrong token to be rejected: ok=%v err=%v", ok, err) + } +} + +func TestValidateArgon2idRejectsExcessiveCost(t *testing.T) { + encoded := strings.Replace(verifier("desktop-secret"), "m=19456", "m=1048576", 1) + if err := ValidateArgon2id(encoded); err == nil { + t.Fatal("excessive verifier cost was accepted") + } +} + +func TestParseBearer(t *testing.T) { + if token, ok := ParseBearer("Bearer abc_123"); !ok || token != "abc_123" { + t.Fatalf("unexpected bearer result: %q %v", token, ok) + } + for _, value := range []string{"", "Basic abc", "Bearer ", "Bearer a b", "bearer abc"} { + if _, ok := ParseBearer(value); ok { + t.Fatalf("expected %q to be rejected", value) + } + } +} diff --git a/apps/coworker-relay/internal/buildinfo/buildinfo.go b/apps/coworker-relay/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..fcb1332 --- /dev/null +++ b/apps/coworker-relay/internal/buildinfo/buildinfo.go @@ -0,0 +1,15 @@ +package buildinfo + +var ( + Version = "dev" + Commit = "unknown" + Date = "unknown" +) + +func Values() map[string]string { + return map[string]string{ + "version": Version, + "commit": Commit, + "date": Date, + } +} diff --git a/apps/coworker-relay/internal/cache/cache.go b/apps/coworker-relay/internal/cache/cache.go new file mode 100644 index 0000000..26b62a2 --- /dev/null +++ b/apps/coworker-relay/internal/cache/cache.go @@ -0,0 +1,382 @@ +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "hash" + "io" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Header [2]string + +type Metadata struct { + InstanceID string `json:"instance_id,omitempty"` + Status int `json:"status"` + Headers []Header `json:"headers"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + CreatedAt time.Time `json:"created_at"` +} + +type Entry struct { + Metadata Metadata + Path string +} + +type keyLock struct { + mutex sync.Mutex + refs int +} + +type verifiedFile struct { + size int64 + modTime time.Time + digest string + checked time.Time +} + +const validationInterval = 15 * time.Minute + +type Cache struct { + root string + maxBytes int64 + locksMu sync.Mutex + locks map[string]*keyLock + verified map[string]verifiedFile + healthMu sync.Mutex + healthAt time.Time + healthErr error +} + +func New(root string, maxBytes int64) (*Cache, error) { + if maxBytes <= 0 { + maxBytes = 4 << 30 + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + return &Cache{ + root: root, maxBytes: maxBytes, + locks: make(map[string]*keyLock), verified: make(map[string]verifiedFile), + }, nil +} + +func Key(instanceID, target string) string { + sum := sha256.Sum256([]byte(instanceID + "\x00" + target)) + return hex.EncodeToString(sum[:]) +} + +func (c *Cache) Lock(key string) func() { + c.locksMu.Lock() + entry := c.locks[key] + if entry == nil { + entry = &keyLock{} + c.locks[key] = entry + } + entry.refs++ + c.locksMu.Unlock() + entry.mutex.Lock() + return func() { + entry.mutex.Unlock() + c.locksMu.Lock() + entry.refs-- + if entry.refs == 0 && c.locks[key] == entry { + delete(c.locks, key) + } + c.locksMu.Unlock() + } +} + +func (c *Cache) Get(key string) (Entry, bool) { + var entry Entry + metaPath, bodyPath := c.paths(key) + raw, err := os.ReadFile(metaPath) + if err != nil || json.Unmarshal(raw, &entry.Metadata) != nil { + c.removeEntry(key, metaPath, bodyPath) + return Entry{}, false + } + info, err := os.Stat(bodyPath) + if err != nil || info.Size() != entry.Metadata.Size { + c.removeEntry(key, metaPath, bodyPath) + return Entry{}, false + } + c.locksMu.Lock() + verified := c.verified[key] + unchanged := verified.size == info.Size() && + verified.modTime.Equal(info.ModTime()) && + verified.digest == entry.Metadata.SHA256 && + time.Since(verified.checked) < validationInterval + c.locksMu.Unlock() + if !unchanged { + digest, err := fileSHA256(bodyPath) + if err != nil || digest != entry.Metadata.SHA256 { + c.removeEntry(key, metaPath, bodyPath) + return Entry{}, false + } + c.locksMu.Lock() + c.verified[key] = verifiedFile{ + size: info.Size(), modTime: info.ModTime(), digest: digest, + checked: time.Now(), + } + c.locksMu.Unlock() + } + now := time.Now() + _ = os.Chtimes(metaPath, now, now) + entry.Path = bodyPath + return entry, true +} + +func (c *Cache) removeEntry(key string, paths ...string) { + for _, path := range paths { + _ = os.Remove(path) + } + c.locksMu.Lock() + delete(c.verified, key) + c.locksMu.Unlock() +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +type Writer struct { + cache *Cache + key string + file *os.File + hash hashWriter + size int64 + metadata Metadata +} + +type hashWriter struct{ value hash.Hash } + +func (c *Cache) Begin(key, instanceID string, status int, headers []Header) (*Writer, error) { + temporary, err := os.CreateTemp(c.root, ".relay-cache-*") + if err != nil { + return nil, err + } + return &Writer{ + cache: c, key: key, file: temporary, + hash: hashWriter{value: sha256.New()}, + metadata: Metadata{ + InstanceID: instanceID, + Status: status, Headers: headers, CreatedAt: time.Now().UTC(), + }, + }, nil +} + +func (w *Writer) Write(value []byte) error { + count, err := w.file.Write(value) + if err == nil { + _, _ = w.hash.value.Write(value[:count]) + w.size += int64(count) + } + return err +} + +func (w *Writer) Abort() { + name := w.file.Name() + _ = w.file.Close() + _ = os.Remove(name) +} + +func (w *Writer) Commit() error { + if err := w.file.Sync(); err != nil { + w.Abort() + return err + } + if err := w.file.Close(); err != nil { + w.Abort() + return err + } + metaPath, bodyPath := w.cache.paths(w.key) + if err := os.Rename(w.file.Name(), bodyPath); err != nil { + w.Abort() + return err + } + w.metadata.Size = w.size + w.metadata.SHA256 = hex.EncodeToString(w.hash.value.Sum(nil)) + raw, err := json.Marshal(w.metadata) + if err != nil { + return err + } + tempMeta := metaPath + ".tmp" + if err := os.WriteFile(tempMeta, raw, 0o600); err != nil { + return err + } + if err := os.Rename(tempMeta, metaPath); err != nil { + return err + } + if info, err := os.Stat(bodyPath); err == nil { + w.cache.locksMu.Lock() + w.cache.verified[w.key] = verifiedFile{ + size: info.Size(), modTime: info.ModTime(), digest: w.metadata.SHA256, + checked: time.Now(), + } + w.cache.locksMu.Unlock() + } + return w.cache.prune() +} + +func (c *Cache) Purge() error { + entries, err := os.ReadDir(c.root) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if err := os.Remove(filepath.Join(c.root, entry.Name())); err != nil && + !errors.Is(err, os.ErrNotExist) { + return err + } + } + c.locksMu.Lock() + clear(c.verified) + c.locksMu.Unlock() + return nil +} + +func (c *Cache) PurgeInstance(instanceID string) error { + return c.purgeMatching(func(metadata Metadata) bool { + return metadata.InstanceID == instanceID + }) +} + +func (c *Cache) purgeMatching(match func(Metadata) bool) error { + entries, err := os.ReadDir(c.root) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + metaPath := filepath.Join(c.root, entry.Name()) + raw, readErr := os.ReadFile(metaPath) + var metadata Metadata + if readErr == nil && json.Unmarshal(raw, &metadata) == nil && !match(metadata) { + continue + } + key := entry.Name()[:len(entry.Name())-len(".json")] + for _, path := range []string{metaPath, filepath.Join(c.root, key+".body")} { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + c.locksMu.Lock() + delete(c.verified, key) + c.locksMu.Unlock() + } + return nil +} + +func (c *Cache) Check() error { + c.healthMu.Lock() + defer c.healthMu.Unlock() + if time.Since(c.healthAt) < 10*time.Second { + return c.healthErr + } + c.healthAt = time.Now() + probe, err := os.CreateTemp(c.root, ".health-*") + if err != nil { + c.healthErr = err + return err + } + name := probe.Name() + if err := probe.Close(); err != nil { + _ = os.Remove(name) + c.healthErr = err + return err + } + c.healthErr = os.Remove(name) + return c.healthErr +} + +func (c *Cache) Stats() (map[string]int64, error) { + entries, err := os.ReadDir(c.root) + if err != nil { + return nil, err + } + var files, bytes int64 + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".body" { + continue + } + info, err := entry.Info() + if err == nil { + files++ + bytes += info.Size() + } + } + return map[string]int64{"entries": files, "bytes": bytes, "max_bytes": c.maxBytes}, nil +} + +func (c *Cache) paths(key string) (string, string) { + return filepath.Join(c.root, key+".json"), filepath.Join(c.root, key+".body") +} + +func (c *Cache) prune() error { + type item struct { + path string + key string + size int64 + used time.Time + } + entries, err := os.ReadDir(c.root) + if err != nil { + return err + } + var items []item + var total int64 + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".body" { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + used := info.ModTime() + key := entry.Name()[:len(entry.Name())-len(".body")] + if metadataInfo, metadataErr := os.Stat(filepath.Join(c.root, key+".json")); metadataErr == nil { + used = metadataInfo.ModTime() + } + total += info.Size() + items = append(items, item{ + path: filepath.Join(c.root, entry.Name()), + key: key, + size: info.Size(), used: used, + }) + } + sort.Slice(items, func(i, j int) bool { return items[i].used.Before(items[j].used) }) + for _, entry := range items { + if total <= c.maxBytes { + break + } + _ = os.Remove(entry.path) + _ = os.Remove(filepath.Join(c.root, entry.key+".json")) + c.locksMu.Lock() + delete(c.verified, entry.key) + c.locksMu.Unlock() + total -= entry.size + } + return nil +} diff --git a/apps/coworker-relay/internal/cache/cache_test.go b/apps/coworker-relay/internal/cache/cache_test.go new file mode 100644 index 0000000..d0e4f27 --- /dev/null +++ b/apps/coworker-relay/internal/cache/cache_test.go @@ -0,0 +1,144 @@ +package cache + +import ( + "os" + "testing" +) + +func TestCacheCommitReadAndPurge(t *testing.T) { + value, err := New(t.TempDir(), 1024) + if err != nil { + t.Fatal(err) + } + key := Key("cw_test", "/asset") + writer, err := value.Begin(key, "cw_test", 200, []Header{{"Content-Type", "application/octet-stream"}}) + if err != nil { + t.Fatal(err) + } + if err := writer.Write([]byte("signed-update")); err != nil { + t.Fatal(err) + } + if err := writer.Commit(); err != nil { + t.Fatal(err) + } + entry, found := value.Get(key) + if !found || entry.Metadata.Size != int64(len("signed-update")) { + t.Fatalf("unexpected cache entry: found=%v entry=%#v", found, entry) + } + body, err := os.ReadFile(entry.Path) + if err != nil || string(body) != "signed-update" { + t.Fatalf("unexpected cache body: %q err=%v", body, err) + } + if err := value.Purge(); err != nil { + t.Fatal(err) + } + if _, found := value.Get(key); found { + t.Fatal("cache entry survived purge") + } + if entries, err := os.ReadDir(value.root); err != nil || len(entries) != 0 { + t.Fatalf("cache purge left files: %#v %v", entries, err) + } +} + +func TestCacheRejectsSameSizeCorruption(t *testing.T) { + value, err := New(t.TempDir(), 1024) + if err != nil { + t.Fatal(err) + } + key := Key("cw_test", "/asset") + writer, err := value.Begin(key, "cw_test", 200, nil) + if err != nil { + t.Fatal(err) + } + if err := writer.Write([]byte("signed-update")); err != nil { + t.Fatal(err) + } + if err := writer.Commit(); err != nil { + t.Fatal(err) + } + entry, found := value.Get(key) + if !found { + t.Fatal("committed cache entry missing") + } + if err := os.WriteFile(entry.Path, []byte("tampered-data"), 0o600); err != nil { + t.Fatal(err) + } + if _, found := value.Get(key); found { + t.Fatal("cache accepted a same-size corrupted body") + } +} + +func TestCacheReusesValidationUntilBodyChanges(t *testing.T) { + value, err := New(t.TempDir(), 1024) + if err != nil { + t.Fatal(err) + } + key := Key("cw_test", "/asset") + writer, err := value.Begin(key, "cw_test", 200, nil) + if err != nil { + t.Fatal(err) + } + if err := writer.Write([]byte("signed-update")); err != nil { + t.Fatal(err) + } + if err := writer.Commit(); err != nil { + t.Fatal(err) + } + before := value.verified[key] + if _, found := value.Get(key); !found { + t.Fatal("committed cache entry missing") + } + after := value.verified[key] + if before != after { + t.Fatal("unchanged cache body was unnecessarily revalidated") + } +} + +func TestKeyLockIsSharedAndReleased(t *testing.T) { + value, err := New(t.TempDir(), 1024) + if err != nil { + t.Fatal(err) + } + unlockFirst := value.Lock("asset") + acquired := make(chan func(), 1) + go func() { + acquired <- value.Lock("asset") + }() + unlockFirst() + unlockSecond := <-acquired + unlockSecond() + value.locksMu.Lock() + defer value.locksMu.Unlock() + if len(value.locks) != 0 { + t.Fatalf("unused key locks were retained: %d", len(value.locks)) + } +} + +func TestPurgeInstanceLeavesOtherInstances(t *testing.T) { + value, err := New(t.TempDir(), 1024) + if err != nil { + t.Fatal(err) + } + for _, instanceID := range []string{"cw_one", "cw_two"} { + key := Key(instanceID, "/asset") + writer, beginErr := value.Begin(key, instanceID, 200, nil) + if beginErr != nil { + t.Fatal(beginErr) + } + if err := writer.Write([]byte(instanceID)); err != nil { + t.Fatal(err) + } + if err := writer.Commit(); err != nil { + t.Fatal(err) + } + } + if err := value.PurgeInstance("cw_one"); err != nil { + t.Fatal(err) + } + if _, found := value.Get(Key("cw_one", "/asset")); found { + t.Fatal("purged instance cache survived") + } + if _, found := value.Get(Key("cw_two", "/asset")); !found { + t.Fatal("unrelated instance cache was removed") + } +} diff --git a/apps/coworker-relay/internal/netutil/public_ip.go b/apps/coworker-relay/internal/netutil/public_ip.go new file mode 100644 index 0000000..a6be9cc --- /dev/null +++ b/apps/coworker-relay/internal/netutil/public_ip.go @@ -0,0 +1,59 @@ +package netutil + +import ( + "net" + "net/netip" +) + +var nonPublicPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001::/32"), + netip.MustParsePrefix("2001:10::/28"), + netip.MustParsePrefix("2001:20::/28"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("fec0::/10"), + netip.MustParsePrefix("ff00::/8"), +} + +// IsPublicIP reports whether ip is a globally routable address suitable for a +// public CA validation attempt. It deliberately rejects IANA documentation, +// benchmarking, shared-address, transition, and other special-purpose ranges +// in addition to the private ranges recognized by net.IP. +func IsPublicIP(ip net.IP) bool { + address, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || + address.IsLoopback() || address.IsLinkLocalUnicast() || + address.IsLinkLocalMulticast() || address.IsUnspecified() { + return false + } + for _, prefix := range nonPublicPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} diff --git a/apps/coworker-relay/internal/netutil/public_ip_test.go b/apps/coworker-relay/internal/netutil/public_ip_test.go new file mode 100644 index 0000000..d1e048f --- /dev/null +++ b/apps/coworker-relay/internal/netutil/public_ip_test.go @@ -0,0 +1,37 @@ +package netutil + +import ( + "net" + "testing" +) + +func TestIsPublicIP(t *testing.T) { + t.Parallel() + tests := map[string]bool{ + "221.228.203.18": true, + "8.8.8.8": true, + "2001:4860::8888": true, + "10.0.0.1": false, + "100.64.0.1": false, + "127.0.0.1": false, + "192.0.2.10": false, + "198.18.0.1": false, + "198.51.100.1": false, + "203.0.113.1": false, + "224.0.0.1": false, + "::1": false, + "2001:db8::1": false, + "3fff::1": false, + "fc00::1": false, + "fe80::1": false, + } + for value, expected := range tests { + value, expected := value, expected + t.Run(value, func(t *testing.T) { + t.Parallel() + if actual := IsPublicIP(net.ParseIP(value)); actual != expected { + t.Fatalf("IsPublicIP(%q) = %t, want %t", value, actual, expected) + } + }) + } +} diff --git a/apps/coworker-relay/internal/protocol/protocol.go b/apps/coworker-relay/internal/protocol/protocol.go new file mode 100644 index 0000000..a1c202a --- /dev/null +++ b/apps/coworker-relay/internal/protocol/protocol.go @@ -0,0 +1,22 @@ +package protocol + +type Header [2]string + +type Message struct { + Type string `json:"type"` + RequestID string `json:"request_id,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + RawPath string `json:"raw_path,omitempty"` + Query string `json:"query,omitempty"` + Headers []Header `json:"headers,omitempty"` + RelayHeaderStart int `json:"relay_header_start,omitempty"` + Body string `json:"body,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + Status int `json:"status,omitempty"` + More bool `json:"more,omitempty"` + Error string `json:"error,omitempty"` + Verifier string `json:"verifier,omitempty"` + Generation string `json:"generation,omitempty"` + SentAt float64 `json:"sent_at,omitempty"` +} diff --git a/apps/coworker-relay/internal/protocol/protocol_test.go b/apps/coworker-relay/internal/protocol/protocol_test.go new file mode 100644 index 0000000..c70a03f --- /dev/null +++ b/apps/coworker-relay/internal/protocol/protocol_test.go @@ -0,0 +1,27 @@ +package protocol + +import ( + "encoding/json" + "os" + "testing" +) + +func TestRequestV1GoldenFixture(t *testing.T) { + raw, err := os.ReadFile("testdata/request-v1.json") + if err != nil { + t.Fatal(err) + } + var message Message + if err := json.Unmarshal(raw, &message); err != nil { + t.Fatal(err) + } + if message.Type != "request" || + message.RequestID != "req_fixture" || + message.RelayHeaderStart != 2 || + len(message.Headers) != 8 || + message.Headers[0][1] != "client-value" || + message.Headers[2][1] != "v1" || + message.Headers[7][0] != "Forwarded" { + t.Fatalf("unexpected fixture: %#v", message) + } +} diff --git a/apps/coworker-relay/internal/protocol/testdata/request-v1.json b/apps/coworker-relay/internal/protocol/testdata/request-v1.json new file mode 100644 index 0000000..9223ce6 --- /dev/null +++ b/apps/coworker-relay/internal/protocol/testdata/request-v1.json @@ -0,0 +1,21 @@ +{ + "type": "request", + "request_id": "req_fixture", + "method": "POST", + "path": "/messages", + "raw_path": "/messages", + "query": "source=desktop", + "headers": [ + ["X-Coworker-Relay", "client-value"], + ["Authorization", "Bearer fixture-token"], + ["X-Coworker-Relay", "v1"], + ["X-Coworker-Relay-Instance", "cw_fixture"], + ["X-Coworker-Relay-Request-Id", "req_fixture"], + ["X-Coworker-Relay-Original-URL", "https://relay.example.com/i/cw_fixture/messages?source=desktop"], + ["X-Coworker-Relay-Original-Target", "/i/cw_fixture/messages?source=desktop"], + ["Forwarded", "for=203.0.113.8;proto=https;host=relay.example.com"] + ], + "relay_header_start": 2, + "body": "e30=", + "client_ip": "203.0.113.8" +} diff --git a/apps/coworker-relay/internal/server/fuzz_test.go b/apps/coworker-relay/internal/server/fuzz_test.go new file mode 100644 index 0000000..e6a539f --- /dev/null +++ b/apps/coworker-relay/internal/server/fuzz_test.go @@ -0,0 +1,23 @@ +package server + +import "testing" + +func FuzzSplitInstancePath(f *testing.F) { + for _, seed := range []string{ + "/i/cw_abcdefgh/status", + "/i/cw_abcdefgh/messages", + "/i/cw_abcdefgh/../api/admin/config", + "/i//status", + "/unknown", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, value string) { + instance, path, ok := splitInstancePath(value) + if ok { + if instance == "" || path == "" || path[0] != '/' { + t.Fatalf("accepted invalid split: %q %q from %q", instance, path, value) + } + } + }) +} diff --git a/apps/coworker-relay/internal/server/server.go b/apps/coworker-relay/internal/server/server.go new file mode 100644 index 0000000..c360771 --- /dev/null +++ b/apps/coworker-relay/internal/server/server.go @@ -0,0 +1,1276 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/auth" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/buildinfo" + relaycache "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/cache" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/protocol" + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/store" + "github.com/coder/websocket" +) + +const ( + protocolVersion = "1" + maxRequestBody = 32 << 20 + maxTunnelFrame = 48 << 20 +) + +type Config struct { + PublicURL string + AdminToken string + TrustedProxies []netip.Prefix + VerifierParallel int + Cache *relaycache.Cache + RequestLimit int + AnonymousLimit int + MaxRequestBody int64 + MaxTunnelFrame int64 +} + +type responseEvent struct { + message protocol.Message + err error +} + +type tunnel struct { + instanceID string + conn *websocket.Conn + sendMu sync.Mutex + pendingMu sync.Mutex + pending map[string]chan responseEvent +} + +func (t *tunnel) send(ctx context.Context, value protocol.Message) error { + t.sendMu.Lock() + defer t.sendMu.Unlock() + raw, err := json.Marshal(value) + if err != nil { + return err + } + return t.conn.Write(ctx, websocket.MessageText, raw) +} + +func (t *tunnel) closePending(err error) { + t.pendingMu.Lock() + defer t.pendingMu.Unlock() + for id, ch := range t.pending { + select { + case ch <- responseEvent{err: err}: + default: + } + close(ch) + delete(t.pending, id) + } +} + +type windowCounter struct { + start time.Time + count int +} + +type Server struct { + config Config + store *store.Store + logger *slog.Logger + tunnelsMu sync.RWMutex + tunnels map[string]*tunnel + verifySem chan struct{} + cache *relaycache.Cache + anonMu sync.Mutex + anonymous map[string]windowCounter + requests map[string]windowCounter + requestLimit int + anonymousLimit int + maxRequestBody int64 + maxTunnelFrame int64 + draining atomic.Bool + requestTotal atomic.Uint64 + requestCompleted atomic.Uint64 + requestDuration atomic.Uint64 + authFailureTotal atomic.Uint64 + banTotal atomic.Uint64 + tunnelConnectTotal atomic.Uint64 + cacheHitTotal atomic.Uint64 + cacheMissTotal atomic.Uint64 +} + +func New(config Config, database *store.Store, logger *slog.Logger) *Server { + parallel := config.VerifierParallel + if parallel <= 0 { + parallel = 4 + } + requestLimit := config.RequestLimit + if requestLimit <= 0 { + requestLimit = 600 + } + anonymousLimit := config.AnonymousLimit + if anonymousLimit <= 0 { + anonymousLimit = 60 + } + requestBody := config.MaxRequestBody + if requestBody <= 0 { + requestBody = maxRequestBody + } + tunnelFrame := config.MaxTunnelFrame + if tunnelFrame <= 0 { + tunnelFrame = maxTunnelFrame + } + return &Server{ + config: config, + store: database, + logger: logger, + tunnels: make(map[string]*tunnel), + verifySem: make(chan struct{}, parallel), + cache: config.Cache, + anonymous: make(map[string]windowCounter), + requests: make(map[string]windowCounter), + requestLimit: requestLimit, + anonymousLimit: anonymousLimit, + maxRequestBody: requestBody, + maxTunnelFrame: tunnelFrame, + } +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /_relay/v1/health", s.health) + mux.HandleFunc("GET /_relay/v1/livez", s.live) + mux.HandleFunc("GET /_relay/v1/readyz", s.ready) + mux.HandleFunc("POST /_relay/v1/enroll", s.enroll) + mux.HandleFunc("POST /_relay/v1/credential/rotate", s.rotateOwnCredential) + mux.HandleFunc("GET /_relay/v1/connect", s.connect) + mux.HandleFunc("POST /_relay/v1/admin/instances", s.adminCreateInstance) + mux.HandleFunc("GET /_relay/v1/admin/instances", s.adminListInstances) + mux.HandleFunc("DELETE /_relay/v1/admin/instances/{instance}", s.adminDeleteInstance) + mux.HandleFunc("POST /_relay/v1/admin/instances/{instance}/rotate-credential", s.adminRotateCredential) + mux.HandleFunc("PATCH /_relay/v1/admin/instances/{instance}/update-auth", s.adminUpdateAuth) + mux.HandleFunc("GET /_relay/v1/admin/instances/{instance}/update-stats", s.adminUpdateStats) + mux.HandleFunc("GET /_relay/v1/admin/bans", s.adminListBans) + mux.HandleFunc("DELETE /_relay/v1/admin/bans", s.adminRemoveBan) + mux.HandleFunc("GET /_relay/v1/admin/cache", s.adminCacheStats) + mux.HandleFunc("DELETE /_relay/v1/admin/cache", s.adminCachePurge) + mux.HandleFunc("GET /_relay/v1/admin/backup", s.adminBackup) + mux.HandleFunc("POST /_relay/v1/admin/gc", s.adminGarbageCollect) + mux.HandleFunc("GET /_relay/v1/admin/metrics", s.adminMetrics) + mux.HandleFunc("/", s.facade) + return s.securityHeaders(mux) +} + +func (s *Server) rotateOwnCredential(w http.ResponseWriter, r *http.Request) { + instanceID := r.Header.Get("X-Coworker-Relay-Instance") + if !s.allowAnonymous("_credential:"+instanceID, s.clientIP(r)) { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + credential, ok := auth.ParseBearer(r.Header.Get("Authorization")) + if !ok { + writeError(w, http.StatusUnauthorized, "instance_auth_required") + return + } + if _, err := s.store.AuthenticateInstance(instanceID, credential); err != nil { + writeError(w, http.StatusUnauthorized, "instance_auth_invalid") + return + } + next, err := s.store.PrepareCredential(instanceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + s.logger.Info( + "relay instance credential rotation prepared", + "instance_id", instanceID, + "actor", "instance", + ) + writeJSON(w, http.StatusOK, map[string]string{"instance_credential": next}) + s.tunnelsMu.RLock() + current := s.tunnels[instanceID] + s.tunnelsMu.RUnlock() + if current != nil { + _ = current.conn.Close(websocket.StatusServiceRestart, "instance credential rotated") + } +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + s.ready(w, r) +} + +func (s *Server) live(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", "protocol_version": 1, "build": buildinfo.Values(), + }) +} + +func (s *Server) ready(w http.ResponseWriter, _ *http.Request) { + if s.draining.Load() { + writeError(w, http.StatusServiceUnavailable, "draining") + return + } + if err := s.store.Check(); err != nil { + writeError(w, http.StatusServiceUnavailable, "database_unavailable") + return + } + if s.cache != nil { + if err := s.cache.Check(); err != nil { + writeError(w, http.StatusServiceUnavailable, "cache_unavailable") + return + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ready", "protocol_version": 1, "build": buildinfo.Values(), + }) +} + +func (s *Server) enroll(w http.ResponseWriter, r *http.Request) { + ip := s.clientIP(r) + if !s.allowAnonymous("_enrollment", ip) { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + var input struct { + PairingCode string `json:"pairing_code"` + Verifier string `json:"verifier"` + ProtocolVersion int `json:"protocol_version"` + } + if err := decodeJSON(r, &input, 16<<10); err != nil || input.ProtocolVersion != 1 || len(input.Verifier) > 512 { + writeError(w, http.StatusBadRequest, "invalid_enrollment") + return + } + if err := auth.ValidateArgon2id(input.Verifier); err != nil { + writeError(w, http.StatusBadRequest, "invalid_enrollment") + return + } + instance, credential, err := s.store.Enroll(input.PairingCode, input.Verifier) + if err != nil { + s.logger.Info("relay enrollment", "source_ip", ip, "result", "rejected") + writeError(w, http.StatusUnauthorized, "pairing_rejected") + return + } + s.logger.Info("relay enrollment", "instance_id", instance.ID, "result", "ok") + writeJSON(w, http.StatusOK, map[string]any{ + "instance_id": instance.ID, + "instance_credential": credential, + "public_base_url": strings.TrimRight(s.config.PublicURL, "/") + "/i/" + instance.ID, + "protocol_version": 1, + }) +} + +func decodeJSON(r *http.Request, target any, limit int64) error { + defer r.Body.Close() + decoder := json.NewDecoder(io.LimitReader(r.Body, limit)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func (s *Server) connect(w http.ResponseWriter, r *http.Request) { + if s.draining.Load() { + writeError(w, http.StatusServiceUnavailable, "draining") + return + } + instanceID := r.Header.Get("X-Coworker-Relay-Instance") + if !s.allowAnonymous("_tunnel:"+instanceID, s.clientIP(r)) { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + if r.Header.Get("X-Coworker-Relay-Protocol") != protocolVersion { + writeError(w, http.StatusUpgradeRequired, "protocol_incompatible") + return + } + credential, ok := auth.ParseBearer(r.Header.Get("Authorization")) + if !ok { + writeError(w, http.StatusUnauthorized, "instance_auth_required") + return + } + if _, err := s.store.AuthenticateInstance(instanceID, credential); err != nil { + writeError(w, http.StatusUnauthorized, "instance_auth_invalid") + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + return + } + conn.SetReadLimit(s.maxTunnelFrame) + current := &tunnel{instanceID: instanceID, conn: conn, pending: make(map[string]chan responseEvent)} + s.tunnelsMu.Lock() + previous := s.tunnels[instanceID] + s.tunnels[instanceID] = current + s.tunnelsMu.Unlock() + if previous != nil { + _ = previous.conn.Close(websocket.StatusPolicyViolation, "superseded by a newer connection") + } + s.logger.Info("relay tunnel connected", "instance_id", instanceID) + s.tunnelConnectTotal.Add(1) + heartbeatCtx, stopHeartbeat := context.WithCancel(r.Context()) + defer stopHeartbeat() + go func() { + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-heartbeatCtx.Done(): + return + case sent := <-ticker.C: + if err := current.send(heartbeatCtx, protocol.Message{ + Type: "ping", + SentAt: float64(sent.UnixNano()) / float64(time.Second), + }); err != nil { + _ = current.conn.Close(websocket.StatusInternalError, "heartbeat failed") + return + } + } + } + }() + err = s.readTunnel(r.Context(), current) + current.closePending(errors.New("instance tunnel closed")) + s.tunnelsMu.Lock() + if s.tunnels[instanceID] == current { + delete(s.tunnels, instanceID) + } + s.tunnelsMu.Unlock() + _ = conn.Close(websocket.StatusNormalClosure, "") + s.logger.Info("relay tunnel disconnected", "instance_id", instanceID, "error", safeError(err)) +} + +func (s *Server) readTunnel(ctx context.Context, current *tunnel) error { + for { + kind, raw, err := current.conn.Read(ctx) + if err != nil { + return err + } + if kind != websocket.MessageText { + continue + } + var message protocol.Message + if err := json.Unmarshal(raw, &message); err != nil { + return errors.New("invalid tunnel frame") + } + switch message.Type { + case "verifier": + if len(message.Verifier) > 512 || !strings.HasPrefix(message.Verifier, "$argon2id$") { + return errors.New("invalid verifier frame") + } + if err := auth.ValidateArgon2id(message.Verifier); err != nil { + return errors.New("invalid verifier frame") + } + instance, err := s.store.GetInstance(current.instanceID) + if err != nil { + return err + } + if err := s.store.UpdateVerifier(current.instanceID, message.Verifier, message.Generation); err != nil { + return err + } + if instance.VerifierGeneration != "" && instance.VerifierGeneration != message.Generation { + current.closePending(errors.New("communication token rotated")) + } + if err := current.send(ctx, protocol.Message{ + Type: "verifier_ack", + Generation: message.Generation, + }); err != nil { + return err + } + case "pong": + continue + case "response_start", "response_body", "response_error": + current.pendingMu.Lock() + ch := current.pending[message.RequestID] + current.pendingMu.Unlock() + if ch != nil { + select { + case ch <- responseEvent{message: message}: + case <-ctx.Done(): + return ctx.Err() + default: + current.pendingMu.Lock() + if current.pending[message.RequestID] == ch { + delete(current.pending, message.RequestID) + close(ch) + } + current.pendingMu.Unlock() + go s.cancelOverloadedStream(current, message.RequestID) + } + } + default: + return errors.New("unknown tunnel frame") + } + } +} + +func (s *Server) cancelOverloadedStream(current *tunnel, requestID string) { + cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = current.send(cancelCtx, protocol.Message{Type: "cancel", RequestID: requestID}) +} + +func (s *Server) facade(w http.ResponseWriter, r *http.Request) { + startedAt := time.Now() + s.requestTotal.Add(1) + defer func() { + s.requestCompleted.Add(1) + s.requestDuration.Add(uint64(time.Since(startedAt))) + }() + if s.draining.Load() { + writeError(w, http.StatusServiceUnavailable, "draining") + return + } + instanceID, path, ok := splitInstancePath(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, "not_found") + return + } + instance, err := s.store.GetInstance(instanceID) + if err != nil { + writeError(w, http.StatusNotFound, "instance_not_found") + return + } + category, allowed := routeCategory(r.Method, path) + if !allowed { + writeError(w, http.StatusNotFound, "not_found") + return + } + ip := s.clientIP(r) + requestID := newRequestID() + w.Header().Set("X-Coworker-Relay-Request-Id", requestID) + if !s.allowRequest(instanceID, ip) { + s.logAccess(instanceID, ip, category, "rate_limited", requestID) + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + if ban, active, _ := s.store.ActiveBan(instanceID, ip, time.Now().UTC()); active { + s.logAccess(instanceID, ip, category, "banned", requestID) + w.Header().Set("Retry-After", auth.RetryAfterSeconds(ban.Until.Unix(), time.Now().Unix())) + writeError(w, http.StatusTooManyRequests, "temporarily_banned") + return + } + authorization, hasAuthorization := r.Header["Authorization"] + rawAuthorization := "" + if len(authorization) > 0 { + rawAuthorization = authorization[0] + } + if category == "status" && !hasAuthorization { + if !s.allowAnonymous(instanceID, ip) { + s.logAccess(instanceID, ip, category, "rate_limited", requestID) + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + s.logAccess(instanceID, ip, category, "anonymous", requestID) + writeJSON(w, http.StatusOK, map[string]any{}) + return + } + requiresAuth := category != "update" || instance.UpdateAuthMode == "required" + if !hasAuthorization { + if requiresAuth { + if !s.allowAnonymous(instanceID, ip) { + s.logAccess(instanceID, ip, category, "rate_limited", requestID) + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + s.logAccess(instanceID, ip, category, "missing", requestID) + writeError(w, http.StatusUnauthorized, "bearer_required") + return + } + if !s.allowAnonymous(instanceID, ip) { + s.logAccess(instanceID, ip, category, "rate_limited", requestID) + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + } else { + token, validFormat := auth.ParseBearer(rawAuthorization) + valid := validFormat && s.verify(instance.Verifier, token) + if !valid { + s.authFailureTotal.Add(1) + banned, until, _ := s.store.RecordFailure(instanceID, ip, time.Now().UTC()) + result := "invalid" + if banned { + s.banTotal.Add(1) + result = "banned" + w.Header().Set("Retry-After", auth.RetryAfterSeconds(until.Unix(), time.Now().Unix())) + writeError(w, http.StatusTooManyRequests, "temporarily_banned") + } else { + writeError(w, http.StatusUnauthorized, "bearer_invalid") + } + s.logAccess(instanceID, ip, category, result, requestID) + return + } + } + authResult := "authenticated" + if !hasAuthorization { + authResult = "anonymous" + } + s.logAccess(instanceID, ip, category, authResult, requestID) + cacheKey := "" + var unlock func() + if category == "update" && strings.HasPrefix(path, "/api/desktop-updates/assets/") && s.cache != nil { + cacheKey = updateAssetCacheKey(instanceID, r.URL) + unlock = s.cache.Lock(cacheKey) + defer unlock() + if entry, found := s.cache.Get(cacheKey); found { + s.cacheHitTotal.Add(1) + s.serveCached(w, r, entry) + return + } + s.cacheMissTotal.Add(1) + if r.Header.Get("Range") != "" { + cacheKey = "" + } + } + if category == "update" { + if version := updateCheckVersion(path); version != "" { + if err := s.store.RecordUpdateCheck(instanceID, version, hasAuthorization, time.Now().UTC()); err != nil { + s.logger.Warn("relay update statistics failed", "instance_id", instanceID, "error", safeError(err)) + } + } + } + s.forward(w, r, instanceID, path, ip, cacheKey, requestID) +} + +func updateAssetCacheKey(instanceID string, target *url.URL) string { + return relaycache.Key(instanceID, target.Path) +} + +func (s *Server) Drain() { + s.draining.Store(true) +} + +func (s *Server) CloseTunnels() { + s.tunnelsMu.RLock() + tunnels := make([]*tunnel, 0, len(s.tunnels)) + for _, current := range s.tunnels { + tunnels = append(tunnels, current) + } + s.tunnelsMu.RUnlock() + for _, current := range tunnels { + _ = current.conn.Close(websocket.StatusGoingAway, "relay shutting down") + } +} + +func (s *Server) logAccess(instanceID, ip, category, result, requestID string) { + s.logger.Info( + "relay access", + "instance_id", instanceID, + "source_ip", ip, + "route", category, + "auth_result", result, + "request_id", requestID, + ) +} + +func splitInstancePath(path string) (string, string, bool) { + if !strings.HasPrefix(path, "/i/") { + return "", "", false + } + rest := strings.TrimPrefix(path, "/i/") + index := strings.IndexByte(rest, '/') + if index < 1 { + return "", "", false + } + instanceID, forwarded := rest[:index], rest[index:] + if !strings.HasPrefix(instanceID, "cw_") || strings.Contains(forwarded, "..") { + return "", "", false + } + return instanceID, forwarded, true +} + +func routeCategory(method, path string) (string, bool) { + switch { + case method == http.MethodGet && path == "/status": + return "status", true + case method == http.MethodPost && path == "/messages": + return "message", true + case method == http.MethodGet && strings.HasPrefix(path, "/sse/"): + return "sse", true + case (method == http.MethodGet || method == http.MethodPost) && path == "/api/communicate/register": + return "registration", true + case method == http.MethodDelete && strings.HasPrefix(path, "/api/communicate/register/"): + return "registration", true + case method == http.MethodGet && allowedUpdatePath(path): + return "update", true + default: + return "", false + } +} + +func allowedUpdatePath(path string) bool { + const prefix = "/api/desktop-updates/" + if !strings.HasPrefix(path, prefix) { + return false + } + rest := strings.TrimPrefix(path, prefix) + if strings.HasPrefix(rest, "assets/") || strings.HasPrefix(rest, "feed/v1/") { + return true + } + if strings.HasPrefix(rest, "releases") || strings.HasPrefix(rest, "statistics") { + return false + } + return len(strings.Split(rest, "/")) == 3 +} + +func updateCheckVersion(path string) string { + const prefix = "/api/desktop-updates/" + rest := strings.TrimPrefix(path, prefix) + if rest == path || strings.HasPrefix(rest, "assets/") || strings.HasPrefix(rest, "feed/") { + return "" + } + parts := strings.Split(rest, "/") + if len(parts) == 3 { + return parts[2] + } + return "" +} + +func (s *Server) verify(verifier, token string) bool { + if verifier == "" { + return false + } + select { + case s.verifySem <- struct{}{}: + defer func() { <-s.verifySem }() + case <-time.After(2 * time.Second): + return false + } + ok, err := auth.VerifyArgon2id(verifier, token) + return err == nil && ok +} + +func (s *Server) allowAnonymous(instanceID, ip string) bool { + return s.allowWindow(s.anonymous, instanceID+"\x00"+ip, s.anonymousLimit) +} + +func (s *Server) allowRequest(instanceID, ip string) bool { + return s.allowWindow(s.requests, instanceID+"\x00"+ip, s.requestLimit) +} + +func (s *Server) allowWindow( + windows map[string]windowCounter, + key string, + limit int, +) bool { + now := time.Now() + s.anonMu.Lock() + defer s.anonMu.Unlock() + if len(windows) > 10_000 { + for candidate, window := range windows { + if now.Sub(window.start) >= time.Minute { + delete(windows, candidate) + } + } + } + window := windows[key] + if now.Sub(window.start) >= time.Minute { + window = windowCounter{start: now} + } + window.count++ + windows[key] = window + return window.count <= limit +} + +func (s *Server) forward( + w http.ResponseWriter, + r *http.Request, + instanceID, path, ip, cacheKey, requestID string, +) { + s.tunnelsMu.RLock() + current := s.tunnels[instanceID] + s.tunnelsMu.RUnlock() + if current == nil { + writeError(w, http.StatusServiceUnavailable, "instance_offline") + return + } + controller := http.NewResponseController(w) + _ = controller.SetReadDeadline(time.Now().Add(30 * time.Second)) + body, err := io.ReadAll(io.LimitReader(r.Body, s.maxRequestBody+1)) + _ = controller.SetReadDeadline(time.Time{}) + if err != nil || int64(len(body)) > s.maxRequestBody { + writeError(w, http.StatusRequestEntityTooLarge, "request_too_large") + return + } + headers := orderedHeaders(r.Header) + relayHeaderStart := len(headers) + originalTarget := r.RequestURI + headers = append(headers, + protocol.Header{"X-Coworker-Relay", "v1"}, + protocol.Header{"X-Coworker-Relay-Instance", instanceID}, + protocol.Header{"X-Coworker-Relay-Request-Id", requestID}, + protocol.Header{"X-Coworker-Relay-Original-URL", strings.TrimRight(s.config.PublicURL, "/") + originalTarget}, + protocol.Header{"X-Coworker-Relay-Original-Target", originalTarget}, + protocol.Header{"Forwarded", "for=" + forwardedIP(ip) + ";proto=https;host=" + s.publicHost()}, + ) + rawPath := path + if escaped := r.URL.EscapedPath(); escaped != "" { + prefix := "/i/" + instanceID + rawPath = strings.TrimPrefix(escaped, prefix) + } + message := protocol.Message{ + Type: "request", + RequestID: requestID, + Method: r.Method, + Path: path, + RawPath: rawPath, + Query: r.URL.RawQuery, + Headers: headers, + RelayHeaderStart: relayHeaderStart, + Body: base64.StdEncoding.EncodeToString(body), + ClientIP: ip, + } + ch := make(chan responseEvent, 32) + current.pendingMu.Lock() + current.pending[requestID] = ch + current.pendingMu.Unlock() + defer func() { + current.pendingMu.Lock() + delete(current.pending, requestID) + current.pendingMu.Unlock() + }() + sendCtx, cancelSend := context.WithTimeout(r.Context(), 15*time.Second) + err = current.send(sendCtx, message) + cancelSend() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "instance_offline") + return + } + started := false + var cacheWriter *relaycache.Writer + abortCache := func() { + if cacheWriter != nil { + cacheWriter.Abort() + cacheWriter = nil + } + } + defer abortCache() + for { + select { + case <-r.Context().Done(): + cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = current.send(cancelCtx, protocol.Message{Type: "cancel", RequestID: requestID}) + cancel() + return + case event, open := <-ch: + if !open || event.err != nil { + if !started { + writeError(w, http.StatusBadGateway, "tunnel_closed") + } + return + } + switch event.message.Type { + case "response_start": + for _, header := range event.message.Headers { + if !hopByHopHeader(header[0]) { + w.Header().Add(header[0], header[1]) + } + } + w.Header().Set("X-Coworker-Relay-Request-Id", requestID) + w.WriteHeader(event.message.Status) + started = true + if cacheKey != "" && event.message.Status == http.StatusOK && s.cache != nil { + cacheHeaders := make([]relaycache.Header, 0, len(event.message.Headers)) + for _, header := range event.message.Headers { + if !hopByHopHeader(header[0]) && !strings.EqualFold(header[0], "Set-Cookie") { + cacheHeaders = append(cacheHeaders, relaycache.Header(header)) + } + } + cacheWriter, _ = s.cache.Begin( + cacheKey, instanceID, event.message.Status, cacheHeaders, + ) + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + case "response_body": + chunk, decodeErr := base64.StdEncoding.DecodeString(event.message.Body) + if decodeErr != nil { + return + } + if len(chunk) > 0 { + _ = controller.SetWriteDeadline(time.Now().Add(30 * time.Second)) + if _, writeErr := w.Write(chunk); writeErr != nil { + return + } + _ = controller.SetWriteDeadline(time.Time{}) + if cacheWriter != nil { + if writeErr := cacheWriter.Write(chunk); writeErr != nil { + abortCache() + } + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + } + if !event.message.More { + if cacheWriter != nil { + if commitErr := cacheWriter.Commit(); commitErr != nil { + s.logger.Warn("relay cache commit failed", "request_id", requestID, "error", safeError(commitErr)) + } + cacheWriter = nil + } + return + } + case "response_error": + if !started { + writeError(w, http.StatusBadGateway, "coworker_request_failed") + } + return + } + case <-time.After(30 * time.Second): + if !started { + writeError(w, http.StatusGatewayTimeout, "coworker_timeout") + return + } + } + } +} + +func (s *Server) publicHost() string { + parsed, _ := url.Parse(s.config.PublicURL) + return parsed.Host +} + +func (s *Server) serveCached(w http.ResponseWriter, r *http.Request, entry relaycache.Entry) { + file, err := os.Open(entry.Path) + if err != nil { + writeError(w, http.StatusInternalServerError, "cache_read_failed") + return + } + defer file.Close() + for _, header := range entry.Metadata.Headers { + if !strings.EqualFold(header[0], "Content-Length") && !strings.EqualFold(header[0], "Accept-Ranges") { + w.Header().Add(header[0], header[1]) + } + } + w.Header().Set("X-Coworker-Relay-Cache", "hit") + http.ServeContent(w, r, filepath.Base(entry.Path), entry.Metadata.CreatedAt, file) +} + +func orderedHeaders(header http.Header) []protocol.Header { + names := make([]string, 0, len(header)) + for name := range header { + names = append(names, name) + } + sort.Strings(names) + values := make([]protocol.Header, 0, len(header)) + for _, name := range names { + for _, value := range header.Values(name) { + values = append(values, protocol.Header{name, value}) + } + } + return values +} + +func hopByHopHeader(name string) bool { + switch strings.ToLower(name) { + case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade": + return true + default: + return false + } +} + +func forwardedIP(ip string) string { + if strings.Contains(ip, ":") { + return `"` + "[" + ip + "]" + `"` + } + return ip +} + +func newRequestID() string { + raw := make([]byte, 16) + _, _ = rand.Read(raw) + return hex.EncodeToString(raw) +} + +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + remote, err := netip.ParseAddr(strings.Trim(host, "[]")) + if err != nil { + return host + } + trusted := false + for _, prefix := range s.config.TrustedProxies { + if prefix.Contains(remote) { + trusted = true + break + } + } + if trusted { + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { + if parsed, err := netip.ParseAddr(strings.Trim(forwarded, "[]")); err == nil { + return parsed.String() + } + } + } + return remote.String() +} + +func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if !s.allowAnonymous("_admin", s.clientIP(r)) { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited") + return false + } + token, ok := auth.ParseBearer(r.Header.Get("Authorization")) + if !ok || s.config.AdminToken == "" { + writeError(w, http.StatusUnauthorized, "admin_auth_required") + return false + } + left, right := sha256.Sum256([]byte(token)), sha256.Sum256([]byte(s.config.AdminToken)) + if subtle.ConstantTimeCompare(left[:], right[:]) != 1 { + writeError(w, http.StatusForbidden, "admin_auth_invalid") + return false + } + return true +} + +func (s *Server) adminCreateInstance(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + var input struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &input, 8<<10); err != nil || len(input.Name) > 120 { + writeError(w, http.StatusBadRequest, "invalid_instance") + return + } + instance, pairingCode, err := s.store.CreateInstance(input.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + s.logger.Info("relay instance created", "instance_id", instance.ID) + writeJSON(w, http.StatusCreated, map[string]any{ + "instance": publicInstance(instance, s.isOnline(instance.ID)), + "pairing_code": pairingCode, + "expires_in": 600, + "relay_url": s.config.PublicURL, + }) +} + +func (s *Server) adminListInstances(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + instances, err := s.store.ListInstances() + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + result := make([]map[string]any, 0, len(instances)) + for _, instance := range instances { + result = append(result, publicInstance(instance, s.isOnline(instance.ID))) + } + writeJSON(w, http.StatusOK, map[string]any{"instances": result}) +} + +func (s *Server) adminDeleteInstance(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + id := r.PathValue("instance") + if err := s.store.DeleteInstance(id); err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + if s.cache != nil { + if err := s.cache.PurgeInstance(id); err != nil { + s.logger.Warn("relay instance cache cleanup failed", "instance_id", id, "error", safeError(err)) + } + } + s.tunnelsMu.RLock() + current := s.tunnels[id] + s.tunnelsMu.RUnlock() + if current != nil { + _ = current.conn.Close(websocket.StatusPolicyViolation, "instance revoked") + } + s.logger.Info("relay instance revoked", "instance_id", id) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) adminRotateCredential(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + id := r.PathValue("instance") + credential, err := s.store.PrepareCredential(id) + if err != nil { + writeError(w, http.StatusNotFound, "instance_not_found") + return + } + s.logger.Info("relay instance credential rotation prepared", "instance_id", id) + writeJSON(w, http.StatusOK, map[string]string{ + "instance_id": id, "instance_credential": credential, + }) +} + +func (s *Server) adminUpdateAuth(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + var input struct { + Mode string `json:"mode"` + } + if err := decodeJSON(r, &input, 8<<10); err != nil { + writeError(w, http.StatusBadRequest, "invalid_update_auth_mode") + return + } + id := r.PathValue("instance") + if err := s.store.SetUpdateAuthMode(id, input.Mode); err != nil { + writeError(w, http.StatusBadRequest, "invalid_update_auth_mode") + return + } + s.logger.Info("relay update auth changed", "instance_id", id, "mode", input.Mode) + writeJSON(w, http.StatusOK, map[string]string{"instance_id": id, "mode": input.Mode}) +} + +func (s *Server) adminUpdateStats(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + stats, err := s.store.UpdateStatistics(r.PathValue("instance"), time.Now().UTC()) + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + writeJSON(w, http.StatusOK, stats) +} + +func (s *Server) adminListBans(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + bans, err := s.store.ListBans(r.URL.Query().Get("instance")) + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"bans": bans}) +} + +func (s *Server) adminRemoveBan(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + instanceID, ip := r.URL.Query().Get("instance"), r.URL.Query().Get("ip") + reason := strings.TrimSpace(r.URL.Query().Get("reason")) + if instanceID == "" || ip == "" || reason == "" || len(reason) > 500 { + writeError(w, http.StatusBadRequest, "instance_ip_reason_required") + return + } + if err := s.store.RemoveBan(instanceID, ip); err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + s.logger.Info("relay ban removed", "instance_id", instanceID, "source_ip", ip, "reason", reason) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) adminCacheStats(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + if s.cache == nil { + writeJSON(w, http.StatusOK, map[string]int64{"entries": 0, "bytes": 0, "max_bytes": 0}) + return + } + stats, err := s.cache.Stats() + if err != nil { + writeError(w, http.StatusInternalServerError, "cache_read_failed") + return + } + writeJSON(w, http.StatusOK, stats) +} + +func (s *Server) adminCachePurge(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + if s.cache != nil { + if err := s.cache.Purge(); err != nil { + writeError(w, http.StatusInternalServerError, "cache_purge_failed") + return + } + } + s.logger.Info("relay cache purged") + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set( + "Content-Disposition", + fmt.Sprintf(`attachment; filename="coworker-relay-%s.db"`, time.Now().UTC().Format("20060102T150405Z")), + ) + w.Header().Set("Cache-Control", "no-store") + if err := s.store.Backup(w); err != nil { + s.logger.Error("relay backup failed", "error", safeError(err)) + } +} + +func (s *Server) adminGarbageCollect(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + removed, err := s.store.GarbageCollect(time.Now().UTC()) + if err != nil { + writeError(w, http.StatusInternalServerError, "store_failed") + return + } + s.logger.Info("relay garbage collection completed", "removed", removed) + writeJSON(w, http.StatusOK, map[string]any{"removed": removed}) +} + +func (s *Server) adminMetrics(w http.ResponseWriter, r *http.Request) { + if !s.requireAdmin(w, r) { + return + } + s.tunnelsMu.RLock() + online := len(s.tunnels) + s.tunnelsMu.RUnlock() + cacheStats := map[string]int64{"entries": 0, "bytes": 0, "max_bytes": 0} + if s.cache != nil { + if values, err := s.cache.Stats(); err == nil { + cacheStats = values + } + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + _, _ = fmt.Fprintf( + w, + "# TYPE coworker_relay_requests_total counter\n"+ + "coworker_relay_requests_total %d\n"+ + "# TYPE coworker_relay_auth_failures_total counter\n"+ + "coworker_relay_auth_failures_total %d\n"+ + "# TYPE coworker_relay_bans_total counter\n"+ + "coworker_relay_bans_total %d\n"+ + "# TYPE coworker_relay_tunnel_connections_total counter\n"+ + "coworker_relay_tunnel_connections_total %d\n"+ + "# TYPE coworker_relay_tunnels gauge\n"+ + "coworker_relay_tunnels %d\n"+ + "# TYPE coworker_relay_request_duration_seconds summary\n"+ + "coworker_relay_request_duration_seconds_count %d\n"+ + "coworker_relay_request_duration_seconds_sum %.6f\n"+ + "# TYPE coworker_relay_verifier_inflight gauge\n"+ + "coworker_relay_verifier_inflight %d\n"+ + "# TYPE coworker_relay_cache_hits_total counter\n"+ + "coworker_relay_cache_hits_total %d\n"+ + "# TYPE coworker_relay_cache_misses_total counter\n"+ + "coworker_relay_cache_misses_total %d\n"+ + "# TYPE coworker_relay_cache_entries gauge\n"+ + "coworker_relay_cache_entries %d\n"+ + "# TYPE coworker_relay_cache_bytes gauge\n"+ + "coworker_relay_cache_bytes %d\n"+ + "# TYPE coworker_relay_cache_max_bytes gauge\n"+ + "coworker_relay_cache_max_bytes %d\n", + s.requestTotal.Load(), + s.authFailureTotal.Load(), + s.banTotal.Load(), + s.tunnelConnectTotal.Load(), + online, + s.requestCompleted.Load(), + float64(s.requestDuration.Load())/float64(time.Second), + len(s.verifySem), + s.cacheHitTotal.Load(), + s.cacheMissTotal.Load(), + cacheStats["entries"], + cacheStats["bytes"], + cacheStats["max_bytes"], + ) +} + +func (s *Server) isOnline(id string) bool { + s.tunnelsMu.RLock() + defer s.tunnelsMu.RUnlock() + return s.tunnels[id] != nil +} + +func publicInstance(instance store.Instance, online bool) map[string]any { + return map[string]any{ + "id": instance.ID, + "name": instance.Name, + "online": online, + "enrolled": instance.CredentialHash != "", + "update_auth_mode": instance.UpdateAuthMode, + "created_at": instance.CreatedAt, + "last_connected_at": instance.LastConnectedAt, + "verifier_generation": instance.VerifierGeneration, + } +} + +func safeError(err error) string { + if err == nil { + return "" + } + return strings.Split(err.Error(), "\n")[0] +} + +func ParseTrustedProxies(raw string) ([]netip.Prefix, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var result []netip.Prefix + for _, value := range strings.Split(raw, ",") { + prefix, err := netip.ParsePrefix(strings.TrimSpace(value)) + if err != nil { + return nil, err + } + result = append(result, prefix) + } + return result, nil +} + +func ValidatePublicURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimRight(raw, "/")) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" { + return "", errors.New("RELAY_PUBLIC_URL must be an HTTPS origin without credentials, path, query, or fragment") + } + return parsed.String(), nil +} diff --git a/apps/coworker-relay/internal/server/server_test.go b/apps/coworker-relay/internal/server/server_test.go new file mode 100644 index 0000000..10a1ce5 --- /dev/null +++ b/apps/coworker-relay/internal/server/server_test.go @@ -0,0 +1,231 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/VirtualBeingsResearch/CoWorker/apps/coworker-relay/internal/store" +) + +func testServer(t *testing.T) (*Server, *store.Store) { + t.Helper() + database, err := store.Open(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + return New(Config{ + PublicURL: "https://relay.example.com", + AdminToken: "administrator-token-long-enough", + }, database, slog.New(slog.NewTextHandler(io.Discard, nil))), database +} + +func TestAnonymousStatusDoesNotNeedTunnel(t *testing.T) { + service, database := testServer(t) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/i/"+instance.ID+"/status", nil) + request.RemoteAddr = "203.0.113.4:1234" + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != "{}" { + t.Fatalf("unexpected status response: %d %q", response.Code, response.Body.String()) + } +} + +func TestReadinessTurnsUnavailableWhenDraining(t *testing.T) { + service, _ := testServer(t) + response := httptest.NewRecorder() + service.Handler().ServeHTTP( + response, + httptest.NewRequest(http.MethodGet, "/_relay/v1/readyz", nil), + ) + if response.Code != http.StatusOK { + t.Fatalf("ready server returned %d", response.Code) + } + service.Drain() + response = httptest.NewRecorder() + service.Handler().ServeHTTP( + response, + httptest.NewRequest(http.MethodGet, "/_relay/v1/readyz", nil), + ) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("draining server returned %d", response.Code) + } +} + +func TestInstanceCanRotateItsOwnCredential(t *testing.T) { + service, database := testServer(t) + instance, pairing, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + _, previous, err := database.Enroll(pairing, "$argon2id$test") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest( + http.MethodPost, + "/_relay/v1/credential/rotate", + nil, + ) + request.Header.Set("Authorization", "Bearer "+previous) + request.Header.Set("X-Coworker-Relay-Instance", instance.ID) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("credential rotation returned %d: %s", response.Code, response.Body.String()) + } + if _, err := database.AuthenticateInstance(instance.ID, previous); err != nil { + t.Fatalf("old credential was invalidated before new credential login: %v", err) + } + var payload map[string]string + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if _, err := database.AuthenticateInstance( + instance.ID, + payload["instance_credential"], + ); err != nil { + t.Fatalf("new credential could not promote: %v", err) + } + if _, err := database.AuthenticateInstance(instance.ID, previous); err == nil { + t.Fatal("old instance credential remained valid after promotion") + } +} + +func TestUnknownAndManagementRoutesAreNotExposed(t *testing.T) { + service, database := testServer(t) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/api/admin/config", "/api/desktop-updates/releases", "/unknown"} { + request := httptest.NewRequest(http.MethodGet, "/i/"+instance.ID+path, nil) + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("%s returned %d", path, response.Code) + } + } +} + +func TestMissingBearerIsNotCountedAsPasswordFailure(t *testing.T) { + service, database := testServer(t) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 6; attempt++ { + request := httptest.NewRequest(http.MethodPost, "/i/"+instance.ID+"/messages", nil) + request.RemoteAddr = "203.0.113.4:1234" + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d returned %d", attempt, response.Code) + } + } + if _, active, _ := database.ActiveBan(instance.ID, "203.0.113.4", testNow()); active { + t.Fatal("missing bearer caused a password ban") + } +} + +func TestSecurityLogDoesNotContainTokenOrRequestBody(t *testing.T) { + database, err := store.Open(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + var logs bytes.Buffer + service := New( + Config{ + PublicURL: "https://relay.example.com", AdminToken: "administrator-token-long-enough", + }, + database, + slog.New(slog.NewJSONHandler(&logs, nil)), + ) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest( + http.MethodPost, + "/i/"+instance.ID+"/messages", + strings.NewReader("private-message-body"), + ) + request.Header.Set("Authorization", "Bearer secret-that-must-not-be-logged") + request.RemoteAddr = "203.0.113.4:1234" + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized { + t.Fatalf("unexpected response: %d", response.Code) + } + for _, secret := range []string{ + "secret-that-must-not-be-logged", + "private-message-body", + "Authorization", + } { + if strings.Contains(logs.String(), secret) { + t.Fatalf("security log contains %q: %s", secret, logs.String()) + } + } +} + +func TestPublicURLMustBeAnHTTPSOrigin(t *testing.T) { + for _, value := range []string{ + "http://relay.example.com", + "https://relay.example.com/base", + "https://user@relay.example.com", + } { + if _, err := ValidatePublicURL(value); err == nil { + t.Fatalf("accepted invalid public URL %q", value) + } + } + value, err := ValidatePublicURL("https://relay.example.com:8443/") + if err != nil || value != "https://relay.example.com:8443" { + t.Fatalf("unexpected normalized URL %q: %v", value, err) + } +} + +func TestPerInstanceSourceRequestLimit(t *testing.T) { + service, _ := testServer(t) + for request := 1; request <= 600; request++ { + if !service.allowRequest("cw_test", "203.0.113.4") { + t.Fatalf("request %d was limited too early", request) + } + } + if service.allowRequest("cw_test", "203.0.113.4") { + t.Fatal("request limit did not reject request 601") + } + if !service.allowRequest("cw_other", "203.0.113.4") { + t.Fatal("request limit leaked across instances") + } +} + +func TestUpdateAssetCacheKeyIgnoresQueryParameters(t *testing.T) { + first, err := url.Parse("/api/desktop-updates/assets/update.tar.gz?cache_bust=one") + if err != nil { + t.Fatal(err) + } + second, err := url.Parse("/api/desktop-updates/assets/update.tar.gz?cache_bust=two") + if err != nil { + t.Fatal(err) + } + if updateAssetCacheKey("cw_test", first) != updateAssetCacheKey("cw_test", second) { + t.Fatal("query parameters created duplicate update cache entries") + } +} + +func testNow() (value time.Time) { return time.Now().UTC() } diff --git a/apps/coworker-relay/internal/store/store.go b/apps/coworker-relay/internal/store/store.go new file mode 100644 index 0000000..8f1b9af --- /dev/null +++ b/apps/coworker-relay/internal/store/store.go @@ -0,0 +1,611 @@ +package store + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + bolt "go.etcd.io/bbolt" +) + +var ( + instancesBucket = []byte("instances") + pairingsBucket = []byte("pairings") + failuresBucket = []byte("failures") + bansBucket = []byte("bans") + updateStatsBucket = []byte("update_stats") + metaBucket = []byte("meta") + schemaVersionKey = []byte("schema_version") +) + +const currentSchemaVersion = 1 + +type Instance struct { + ID string `json:"id"` + Name string `json:"name"` + CredentialHash string `json:"credential_hash"` + PendingCredentialHash string `json:"pending_credential_hash,omitempty"` + Verifier string `json:"verifier"` + VerifierGeneration string `json:"verifier_generation"` + UpdateAuthMode string `json:"update_auth_mode"` + CreatedAt time.Time `json:"created_at"` + LastConnectedAt time.Time `json:"last_connected_at,omitempty"` + LastAnonymousUpdate time.Time `json:"last_anonymous_update,omitempty"` +} + +type Pairing struct { + InstanceID string `json:"instance_id"` + ExpiresAt time.Time `json:"expires_at"` + Used bool `json:"used"` +} + +type Ban struct { + InstanceID string `json:"instance_id"` + IP string `json:"ip"` + Until time.Time `json:"until"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +type Store struct { + db *bolt.DB + failureWindow time.Duration + failureLimit int + banDuration time.Duration +} + +type UpdateDay struct { + Authenticated uint64 `json:"authenticated"` + Anonymous uint64 `json:"anonymous"` + Versions map[string]uint64 `json:"versions"` +} + +type UpdateStats struct { + Days map[string]UpdateDay `json:"days"` +} + +func Open(path string) (*Store, error) { + db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 5 * time.Second}) + if err != nil { + return nil, err + } + err = db.Update(func(tx *bolt.Tx) error { + for _, name := range [][]byte{ + instancesBucket, pairingsBucket, failuresBucket, bansBucket, updateStatsBucket, + metaBucket, + } { + if _, err := tx.CreateBucketIfNotExists(name); err != nil { + return err + } + } + meta := tx.Bucket(metaBucket) + rawVersion := meta.Get(schemaVersionKey) + if rawVersion == nil { + return meta.Put(schemaVersionKey, []byte("1")) + } + if string(rawVersion) != "1" { + return fmt.Errorf( + "relay database schema %s is unsupported by this build (supports %d)", + string(rawVersion), + currentSchemaVersion, + ) + } + return nil + }) + if err != nil { + _ = db.Close() + return nil, err + } + return &Store{ + db: db, failureWindow: 10 * time.Minute, failureLimit: 5, + banDuration: time.Hour, + }, nil +} + +func Validate(path string) error { + db, err := bolt.Open(path, 0o600, &bolt.Options{ReadOnly: true, Timeout: 5 * time.Second}) + if err != nil { + return err + } + defer db.Close() + return db.View(func(tx *bolt.Tx) error { + meta := tx.Bucket(metaBucket) + if meta == nil || string(meta.Get(schemaVersionKey)) != "1" { + return errors.New("backup has an unsupported or missing relay database schema") + } + if tx.Bucket(instancesBucket) == nil { + return errors.New("backup is missing the instances bucket") + } + return nil + }) +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Check() error { + return s.db.View(func(tx *bolt.Tx) error { + if tx.Bucket(instancesBucket) == nil || tx.Bucket(metaBucket) == nil { + return errors.New("relay database is missing required buckets") + } + return nil + }) +} + +func (s *Store) Backup(writer io.Writer) error { + return s.db.View(func(tx *bolt.Tx) error { + _, err := tx.WriteTo(writer) + return err + }) +} + +func (s *Store) SetAuthPolicy( + failureWindow time.Duration, + failureLimit int, + banDuration time.Duration, +) error { + if failureWindow <= 0 || failureLimit < 1 || failureLimit > 100 || banDuration <= 0 { + return errors.New("invalid relay authentication policy") + } + s.failureWindow = failureWindow + s.failureLimit = failureLimit + s.banDuration = banDuration + return nil +} + +func randomToken(bytes int) (string, error) { + raw := make([]byte, bytes) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return strings.TrimRight(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw), "="), nil +} + +func digest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func putJSON(bucket *bolt.Bucket, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + return bucket.Put([]byte(key), raw) +} + +func getJSON[T any](bucket *bolt.Bucket, key string) (T, error) { + var value T + raw := bucket.Get([]byte(key)) + if raw == nil { + return value, errors.New("not found") + } + err := json.Unmarshal(raw, &value) + return value, err +} + +func (s *Store) CreateInstance(name string) (Instance, string, error) { + idToken, err := randomToken(10) + if err != nil { + return Instance{}, "", err + } + pairingCode, err := randomToken(12) + if err != nil { + return Instance{}, "", err + } + instance := Instance{ + ID: "cw_" + strings.ToLower(idToken), + Name: strings.TrimSpace(name), + UpdateAuthMode: "optional", + CreatedAt: time.Now().UTC(), + } + pairing := Pairing{ + InstanceID: instance.ID, + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + } + err = s.db.Update(func(tx *bolt.Tx) error { + if err := putJSON(tx.Bucket(instancesBucket), instance.ID, instance); err != nil { + return err + } + return putJSON(tx.Bucket(pairingsBucket), digest(pairingCode), pairing) + }) + return instance, pairingCode, err +} + +func (s *Store) Enroll(pairingCode, verifier string) (Instance, string, error) { + var instance Instance + credential, err := randomToken(32) + if err != nil { + return instance, "", err + } + err = s.db.Update(func(tx *bolt.Tx) error { + pairings := tx.Bucket(pairingsBucket) + pairing, err := getJSON[Pairing](pairings, digest(strings.TrimSpace(pairingCode))) + if err != nil || pairing.Used || time.Now().UTC().After(pairing.ExpiresAt) { + return errors.New("pairing code is invalid, expired, or already used") + } + instance, err = getJSON[Instance](tx.Bucket(instancesBucket), pairing.InstanceID) + if err != nil { + return err + } + instance.CredentialHash = digest(credential) + instance.Verifier = verifier + pairing.Used = true + if err := putJSON(tx.Bucket(instancesBucket), instance.ID, instance); err != nil { + return err + } + return putJSON(pairings, digest(strings.TrimSpace(pairingCode)), pairing) + }) + return instance, credential, err +} + +func (s *Store) GetInstance(id string) (Instance, error) { + var instance Instance + err := s.db.View(func(tx *bolt.Tx) error { + var err error + instance, err = getJSON[Instance](tx.Bucket(instancesBucket), id) + return err + }) + return instance, err +} + +func (s *Store) AuthenticateInstance(id, credential string) (Instance, error) { + var instance Instance + err := s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(instancesBucket) + var err error + instance, err = getJSON[Instance](bucket, id) + if err != nil { + return errors.New("invalid instance credential") + } + candidate := digest(credential) + if instance.CredentialHash != "" && + subtle.ConstantTimeCompare( + []byte(candidate), + []byte(instance.CredentialHash), + ) == 1 { + return nil + } + if instance.PendingCredentialHash != "" && + subtle.ConstantTimeCompare( + []byte(candidate), + []byte(instance.PendingCredentialHash), + ) == 1 { + instance.CredentialHash = instance.PendingCredentialHash + instance.PendingCredentialHash = "" + return putJSON(bucket, id, instance) + } + return errors.New("invalid instance credential") + }) + if err != nil { + return Instance{}, errors.New("invalid instance credential") + } + return instance, nil +} + +func (s *Store) UpdateVerifier(id, verifier, generation string) error { + return s.updateInstance(id, func(instance *Instance) { + instance.Verifier = verifier + instance.VerifierGeneration = generation + instance.LastConnectedAt = time.Now().UTC() + }) +} + +func (s *Store) PrepareCredential(id string) (string, error) { + credential, err := randomToken(32) + if err != nil { + return "", err + } + err = s.updateInstance(id, func(instance *Instance) { + instance.PendingCredentialHash = digest(credential) + }) + return credential, err +} + +func (s *Store) SetUpdateAuthMode(id, mode string) error { + if mode != "optional" && mode != "required" { + return errors.New("update auth mode must be optional or required") + } + return s.updateInstance(id, func(instance *Instance) { instance.UpdateAuthMode = mode }) +} + +func (s *Store) updateInstance(id string, mutate func(*Instance)) error { + return s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(instancesBucket) + instance, err := getJSON[Instance](bucket, id) + if err != nil { + return err + } + mutate(&instance) + return putJSON(bucket, id, instance) + }) +} + +func (s *Store) DeleteInstance(id string) error { + return s.db.Update(func(tx *bolt.Tx) error { + if err := tx.Bucket(instancesBucket).Delete([]byte(id)); err != nil { + return err + } + if err := tx.Bucket(updateStatsBucket).Delete([]byte(id)); err != nil { + return err + } + pairings := tx.Bucket(pairingsBucket) + var pairingKeys [][]byte + if err := pairings.ForEach(func(key, raw []byte) error { + var pairing Pairing + if json.Unmarshal(raw, &pairing) == nil && pairing.InstanceID == id { + pairingKeys = append(pairingKeys, append([]byte(nil), key...)) + } + return nil + }); err != nil { + return err + } + for _, key := range pairingKeys { + if err := pairings.Delete(key); err != nil { + return err + } + } + for _, bucketName := range [][]byte{failuresBucket, bansBucket} { + bucket := tx.Bucket(bucketName) + var keys [][]byte + prefix := []byte(id + "\x00") + cursor := bucket.Cursor() + for key, _ := cursor.Seek(prefix); key != nil && strings.HasPrefix(string(key), string(prefix)); key, _ = cursor.Next() { + keys = append(keys, append([]byte(nil), key...)) + } + for _, key := range keys { + if err := bucket.Delete(key); err != nil { + return err + } + } + } + return nil + }) +} + +func (s *Store) GarbageCollect(now time.Time) (map[string]int, error) { + removed := map[string]int{"pairings": 0, "failures": 0, "bans": 0} + err := s.db.Update(func(tx *bolt.Tx) error { + type target struct { + name string + bucket []byte + expired func([]byte) bool + } + targets := []target{ + { + name: "pairings", bucket: pairingsBucket, + expired: func(raw []byte) bool { + var value Pairing + return json.Unmarshal(raw, &value) != nil || value.Used || !value.ExpiresAt.After(now) + }, + }, + { + name: "failures", bucket: failuresBucket, + expired: func(raw []byte) bool { + var values []time.Time + if json.Unmarshal(raw, &values) != nil { + return true + } + cutoff := now.Add(-s.failureWindow) + for _, value := range values { + if value.After(cutoff) { + return false + } + } + return true + }, + }, + { + name: "bans", bucket: bansBucket, + expired: func(raw []byte) bool { + var value Ban + return json.Unmarshal(raw, &value) != nil || !value.Until.After(now) + }, + }, + } + for _, item := range targets { + bucket := tx.Bucket(item.bucket) + var keys [][]byte + if err := bucket.ForEach(func(key, raw []byte) error { + if item.expired(raw) { + keys = append(keys, append([]byte(nil), key...)) + } + return nil + }); err != nil { + return err + } + for _, key := range keys { + if err := bucket.Delete(key); err != nil { + return err + } + removed[item.name]++ + } + } + return nil + }) + return removed, err +} + +func (s *Store) ListInstances() ([]Instance, error) { + var values []Instance + err := s.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(instancesBucket).ForEach(func(_, raw []byte) error { + var item Instance + if err := json.Unmarshal(raw, &item); err != nil { + return err + } + values = append(values, item) + return nil + }) + }) + return values, err +} + +func failureKey(instanceID, ip string) string { return instanceID + "\x00" + ip } + +func (s *Store) RecordFailure(instanceID, ip string, now time.Time) (bool, time.Time, error) { + key := failureKey(instanceID, ip) + var bannedUntil time.Time + err := s.db.Update(func(tx *bolt.Tx) error { + failures := tx.Bucket(failuresBucket) + var times []time.Time + if raw := failures.Get([]byte(key)); raw != nil { + _ = json.Unmarshal(raw, ×) + } + cutoff := now.Add(-s.failureWindow) + filtered := times[:0] + for _, value := range times { + if value.After(cutoff) { + filtered = append(filtered, value) + } + } + filtered = append(filtered, now) + if len(filtered) >= s.failureLimit { + bannedUntil = now.Add(s.banDuration) + ban := Ban{InstanceID: instanceID, IP: ip, Until: bannedUntil, Reason: "authentication failures", CreatedAt: now} + if err := putJSON(tx.Bucket(bansBucket), key, ban); err != nil { + return err + } + return failures.Delete([]byte(key)) + } + return putJSON(failures, key, filtered) + }) + return !bannedUntil.IsZero(), bannedUntil, err +} + +func (s *Store) ActiveBan(instanceID, ip string, now time.Time) (Ban, bool, error) { + key := failureKey(instanceID, ip) + var ban Ban + var active bool + err := s.db.Update(func(tx *bolt.Tx) error { + bans := tx.Bucket(bansBucket) + value, err := getJSON[Ban](bans, key) + if err != nil { + return nil + } + if !value.Until.After(now) { + return bans.Delete([]byte(key)) + } + ban, active = value, true + return nil + }) + return ban, active, err +} + +func (s *Store) ListBans(instanceID string) ([]Ban, error) { + var values []Ban + now := time.Now().UTC() + err := s.db.View(func(tx *bolt.Tx) error { + return tx.Bucket(bansBucket).ForEach(func(_, raw []byte) error { + var item Ban + if err := json.Unmarshal(raw, &item); err != nil { + return err + } + if item.Until.After(now) && (instanceID == "" || item.InstanceID == instanceID) { + values = append(values, item) + } + return nil + }) + }) + return values, err +} + +func (s *Store) RemoveBan(instanceID, ip string) error { + return s.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(bansBucket).Delete([]byte(failureKey(instanceID, ip))) + }) +} + +func (s *Store) RecordUpdateCheck( + instanceID, version string, + authenticated bool, + now time.Time, +) error { + return s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(updateStatsBucket) + stats := UpdateStats{Days: make(map[string]UpdateDay)} + if raw := bucket.Get([]byte(instanceID)); raw != nil { + if err := json.Unmarshal(raw, &stats); err != nil { + return err + } + } + dayKey := now.UTC().Format("2006-01-02") + day := stats.Days[dayKey] + if day.Versions == nil { + day.Versions = make(map[string]uint64) + } + if authenticated { + day.Authenticated++ + } else { + day.Anonymous++ + instances := tx.Bucket(instancesBucket) + instance, err := getJSON[Instance](instances, instanceID) + if err == nil { + instance.LastAnonymousUpdate = now.UTC() + if err := putJSON(instances, instanceID, instance); err != nil { + return err + } + } + } + if version != "" { + day.Versions[version]++ + } + stats.Days[dayKey] = day + cutoff := now.UTC().AddDate(0, 0, -31) + for key := range stats.Days { + parsed, err := time.Parse("2006-01-02", key) + if err != nil || parsed.Before(cutoff) { + delete(stats.Days, key) + } + } + return putJSON(bucket, instanceID, stats) + }) +} + +func (s *Store) UpdateStatistics(instanceID string, now time.Time) (map[string]any, error) { + stats := UpdateStats{Days: make(map[string]UpdateDay)} + err := s.db.View(func(tx *bolt.Tx) error { + raw := tx.Bucket(updateStatsBucket).Get([]byte(instanceID)) + if raw == nil { + return nil + } + return json.Unmarshal(raw, &stats) + }) + if err != nil { + return nil, err + } + aggregate := func(days int) map[string]any { + cutoff := now.UTC().AddDate(0, 0, -(days - 1)) + var authenticated, anonymous uint64 + versions := make(map[string]uint64) + for key, day := range stats.Days { + parsed, err := time.Parse("2006-01-02", key) + if err != nil || parsed.Before(cutoff) { + continue + } + authenticated += day.Authenticated + anonymous += day.Anonymous + for version, count := range day.Versions { + versions[version] += count + } + } + return map[string]any{ + "authenticated": authenticated, + "anonymous": anonymous, + "versions": versions, + } + } + instance, _ := s.GetInstance(instanceID) + return map[string]any{ + "last_7_days": aggregate(7), + "last_30_days": aggregate(30), + "last_anonymous_update": instance.LastAnonymousUpdate, + }, nil +} diff --git a/apps/coworker-relay/internal/store/store_test.go b/apps/coworker-relay/internal/store/store_test.go new file mode 100644 index 0000000..bf5322d --- /dev/null +++ b/apps/coworker-relay/internal/store/store_test.go @@ -0,0 +1,182 @@ +package store + +import ( + "bytes" + "path/filepath" + "testing" + "time" + + bolt "go.etcd.io/bbolt" +) + +func openTestStore(t *testing.T) *Store { + t.Helper() + value, err := Open(filepath.Join(t.TempDir(), "relay.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = value.Close() }) + return value +} + +func TestPreparedCredentialPromotesOnlyAfterSuccessfulUse(t *testing.T) { + database := openTestStore(t) + instance, code, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + _, previous, err := database.Enroll(code, "$argon2id$test") + if err != nil { + t.Fatal(err) + } + next, err := database.PrepareCredential(instance.ID) + if err != nil { + t.Fatal(err) + } + if _, err := database.AuthenticateInstance(instance.ID, previous); err != nil { + t.Fatalf("current credential was invalidated before promotion: %v", err) + } + if _, err := database.AuthenticateInstance(instance.ID, next); err != nil { + t.Fatalf("pending credential could not promote: %v", err) + } + if _, err := database.AuthenticateInstance(instance.ID, previous); err == nil { + t.Fatal("previous credential remained valid after promotion") + } +} + +func TestDeleteInstanceRemovesRelatedSecurityState(t *testing.T) { + database := openTestStore(t) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + for range 5 { + if _, _, err := database.RecordFailure(instance.ID, "203.0.113.5", now); err != nil { + t.Fatal(err) + } + } + if err := database.RecordUpdateCheck(instance.ID, "0.3.5", false, now); err != nil { + t.Fatal(err) + } + if err := database.DeleteInstance(instance.ID); err != nil { + t.Fatal(err) + } + if _, err := database.GetInstance(instance.ID); err == nil { + t.Fatal("instance survived deletion") + } + if bans, err := database.ListBans(instance.ID); err != nil || len(bans) != 0 { + t.Fatalf("instance bans survived deletion: %#v %v", bans, err) + } + if err := database.db.View(func(tx *bolt.Tx) error { + for _, bucketName := range [][]byte{pairingsBucket, failuresBucket, updateStatsBucket} { + if count := tx.Bucket(bucketName).Stats().KeyN; count != 0 { + t.Fatalf("%s retained %d keys", bucketName, count) + } + } + return nil + }); err != nil { + t.Fatal(err) + } +} + +func TestGarbageCollectionAndBackup(t *testing.T) { + database := openTestStore(t) + if _, _, err := database.CreateInstance("home"); err != nil { + t.Fatal(err) + } + removed, err := database.GarbageCollect(time.Now().UTC().Add(11 * time.Minute)) + if err != nil || removed["pairings"] != 1 { + t.Fatalf("unexpected GC result: %#v %v", removed, err) + } + var backup bytes.Buffer + if err := database.Backup(&backup); err != nil || backup.Len() == 0 { + t.Fatalf("backup failed: bytes=%d err=%v", backup.Len(), err) + } +} + +func TestEnrollmentIsSingleUse(t *testing.T) { + database := openTestStore(t) + created, code, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + enrolled, credential, err := database.Enroll(code, "$argon2id$test") + if err != nil { + t.Fatal(err) + } + if enrolled.ID != created.ID || credential == "" { + t.Fatalf("unexpected enrollment: %#v credential=%q", enrolled, credential) + } + if _, _, err := database.Enroll(code, "$argon2id$test"); err == nil { + t.Fatal("pairing code was accepted twice") + } + if _, err := database.AuthenticateInstance(created.ID, credential); err != nil { + t.Fatalf("credential was not accepted: %v", err) + } +} + +func TestFailureBanPersistsAndCanBeRemoved(t *testing.T) { + database := openTestStore(t) + now := time.Now().UTC() + for attempt := 1; attempt <= 5; attempt++ { + banned, _, err := database.RecordFailure("cw_test", "203.0.113.5", now) + if err != nil { + t.Fatal(err) + } + if banned != (attempt == 5) { + t.Fatalf("attempt %d banned=%v", attempt, banned) + } + } + if _, active, err := database.ActiveBan("cw_test", "203.0.113.5", now); err != nil || !active { + t.Fatalf("expected active ban: active=%v err=%v", active, err) + } + if err := database.RemoveBan("cw_test", "203.0.113.5"); err != nil { + t.Fatal(err) + } + if _, active, _ := database.ActiveBan("cw_test", "203.0.113.5", now); active { + t.Fatal("ban remained active after removal") + } +} + +func TestAuthenticationBanPolicyIsConfigurable(t *testing.T) { + database := openTestStore(t) + if err := database.SetAuthPolicy(time.Minute, 2, 15*time.Minute); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if banned, _, err := database.RecordFailure("cw_test", "203.0.113.6", now); err != nil || banned { + t.Fatalf("first attempt unexpectedly banned: %v %v", banned, err) + } + banned, until, err := database.RecordFailure("cw_test", "203.0.113.6", now) + if err != nil || !banned || until.Sub(now) != 15*time.Minute { + t.Fatalf("custom policy was not applied: %v %v %v", banned, until, err) + } +} + +func TestUpdateStatisticsSeparateAnonymousAndAuthenticatedChecks(t *testing.T) { + database := openTestStore(t) + instance, _, err := database.CreateInstance("home") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := database.RecordUpdateCheck(instance.ID, "0.3.4", false, now); err != nil { + t.Fatal(err) + } + if err := database.RecordUpdateCheck(instance.ID, "0.3.5", true, now); err != nil { + t.Fatal(err) + } + stats, err := database.UpdateStatistics(instance.ID, now) + if err != nil { + t.Fatal(err) + } + window := stats["last_7_days"].(map[string]any) + if window["anonymous"] != uint64(1) || window["authenticated"] != uint64(1) { + t.Fatalf("unexpected update statistics: %#v", window) + } + versions := window["versions"].(map[string]uint64) + if versions["0.3.4"] != 1 || versions["0.3.5"] != 1 { + t.Fatalf("unexpected version counts: %#v", versions) + } +} diff --git a/docs/architecture/data-boundaries.en.md b/docs/architecture/data-boundaries.en.md index 5edd390..c36c37f 100644 --- a/docs/architecture/data-boundaries.en.md +++ b/docs/architecture/data-boundaries.en.md @@ -17,6 +17,8 @@ The following paths are relative to Coworker's working directory unless configur | `data/` | Identity, memory stores, tasks, inboxes and outboxes, attachments, screenshots, runtime state, and logs | | `.coworker/` | Skills, memory palaces, subconscious modes, and user changes to them | | Desktop application data directory | Desktop settings, credentials, bridge state, and logs; the operating system determines the exact location | +| `relay` fields in Coworker administration configuration | Self-hosted Relay URL, instance ID, and long-lived instance credential | +| Relay's separate data volume | Instance verifiers, credential digests, pairing state, source-IP bans/failures, update statistics, ACME state, and installer cache | These files may contain conversations, prompts, tool arguments and results, webpage content, file content, and personal information. `.env`, `providers.json`, and the administration configuration are ordinary local files; Coworker core does not encrypt them for you. Protect them with operating-system permissions, disk encryption, and a least-privileged account. Configuration export bundles include runtime data and secrets and must be handled as credential files. @@ -33,6 +35,10 @@ at runtime. Deleting these volumes also deletes the corresponding history, state - `visual_analyze` sends selected images or videos to the configured vision model service. - Search tools send queries. Browser tools visit target websites and are subject to those sites' logging, cookie, and session policies. - WeCom, the Desktop bridge, and other communication or MCP integrations transmit messages, attachments, and protocol metadata to their corresponding services. +- When self-hosted Relay is enabled, request Bearers, headers, paths, source IPs, message bodies, + attachments, and SSE events pass through Relay during forwarding. Relay does not persist those + bodies, but its administrator controls the TLS termination point and is therefore inside the + full trust boundary. - Installing dependencies, Playwright browsers, or local embedding models connects to package registries, browser download servers, or model repositories. If data must not be shared with an external service, do not configure that service for Coworker and do not let the agent read the relevant files. A self-hosted model changes only the model boundary; it does not automatically restrict search, browser, or other integrations. @@ -42,6 +48,9 @@ If data must not be shared with an external service, do not configure that servi - Coworker is not a security sandbox. Command, file, and browser tools run with the permissions of the operating-system user that started the process. Use a dedicated least-privileged account, container, or virtual machine, and mount only disposable or backed-up directories. - Treat webpages, messages, attachments, skills, memory, and model output as untrusted input. Any of them may contain prompt injection or malicious content. - The API binds to `127.0.0.1` by default. The administrator token protects the administration API, but the current v0.x releases do not provide a complete multitenant authorization boundary for every route. Do not expose port 8000 directly. Remote deployments require TLS, trusted CORS origins, a strong communication token, and additional network access controls. See the [security policy](../../SECURITY.en.md). +- Use [self-hosted Relay](../operations/relay.en.md) when Desktop needs public access instead of + publishing Coworker's port 8000. Relay v1 is a single-node public security boundary, not a + multitenant isolation platform or general proxy. ## Inspection, backup, and cleanup @@ -59,4 +68,9 @@ uv run python scripts/cleanup.py backup-delete `cleanup.py` handles only runtime files under `data/` and preserves `data/_backups/`, so `backup-delete` is not a secure erase. It also does not remove `.env`, `providers.json`, `.coworker/`, `credentials/`, Desktop application data, or Docker volumes. For complete removal, inspect and delete each of those locations and `data/_backups/` only after confirming recovery is no longer needed. Container deployments must also inspect bind-mounted directories and named volumes instead of deleting only the container. +Relay data is outside `cleanup.py`'s scope. Run `coworker-relay backup` before deletion. Use +`coworker-relay instance revoke` for one instance; it cascades through that instance's verifier, +failure/ban state, statistics, and cache. Full decommissioning also requires removing `.env`, +backup files, and the Relay deployment's separate Docker volume. + [← Back to project home](../../README.en.md) diff --git a/docs/architecture/data-boundaries.md b/docs/architecture/data-boundaries.md index bee2524..56a5136 100644 --- a/docs/architecture/data-boundaries.md +++ b/docs/architecture/data-boundaries.md @@ -19,6 +19,8 @@ Coworker 是本地运行的自主 Agent,但“本地运行”不等于“数 | `data/` | 身份、记忆库、任务、收发件箱、附件、截图、运行状态和日志 | | `.coworker/` | 技能、记忆宫殿、潜意识模式及其用户修改 | | 桌面应用的数据目录 | Desktop 的设置、凭据、桥接状态和日志;具体位置由操作系统决定 | +| Coworker 管理配置中的 `relay` 字段 | 自托管 Relay 地址、实例 ID 和实例长期凭据 | +| Relay 的独立数据卷 | 实例 verifier、凭据摘要、配对状态、来源 IP 封禁/失败记录、更新统计、ACME 状态和安装包缓存 | 这些文件可能包含对话、提示词、工具参数与结果、网页内容、文件内容和个人信息。 `.env`、`providers.json` 与管理配置是普通本地文件,Coworker 核心不会替你加密它们; @@ -40,6 +42,9 @@ Coworker 是本地运行的自主 Agent,但“本地运行”不等于“数 - 搜索工具会发送查询词;浏览器工具会访问目标网站,并受到该网站的日志、Cookie 和会话 策略约束。 - 企业微信、Desktop 桥接及其他通信或 MCP 集成会向对应服务传输消息、附件和协议元数据。 +- 启用自托管 Relay 后,请求的 Bearer、Header、路径、来源 IP、消息正文、附件和 SSE + 事件会在转发期间经过 Relay。Relay不持久化这些正文,但 Relay管理员控制 TLS终止点, + 因而属于完整信任边界。 - 安装依赖、Playwright 浏览器或本地嵌入模型时,会连接软件源、浏览器下载源或模型仓库。 若数据不能交给某个外部服务,不要为 Coworker 配置该服务,也不要让 Agent 读取相关文件。 @@ -53,6 +58,8 @@ Coworker 是本地运行的自主 Agent,但“本地运行”不等于“数 - API 默认绑定 `127.0.0.1`。管理员令牌保护管理 API,但当前 v0.x 并非每个路由都具备完整的 多租户授权边界。不要直接暴露 8000 端口;远程部署要求 TLS、可信 CORS 来源、强通信令牌 和额外的网络访问控制。详见 [安全策略](../../SECURITY.md)。 +- 需要从公网访问 Desktop 时,应使用[自托管 Relay](../operations/relay.md),不要直接公开 + Coworker 的 8000 端口。Relay v1 是单节点公网安全边界,不是多租户隔离平台或通用代理。 ## 查看、备份与清理 @@ -74,4 +81,8 @@ uv run python scripts/cleanup.py backup-delete `data/_backups/`;删除前确认不再需要恢复。容器部署还应检查绑定目录和命名卷,而不是只删除 容器。 +Relay数据不在 `cleanup.py` 范围内。删除前先用 `coworker-relay backup` 创建一致快照;撤销单个 +实例使用 `coworker-relay instance revoke`,它会级联清理该实例的 verifier、失败/封禁状态、 +统计和缓存。完整退役还需移除 Relay部署目录中的 `.env`、备份文件和独立 Docker卷。 + [← 返回项目首页](../../README.md) diff --git a/docs/operations/README.en.md b/docs/operations/README.en.md index 7b5b418..1934976 100644 --- a/docs/operations/README.en.md +++ b/docs/operations/README.en.md @@ -7,4 +7,6 @@ This domain contains configuration and operational guidance for deploying and running Coworker. - [Configuration and models](configuration.en.md): environment variables, providers, model selection, multi-instance setup, and runtime model switching. +- [Self-hosted Relay](relay.en.md): path-based public access, Coworker pairing, bans, and Desktop compatibility migration. +- [Relay v1 protocol](relay-protocol.en.md): tunnel frames, header boundaries, streaming limits, and compatibility commitments. - For local data, external services, and cleanup scope, see [Data and trust boundaries](../architecture/data-boundaries.en.md). diff --git a/docs/operations/README.md b/docs/operations/README.md index c03b225..bab94ce 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -7,4 +7,6 @@ 本目录收纳部署和运行 Coworker 时需要维护的配置与运维说明。 - [配置与模型](configuration.md):环境变量、Provider、模型选择、多实例配置和运行时模型切换。 +- [自托管 Relay](relay.md):路径型公网入口、Coworker 配对、封禁与 Desktop 兼容迁移。 +- [Relay v1 协议](relay-protocol.md):隧道帧、Header 边界、流限制与兼容承诺。 - 涉及本地数据、外部服务和清理范围时,另见[数据与信任边界](../architecture/data-boundaries.md)。 diff --git a/docs/operations/relay-protocol.en.md b/docs/operations/relay-protocol.en.md new file mode 100644 index 0000000..b3a7823 --- /dev/null +++ b/docs/operations/relay-protocol.en.md @@ -0,0 +1,81 @@ +# Relay v1 Protocol and Compatibility Boundary + +[中文](relay-protocol.md) · English + +[← Back to Relay operations](relay.en.md) + +This document is the v1 contract between Go Relay and Coworker's built-in Python Relay Client. +Both sides must send protocol version `1`. Relay returns `426 protocol_incompatible` for a +mismatch and does not silently downgrade. + +## Connection and frames + +Coworker opens `wss:///_relay/v1/connect` with its long-lived instance credential. The +tunnel uses UTF-8 JSON text frames: + +- `ping`, `pong`: connection liveness. +- `verifier`, `verifier_ack`: atomic synchronization of the communication token's Argon2id + verifier. +- `request`: an HTTP request that passed Relay's edge authentication. +- `response_start`, `response_body`, `response_error`: streaming responses. +- `cancel`: cancellation when the public request disconnects, times out, or exceeds backpressure. + +`request_id` correlates a request and response within one tunnel connection. Relay does not replay +ordinary business requests after a disconnect and provides no offline outbox. This avoids +duplicating a message when the original result is unknown. Existing Desktop and Coworker protocols +retain their own ACK and deduplication semantics. + +## Headers and source context + +The request frame represents `headers` as an array of `[name, value]` pairs. +`relay_header_start` identifies the first Relay-appended header, allowing Coworker to distinguish +client values from trusted Relay additions. + +Go's `net/http` preserves multiple values for one header name but does not expose the original +global wire order across different names. Relay therefore sorts client headers deterministically +by name, preserves value order within each name, and appends Relay headers afterwards. +Authentication, authorization, and source-IP decisions may use only the authenticated tunnel +context and appended region, never client-supplied duplicates. + +The Python client rejects request frames with invalid header counts, aggregate size, names, +encodings, or `relay_header_start` boundaries, and verifies the Relay-appended header set required +by v1 together with its instance and Request ID. Invalid frames never enter Coworker's ASGI +application. + +## Streaming, limits, and backpressure + +- v1 buffers the complete request body and puts it in one Base64 `request` frame, with a 32 MiB + limit. +- Tunnel text frames are limited to 48 MiB to allow Base64 and protocol overhead. +- Response bodies use multiple `response_body` frames, including SSE and update downloads. +- Each response stream has a bounded buffer. Relay cancels a slow stream instead of consuming + unbounded memory. +- Relay sends `cancel` when a client disconnects, but cancellation is best effort and is not a + transaction rollback. + +Truly streaming request uploads, resumable request delivery, and generic file tunnels are outside +v1. + +## Update cache + +In v1, Coworker's existing read-only update endpoints still produce update responses. Relay caches +only allowlisted installer paths that return `200`, and authentication runs again before every +cache hit. A client cannot submit an arbitrary upstream URL, and Relay is not a general upstream +downloader. + +Cache keys use the instance and asset path, so semantically irrelevant query parameters do not +create duplicate copies. A concurrent-fill lock is reclaimed after its last user exits. Content is +checked with SHA-256 when written, on the first read after Relay restarts, after the file changes, +and periodically; ordinary hits do not reread the entire installer. The cache supports ETag and +Range reads. Desktop independently verifies Tauri update signatures; cache integrity does not +replace release signing. + +## Compatibility commitment + +- Protocol `1` may add response JSON fields that receivers ignore, but existing field meanings + cannot change. +- New frame types, chunked request uploads, or authentication-semantic changes require a new + protocol version or capability negotiation. +- Upgrade Relay and Coworker together. `coworker-relay health` reports Relay's build and protocol + versions. +- v1 is single-node. An instance has one active tunnel; a new connection supersedes the old one. diff --git a/docs/operations/relay-protocol.md b/docs/operations/relay-protocol.md new file mode 100644 index 0000000..3ef94c8 --- /dev/null +++ b/docs/operations/relay-protocol.md @@ -0,0 +1,65 @@ +# Relay v1 协议与兼容边界 + +中文 · [English](relay-protocol.en.md) + +[← 返回 Relay 运维](relay.md) + +本文是 Go Relay 与 Python 内置 Relay Client 之间的 v1 契约。两端都必须发送协议版本 +`1`;版本不一致时 Relay 返回 `426 protocol_incompatible`,不会尝试降级。 + +## 连接与帧 + +Coworker 使用实例长期凭据建立 +`wss:///_relay/v1/connect`。隧道采用 UTF-8 JSON 文本帧,帧类型包括: + +- `ping`、`pong`:连接存活检测。 +- `verifier`、`verifier_ack`:原子同步 communication token 的 Argon2id verifier。 +- `request`:一个经过 Relay 前置认证的 HTTP 请求。 +- `response_start`、`response_body`、`response_error`:流式响应。 +- `cancel`:公网请求断开、超时或背压溢出时取消 Coworker 内部请求。 + +`request_id` 在一个隧道连接内关联请求和响应。Relay不会在断线后自动重放普通业务 +请求,也不提供离线 outbox;这避免在无法确认处理结果时重复发送消息。Desktop和 +Coworker现有协议负责其自身已有的 ACK 和去重语义。 + +## Header 与来源上下文 + +请求帧中的 `headers` 是 `[名称, 值]` 对组成的数组。`relay_header_start` 指向 Relay +追加 Header 的第一个元素,因此 Coworker可以区分客户端值和 Relay可信追加值。 + +Go 的 `net/http` 会保留同名 Header 的多个值,但不会暴露 HTTP 报文中不同名称 Header +的原始全局顺序。Relay因此按名称确定性排列客户端 Header,并保留每个名称内部的值顺序; +随后追加 Relay Header。任何认证、授权或来源 IP 判断都只能使用已认证隧道上下文和追加 +区域,不能信任客户端提交的同名 Header。 + +Python Client会拒绝 Header数量、总大小、名称、编码或 `relay_header_start` 边界不合法 +的请求帧,并校验 v1 要求的 Relay追加 Header集合、实例和 Request ID;无效帧不会进入 +Coworker ASGI应用。 + +## 流、限制和背压 + +- v1 将完整请求正文读入内存后,以 Base64 放入单个 `request` 帧;上限为 32 MiB。 +- 隧道文本帧上限为 48 MiB,用于容纳 Base64和协议开销。 +- 响应正文通过多个 `response_body` 帧发送,适用于 SSE和安装包下载。 +- 每个响应流有有界缓冲区。消费者过慢时 Relay取消该流,不允许无界占用内存。 +- 客户端断开时 Relay发送 `cancel`,但取消是尽力而为,不能作为事务回滚机制。 + +真正的流式请求上传、断线续传请求和通用文件隧道不属于 v1。 + +## 更新缓存 + +v1 的更新路由仍由 Coworker现有只读更新端点产生响应。Relay只缓存已通过路由白名单 +且状态为 `200` 的安装包响应,并在缓存命中时重新执行认证。客户端不能向 Relay提交 +任意上游 URL;Relay自身也不充当通用上游下载器。 + +缓存键由实例和资产路径组成,无语义的查询参数不会生成重复副本;同一资产的并发填充锁会 +在最后一个使用者退出后回收。内容在写入、Relay重启后的首次读取、文件发生变化以及周期 +复核时使用 SHA-256校验,普通命中不重复读取整个安装包。缓存支持 ETag和 Range读取。 +Desktop继续独立验证 Tauri更新签名,Relay缓存校验不能替代发布签名。 + +## 兼容承诺 + +- 协议 `1` 内可以增加接收方会忽略的响应 JSON 字段,但不能改变已有字段含义。 +- 新帧类型、请求分块上传或认证语义变化需要新的协议版本或能力协商。 +- Relay和 Coworker版本应一起升级;`coworker-relay health` 会显示 Relay构建版本和协议版本。 +- v1 是单节点协议。实例在任一时刻只能有一个活动隧道,新连接会替换旧连接。 diff --git a/docs/operations/relay.en.md b/docs/operations/relay.en.md new file mode 100644 index 0000000..e694497 --- /dev/null +++ b/docs/operations/relay.en.md @@ -0,0 +1,213 @@ +# Self-hosted Relay + +[中文](relay.md) · English + +Coworker Relay lets a Coworker inside a private network open an encrypted outbound connection and expose Desktop status, registration, messaging, SSE, and update endpoints through one path: + +```text +https://relay.example.com/i/{instance_id} +``` + +Relay terminates public HTTPS and can see headers and bodies while processing a request, but it does not persist message bodies, attachments, or SSE events. + +## Deployment + +The `coworker-relay` executable under `apps/coworker-relay/` provides the Relay +service and administration commands. Download the Relay +archive for the current system from the Coworker release, place its executable +on `PATH`, then run: + +```bash +coworker-relay init +cd coworker-relay-deploy +docker compose up -d +``` + +`coworker-relay init` starts an interactive wizard in a terminal. Automated deployments +can continue to use `coworker-relay init --public-url https://relay.example.com:8443`. +Run `coworker-relay --help`, `coworker-relay init --help`, or `--help` on any subcommand for +complete usage. Help never loads configuration or contacts Relay. + +Each Coworker release publishes a multi-platform `linux/amd64` and `linux/arm64` Relay image on +GHCR, plus Linux, macOS, and Windows archives containing `coworker-relay`, +SHA-256 checksums, and build provenance. Pin a version tag in production. For source development, +continue to use `docker build -t coworker-relay .` and `go build ./cmd/...` under +`apps/coworker-relay/`. + +`coworker-relay init` creates a deployment `.env` (mode `0600`), `compose.yaml`, and +.gitignore`; persistent state uses a dedicated Docker volume. It generates a random administrator +token and writes it only to `.env`, without printing it to the terminal. +The external port defaults to 8443. Existing generated files are not replaced; +the interactive wizard requests confirmation, while non-interactive use +requires an explicit `--force`. Public DNS names and public IP addresses use +automatic ACME issuance, installation, and renewal by default. Use +`--tls-cert --tls-key ` for private IPs, private CAs, or existing +certificates. Both service and administration commands automatically read +`.env` in the working directory; `RELAY_CONFIG` selects another file and +explicit process environment values take precedence. The container defaults +to `coworker-relay serve`. For a private CA, +set `RELAY_CA_CERT` to its PEM bundle. This extends normal trust validation; +there is no insecure skip-verification mode. + +The generated container runs without root privileges. In ACME mode Compose maps +public host TCP port 80 to container port 8080 and stores the ACME account, keys, +certificates, and renewal state in the Relay data volume. Public-IP certificates +use the `shortlived` profile. Relay starts renewal around two thirds into the +certificate lifetime, continues serving an unexpired cached certificate after a +renewal failure, and retries hourly. A first-issuance failure or an expired cache +keeps HTTPS unready while Relay retries with jittered exponential backoff; Relay +never falls back to plaintext or a self-signed certificate. + +Minimum configuration: + +```text +RELAY_PUBLIC_URL=https://relay.example.com +RELAY_ADMIN_TOKEN= +RELAY_DATABASE=/var/lib/coworker-relay/relay.db +RELAY_LISTEN=:8443 +``` + +For container secrets, use `RELAY_ADMIN_TOKEN_FILE=/run/secrets/relay-admin-token` instead of +putting `RELAY_ADMIN_TOKEN` directly in the environment. The two settings are mutually exclusive. + +The example Compose file publishes container port `8443` as host port `8443`. +Set `RELAY_PUBLIC_URL=https://relay.example.com:8443` when connecting to it +directly. If a reverse proxy publishes standard port 443, set the public URL to +`https://relay.example.com` and forward that proxy to the container's port 8443. + +Choose external PEM files (`RELAY_TLS_CERT`, `RELAY_TLS_KEY`), DNS-name ACME +(`RELAY_ACME_DOMAIN`), or public-IP ACME (`RELAY_ACME_IP`); ACME also accepts an +optional `RELAY_ACME_EMAIL`. The certificate identifier must match the +`RELAY_PUBLIC_URL` host. Public IPs always use the `shortlived` profile. ACME +requires public TCP port 80 to reach `RELAY_ACME_HTTP_LISTEN`, which defaults to +`:80`; issuance fails if the port is occupied, NAT does not forward it, or the +address does not belong to the host. Private IPs and private CAs use external +PEM files. + +When another reverse proxy is in front, set `RELAY_TRUSTED_PROXY_CIDRS`. Forwarded addresses from other peers are not used as ban identities. + +## Pairing + +```bash +export RELAY_URL=https://relay.example.com +export RELAY_ADMIN_TOKEN='' +coworker-relay instance create --name home-coworker +``` + +When running from the generated deployment directory, the two exports are not +needed because `coworker-relay` reads its `.env`. + +Enter the one-time code on Coworker's Remote access page. It expires after ten minutes and works once. After pairing, copy the displayed Base URL and Bearer Token into Desktop. Test remote connection sends the current communication token to the public instance's `/status`, covering public HTTPS, Relay pre-authentication, the active tunnel, and the Coworker response. + +## Administration + +```bash +coworker-relay health +coworker-relay version +coworker-relay instance list +coworker-relay instance update-auth cw_xxx optional +coworker-relay instance update-auth cw_xxx required +coworker-relay instance update-stats cw_xxx +coworker-relay bans list --instance cw_xxx +coworker-relay bans remove --instance cw_xxx --ip 203.0.113.8 --reason "false positive" +coworker-relay cache inspect +coworker-relay metrics +coworker-relay gc +coworker-relay instance revoke cw_xxx +``` + +“Rotate instance credential” first stages only the new credential digest at Relay. The old +credential remains valid until Coworker persists the new credential and successfully authenticates +a WSS connection with it, at which point Relay promotes it atomically. A lost response or +mid-rotation disconnect cannot lock out the instance. +`coworker-relay instance rotate-credential cw_xxx` is available for emergency +administration, but its returned credential must be written to that Coworker or the tunnel remains +offline. The administrator token is one shared privileged credential; v1 has no multi-admin RBAC. +Distribute it through a secret manager and restrict CLI hosts. + +To rotate the administrator token, generate at least 32 random bytes, update +`RELAY_ADMIN_TOKEN` in the deployment `.env`, and run +`docker compose up -d --force-recreate relay`. The old token becomes invalid when the new process +starts. Back up the database first and update CLI configuration through a secure channel. + +Desktop updates start in `optional` mode so older builds can update anonymously. New builds attach the Coworker Bearer to update checks and package downloads. Switch to `required` after older clients have migrated. + +## Backup, upgrade, and restore + +Create a consistent online bbolt snapshot before upgrading: + +```bash +coworker-relay backup --output relay-before-upgrade.db +docker compose pull +docker compose up -d +coworker-relay health +``` + +Restore only while Relay is stopped. `--force` preserves the previous database under a timestamped +`before-restore` name instead of deleting it: + +```bash +docker compose stop relay +coworker-relay restore \ + --from relay-before-upgrade.db \ + --database /path/to/mounted/relay.db \ + --force +docker compose start relay +``` + +The database has an explicit schema version. Relay refuses a newer or unsupported schema instead +of guessing a downgrade. Treat database backups, `.env`, ACME state, and Coworker's local instance +credential as secrets; encrypt them and test restoration. `coworker-relay gc` immediately removes +expired pairing, failure, and ban records, and Relay also performs hourly collection. Revoking an +instance cascades through its security state, statistics, and cache. + +On SIGTERM, Relay stops accepting work, closes tunnels, and gives HTTP requests up to 30 seconds +to drain. Compose grants a 35-second stop grace period. + +## Health, metrics, and logs + +- `/_relay/v1/livez` reports process liveness. +- `/_relay/v1/readyz` and the compatible `/_relay/v1/health` check draining state, database and + cache availability, and report build and protocol versions. +- `coworker-relay metrics` returns administrator-Bearer-protected Prometheus text for requests, + authentication failures, bans, latency, Argon2 concurrency, tunnel connections, online tunnels, + and cache capacity/hits. + +Relay writes structured JSON logs to stdout. Security logs contain a full source IP, instance, +route category, authentication result, and Request ID, but no token, cookie, body, attachment, or +complete original URL. Configure rotation and retention in the container runtime and treat source +IPs as personal data. Alert on readiness failures, authentication spikes, reconnect storms, cache +quota pressure, and certificate expiry. + +## Capacity and topology boundary + +v1 is single-node: bbolt uses a local volume and tunnel ownership lives in process memory. Never +mount one data volume into multiple Relay replicas. Cold-backup recovery is supported; +active-active and zero-downtime rolling upgrades are not. + +Public requests default to 600 requests per minute per instance and source IP, while anonymous +requests default to 60. Argon2 verification has a global concurrency bound. Tune policy with +`RELAY_REQUESTS_PER_MINUTE`, `RELAY_ANONYMOUS_PER_MINUTE`, +`RELAY_VERIFIER_CONCURRENCY`, `RELAY_BAN_FAILURE_LIMIT`, `RELAY_BAN_FAILURE_WINDOW`, and +`RELAY_BAN_DURATION`. Headers are limited to 32 KiB, request bodies to 32 MiB, and tunnel frames +to 48 MiB. Operators may lower, but not raise, the protocol caps with +`RELAY_MAX_REQUEST_BODY_BYTES` and +`RELAY_MAX_TUNNEL_FRAME_BYTES`. Load-test the expected SSE count, +update size, and instance count, then provision file descriptors, memory, and cache storage. +A reverse proxy must allow WebSocket upgrades, disable SSE response buffering, and use an idle +timeout above Relay's 90 seconds. Only proxies in `RELAY_TRUSTED_PROXY_CIDRS` may supply source IPs. + +See [Relay v1 protocol](relay-protocol.en.md) for frame, header-order, and retry boundaries. + +## Security boundary + +- Relay exposes only Desktop communication and read-only update routes; it is not a general HTTP/TCP proxy. +- Relay authenticates with a derived Argon2id verifier. Coworker's existing endpoint authentication verifies the original Bearer again. +- Five invalid Bearers for one instance and source IP within ten minutes trigger a one-hour ban. +- A missing Bearer does not count as a password failure, but anonymous traffic is rate-limited. +- Enrollment, tunnel authentication, credential rotation, and administrator APIs are also + source-IP-rate-limited before credential verification. +- Relay appends `X-Coworker-Relay-*` while preserving client-supplied duplicates. Coworker identifies Relay traffic through the authenticated tunnel, not headers. +- Private Relay deployments must use a system-trusted private CA. TLS verification cannot be disabled. +- v1 provides no P2P, offline message storage, arbitrary upstream downloading, generic + reverse proxy, or multi-node high availability. diff --git a/docs/operations/relay.md b/docs/operations/relay.md new file mode 100644 index 0000000..34c976f --- /dev/null +++ b/docs/operations/relay.md @@ -0,0 +1,191 @@ +# 自托管 Relay + +中文 · [English](relay.en.md) + +Coworker Relay 让内网中的 Coworker 主动建立加密出站连接,并通过统一路径为 Desktop 提供状态、注册、消息、SSE 和桌面更新服务: + +```text +https://relay.example.com/i/{instance_id} +``` + +Relay 会终止公网 HTTPS,并能在请求处理期间看到 Header 和正文,但不会持久化消息正文、附件或 SSE 事件。 + +## 部署 + +`apps/coworker-relay/` 下的 `coworker-relay` 提供 Relay 服务和管理命令。 +先从 Coworker Release 下载适合当前系统的 Relay +压缩包,将其中的可执行文件放入 `PATH`,然后运行: + +```bash +coworker-relay init +cd coworker-relay-deploy +docker compose up -d +``` + +`coworker-relay init` 在终端中启动交互式向导;自动化部署可继续使用 +`coworker-relay init --public-url https://relay.example.com:8443`。运行 +`coworker-relay --help`、`coworker-relay init --help` 或任意子命令的 `--help` 可查看完整用法, +帮助命令不会读取配置或访问 Relay。 + +每次 Coworker发行会同时提供 GHCR 的 `linux/amd64`、`linux/arm64` Relay镜像,以及 +Linux、macOS和 Windows 的 `coworker-relay` 压缩包、SHA-256和构建来源证明。 +建议生产部署固定版本标签。源码开发时仍可在 `apps/coworker-relay/` 执行 +`docker build -t coworker-relay .` 和 `go build ./cmd/...`。 + +`coworker-relay init` 会生成权限为 `0600` 的部署 `.env`、`compose.yaml` 和 +`.gitignore`,持久化数据使用独立 Docker 卷,并自动生成随机管理员 Token。Token 只写入 +`.env`,不会打印到终端。外部端口默认为 8443;已有生成文件时会拒绝覆盖,交互式向导会 +请求确认,非交互模式只有显式传入 `--force` 才会替换。公网域名和公网 IP 默认使用 +ACME 自动申请、安装和续期证书;私网 IP、私有 CA 或已有证书使用 +`--tls-cert --tls-key `。`coworker-relay` 的服务和管理命令都会自动读取当前 +目录的 `.env`,也可通过 `RELAY_CONFIG` 指定其他文件;进程中显式设置的环境变量优先。 +容器默认执行 `coworker-relay serve`。私有 CA 可通过 +`RELAY_CA_CERT` 指向 PEM 信任包;它只扩展正常的证书信任,CLI 不提供跳过证书 +校验的选项。 + +生成的容器以非 root 用户运行。ACME 模式下,Compose 会把宿主机公网 TCP 80 端口映射到 +容器 8080 端口,并将 ACME账户、私钥、证书和续期状态保存在 Relay 数据卷中。公网 IP +证书使用 `shortlived` profile;Relay 会在证书生命周期约三分之二处开始续期,失败时继续 +使用尚未过期的旧证书并每小时重试。首次签发失败或旧证书已过期时,HTTPS 保持未就绪并 +以带抖动的指数退避重试;Relay 不会以不安全的明文或自签名模式启动。 + +最低配置: + +```text +RELAY_PUBLIC_URL=https://relay.example.com +RELAY_ADMIN_TOKEN=<至少 24 个字符的随机管理令牌> +RELAY_DATABASE=/var/lib/coworker-relay/relay.db +RELAY_LISTEN=:8443 +``` + +容器 secret可用 `RELAY_ADMIN_TOKEN_FILE=/run/secrets/relay-admin-token` 代替直接的 +`RELAY_ADMIN_TOKEN`;两者不能同时设置。 + +示例 Compose 将容器的 `8443` 端口发布为宿主机的 `8443` 端口。直接连接时将 +`RELAY_PUBLIC_URL` 设为 `https://relay.example.com:8443`。如果由反向代理对外 +提供标准 443 端口,则使用 `https://relay.example.com`,并将代理转发到容器 +8443 端口。 + +TLS 可选择外部 PEM(`RELAY_TLS_CERT`、`RELAY_TLS_KEY`)、域名 ACME +(`RELAY_ACME_DOMAIN`)或公网 IP ACME(`RELAY_ACME_IP`),ACME 可选 +`RELAY_ACME_EMAIL`。证书标识必须与 `RELAY_PUBLIC_URL` 的主机一致;公网 IP 自动固定 +使用 `shortlived` profile。ACME 模式还需保证公网 TCP 80 能到达 +`RELAY_ACME_HTTP_LISTEN`,默认 `:80`;如果端口被占用、NAT 未转发或地址不属于本机, +签发会失败。私网 IP 和私有 CA 使用外部 PEM。 + +经过其他反向代理部署时,用 `RELAY_TRUSTED_PROXY_CIDRS` 配置可信代理网段;其他来源提交的转发地址不参与封禁身份判断。 + +## 配对 + +```bash +export RELAY_URL=https://relay.example.com +export RELAY_ADMIN_TOKEN='<管理令牌>' +coworker-relay instance create --name home-coworker +``` + +如果在生成的部署目录中执行,`coworker-relay` 会读取 `.env`,无需手动执行上述两个 +`export`。 + +将输出的一次性配对码填入 Coworker 管理台的“远程访问”页面。配对码十分钟后过期且只能使用一次。配对完成后,把页面显示的 Base URL 和 Bearer Token 填入 Desktop。“测试远程连接”会携带当前通信 Token 请求公开实例的 `/status`,同时验证公网 HTTPS、Relay 前置认证、活动隧道和 Coworker 响应。 + +## 管理 + +```bash +coworker-relay health +coworker-relay version +coworker-relay instance list +coworker-relay instance update-auth cw_xxx optional +coworker-relay instance update-auth cw_xxx required +coworker-relay instance update-stats cw_xxx +coworker-relay bans list --instance cw_xxx +coworker-relay bans remove --instance cw_xxx --ip 203.0.113.8 --reason "误封" +coworker-relay cache inspect +coworker-relay metrics +coworker-relay gc +coworker-relay instance revoke cw_xxx +``` + +在 Coworker管理台点击“轮换实例凭据”会先在 Relay暂存新凭据摘要;旧凭据保持有效, +直到 Coworker持久化新凭据并用它完成 WSS认证后才原子失效。响应丢失或中途断网不会锁死 +实例。管理员应急操作也可 +执行 `coworker-relay instance rotate-credential cw_xxx`,但输出的新凭据必须同步写入对应 +Coworker配置,否则隧道会保持离线。管理员 Token是单一共享高权限凭据,v1不提供 +多管理员 RBAC;请通过 secret manager分发并限制 CLI主机。 + +轮换管理员 Token时,先生成至少 32 bytes随机值并更新部署 `.env` 中的 +`RELAY_ADMIN_TOKEN`,再执行 `docker compose up -d --force-recreate relay`。旧 Token在 +新进程启动后立即失效;轮换前应先完成数据库备份,并通过安全渠道更新 CLI配置。 + +桌面更新最初使用 `optional`,允许旧版 Desktop 匿名更新。新版会为更新检查和安装包下载携带 Coworker Bearer。确认旧客户端完成迁移后,再切换为 `required`。 + +## 备份、升级与恢复 + +升级前创建一致的在线 bbolt快照: + +```bash +coworker-relay backup --output relay-before-upgrade.db +docker compose pull +docker compose up -d +coworker-relay health +``` + +恢复必须在 Relay停止后进行。`--force` 不直接删除旧数据库,而是把它重命名为带 +`before-restore` 时间戳的可恢复文件: + +```bash +docker compose stop relay +coworker-relay restore \ + --from relay-before-upgrade.db \ + --database /path/to/mounted/relay.db \ + --force +docker compose start relay +``` + +Relay数据库具有显式 schema version。程序拒绝打开比自身更新或不受支持的 schema, +不会猜测降级。备份数据库、`.env`、ACME状态和 Coworker本地实例凭据都属于敏感数据; +应加密保存并验证恢复演练。`coworker-relay gc` 可立即清理过期配对、失败和封禁记录,服务也会 +每小时自动清理。撤销实例会级联删除其安全状态、统计和缓存。 + +容器收到 SIGTERM 后会停止接受新请求、关闭隧道并给 HTTP请求最多 30 秒 drain时间。 +Compose配置使用 35 秒 stop grace period。 + +## 健康、指标与日志 + +- `/_relay/v1/livez` 只表示进程存活。 +- `/_relay/v1/readyz` 和兼容端点 `/_relay/v1/health` 会检查 draining状态、数据库和 + 缓存目录可用性,并返回构建与协议版本。 +- `coworker-relay metrics` 返回受管理员 Bearer保护的 Prometheus文本,包括请求、认证失败、 + 封禁、延迟、Argon2并发、隧道连接、在线隧道和缓存容量/命中率。 + +Relay输出结构化 JSON日志到 stdout。安全日志包含完整来源 IP、实例、路由类别、认证结果 +和 Request ID,但不包含 Token、Cookie、正文、附件或完整原始 URL。部署方必须在容器 +运行时设置日志轮转和保留期,并按个人数据处理来源 IP。建议对 ready失败、认证失败突增、 +重连风暴、缓存接近配额和 TLS证书临期设置告警。 + +## 容量与拓扑边界 + +v1是单节点服务:bbolt位于本地卷中,隧道归属保存在进程内,不能把同一数据卷同时挂给 +多个 Relay副本。可以使用冷备份恢复,但不支持 active-active或无状态滚动升级。 + +公网请求每个实例和来源 IP默认每分钟 600 次,匿名请求默认每分钟 60 次;Argon2验证具有 +全局并发上限。可通过 `RELAY_REQUESTS_PER_MINUTE`、`RELAY_ANONYMOUS_PER_MINUTE`、 +`RELAY_VERIFIER_CONCURRENCY`、`RELAY_BAN_FAILURE_LIMIT`、`RELAY_BAN_FAILURE_WINDOW` +和 `RELAY_BAN_DURATION` 调整策略。请求 Header上限 32 KiB、请求正文上限 32 MiB、 +隧道帧上限 48 MiB;部署方可用 `RELAY_MAX_REQUEST_BODY_BYTES` 和 +`RELAY_MAX_TUNNEL_FRAME_BYTES` 调低但不能突破协议上限。 +上线前应按实际 SSE连接数、更新包大小和实例数进行负载测试,并配置足够的文件描述符、 +内存和缓存卷。反向代理必须允许 WebSocket升级,关闭 SSE响应缓冲,并把空闲超时设为 +大于 Relay的 90 秒;只有 `RELAY_TRUSTED_PROXY_CIDRS` 中的代理才可提供来源 IP。 + +协议的帧、Header顺序和重试边界见 [Relay v1 协议](relay-protocol.md)。 + +## 安全边界 + +- Relay 只开放 Desktop 通信和只读更新路径,不是通用 HTTP/TCP 代理。 +- Relay 使用派生 Argon2id verifier 做前置认证;原始 Bearer 仍由 Coworker 接口再次认证。 +- 同一实例和来源 IP 在十分钟内五次错误 Bearer 会被封禁一小时。 +- 缺少 Bearer 不计密码失败,但受匿名速率限制。 +- 配对、隧道认证、凭据轮换和管理员 API 在执行凭据校验前也有来源 IP 限速。 +- Relay 追加 `X-Coworker-Relay-*`,同时保留客户端原有同名 Header。Coworker 依靠已认证隧道而不是 Header 判断来源。 +- 私网 Relay 应使用系统信任的私有 CA;不能关闭证书校验。 +- v1 不提供 P2P、离线消息存储、任意上游下载、通用反向代理或多节点高可用。 diff --git a/pyproject.toml b/pyproject.toml index 7fd4900..54f04fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "requests>=2.31", "python-jenkins>=1.8", "python-multipart>=0.0.32", + "argon2-cffi>=25.1.0", + "websockets>=14.0", "spacy==3.8.13", "en-core-web-sm>=3.8.0", "qrcode[pil]>=8.0", diff --git a/src/coworker/admin/configuration/service.py b/src/coworker/admin/configuration/service.py index c3ac2e9..e075d95 100644 --- a/src/coworker/admin/configuration/service.py +++ b/src/coworker/admin/configuration/service.py @@ -33,6 +33,7 @@ SECRET_PATHS = { "admin.token", "api.communication_token", + "relay.instance_credential", "desktop_updates.admin_token", "desktop_updates.feed_token", "llm.anthropic_api_key", diff --git a/src/coworker/api/admin/router.py b/src/coworker/api/admin/router.py index 81ec1e6..df7eb4f 100644 --- a/src/coworker/api/admin/router.py +++ b/src/coworker/api/admin/router.py @@ -54,6 +54,7 @@ from coworker.channels.module import ChannelModuleRegistry from coworker.desktop_updates import SyncService from coworker.palaces.loader import Palace, PalaceLoader + from coworker.relay import RelayClient from coworker.skills.loader import Skill, SkillLoader from coworker.tools.alarm_tools import AlarmManager from coworker.tools.reasoning_tools import Task, TaskStore @@ -78,6 +79,7 @@ _channel_modules: ChannelModuleRegistry | None = None _process_started_at: datetime = datetime.now() _admin_config_service: AdminConfigService | None = None +_relay_client: RelayClient | None = None _background_tasks: set[asyncio.Task[None]] = set() _CONTENT_TYPES = {"skills", "palaces", "subconscious"} _SAFE_SLUG = re.compile(r"^[\w.-]{1,80}$", re.UNICODE) @@ -171,6 +173,13 @@ class BackupRestorePayload(BaseModel): confirm_name: str = "" +class RelayConnectPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + relay_url: str = Field(min_length=1, max_length=2048) + pairing_code: str = Field(min_length=4, max_length=128) + + def setup_admin( *, agent: AgentLoop, @@ -182,6 +191,7 @@ def setup_admin( mode_loader: SubconsciousModeLoader, desktop_update_sync: SyncService | None = None, inherited_config: Config | None = None, + relay_client: RelayClient | None = None, ) -> None: global \ _agent, \ @@ -193,7 +203,8 @@ def setup_admin( _palace_loader, \ _mode_loader, \ _desktop_update_sync, \ - _admin_config_service + _admin_config_service, \ + _relay_client _agent = agent _brain = brain _config = config @@ -203,6 +214,7 @@ def setup_admin( _palace_loader = palace_loader _mode_loader = mode_loader _desktop_update_sync = desktop_update_sync + _relay_client = relay_client _admin_config_service = AdminConfigService( AdminConfigDependencies( agent=agent, @@ -234,6 +246,15 @@ def _require_brain() -> Brain: return _brain +def _require_relay_client() -> RelayClient: + if _relay_client is None: + raise HTTPException( + status_code=503, + detail=tr("api.relay.client_not_ready"), + ) + return _relay_client + + def _require_config() -> Config: if _config is None: raise HTTPException(status_code=503, detail=tr("api.state.config_not_ready")) @@ -1055,6 +1076,101 @@ async def get_channel_management( return await management.snapshot() +@router.get("/relay") +async def get_relay(_: None = Depends(require_admin)) -> ApiResponse: + return _require_relay_client().snapshot() + + +@router.get("/relay/token") +async def get_relay_token( + request: Request, + _: None = Depends(require_admin), +) -> ApiResponse: + _audit(request, "relay.token.read", "relay") + snapshot = _require_relay_client().snapshot(include_token=True) + return {"communication_token": snapshot.get("communication_token", "")} + + +@router.post("/relay/connect") +async def connect_relay( + payload: RelayConnectPayload, + request: Request, + _: None = Depends(require_admin), +) -> ApiResponse: + try: + result = await _require_relay_client().enroll( + payload.relay_url, + payload.pairing_code, + ) + except Exception as error: + _audit(request, "relay.connect", "relay", result="error", detail=type(error).__name__) + raise HTTPException( + status_code=502, + detail=tr("api.relay.connect_failed", error=error), + ) from error + _audit(request, "relay.connect", str(result.get("instance_id", ""))) + return result + + +@router.post("/relay/test") +async def test_relay( + request: Request, + _: None = Depends(require_admin), +) -> ApiResponse: + try: + result = await _require_relay_client().test() + except Exception as error: + raise HTTPException( + status_code=502, + detail=tr("api.relay.test_failed", error=error), + ) from error + _audit(request, "relay.test", "relay") + return result + + +@router.post("/relay/reconnect", status_code=202) +async def reconnect_relay( + request: Request, + _: None = Depends(require_admin), +) -> ApiResponse: + await _require_relay_client().reconnect() + _audit(request, "relay.reconnect", "relay") + return {"accepted": True} + +@router.post("/relay/rotate-credential") +async def rotate_relay_credential( + request: Request, + _: None = Depends(require_admin), +) -> ApiResponse: + try: + result = await _require_relay_client().rotate_credential() + except Exception as error: + _audit( + request, + "relay.credential.rotate", + "relay", + result="error", + detail=type(error).__name__, + ) + raise HTTPException( + status_code=502, + detail=tr("api.relay.credential_rotation_failed", error=error), + ) from error + _audit(request, "relay.credential.rotate", str(result.get("instance_id", ""))) + return result + + +@router.delete("/relay", status_code=204) +async def disconnect_relay( + request: Request, + _: None = Depends(require_admin), +) -> Response: + instance_id = str(_require_relay_client().snapshot().get("instance_id", "")) + await _require_relay_client().disconnect() + _audit(request, "relay.disconnect", instance_id or "relay") + return Response(status_code=204) + + @router.post("/channels/{channel_name}/management/{command}") async def execute_channel_management( channel_name: str, diff --git a/src/coworker/api/app.py b/src/coworker/api/app.py index 0be76b9..e95214f 100644 --- a/src/coworker/api/app.py +++ b/src/coworker/api/app.py @@ -31,7 +31,11 @@ from starlette.background import BackgroundTask from coworker.api.admin import admin_router -from coworker.api.routes import router, verify_communication_authorization +from coworker.api.routes import ( + is_authenticated_relay_request, + router, + verify_communication_authorization, +) from coworker.channels.inbound import InboundEnvelope from coworker.channels.stream.wire import SHUTDOWN_SENTINEL, serialize_outbound_message from coworker.core.config import APIConfig, DesktopUpdatesConfig @@ -824,7 +828,9 @@ async def sse_endpoint( 入站方向使用 POST /messages;EventSource 原生自动重连。 """ - if participant_id.startswith("coworker-desktop:"): + if participant_id.startswith("coworker-desktop:") or is_authenticated_relay_request( + request + ): verify_communication_authorization(authorization) queue: asyncio.Queue = asyncio.Queue() stream = _channel_system.stream_runtime if _channel_system is not None else None diff --git a/src/coworker/api/routes.py b/src/coworker/api/routes.py index d5413f4..7dc31d0 100644 --- a/src/coworker/api/routes.py +++ b/src/coworker/api/routes.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from fastapi import APIRouter, Header, HTTPException +from fastapi import APIRouter, Header, HTTPException, Request from loguru import logger from pydantic import BaseModel @@ -121,6 +121,11 @@ def verify_communication_authorization(authorization: str | None) -> None: ) +def is_authenticated_relay_request(request: Request) -> bool: + relay = request.scope.get("state", {}).get("coworker_relay") + return isinstance(relay, dict) and relay.get("authenticated_tunnel") is True + + class SwitchModelPayload(BaseModel): provider: str model_id: str = "" # 省略则使用该 provider 实例配置的 default_model @@ -163,7 +168,11 @@ def _model_config_response() -> dict: @router.post("/messages") -async def post_message(message: MessagePayload, authorization: str | None = Header(default=None)): +async def post_message( + message: MessagePayload, + request: Request, + authorization: str | None = Header(default=None), +): if _inbox is None: raise HTTPException(status_code=503, detail=tr("api.state.agent_not_ready")) is_desktop = ( @@ -171,8 +180,9 @@ async def post_message(message: MessagePayload, authorization: str | None = Head or message.message_id is not None or message.type is not None ) - if is_desktop: + if is_desktop or is_authenticated_relay_request(request): verify_communication_authorization(authorization) + if is_desktop: if message.protocol_version != 1: raise HTTPException(status_code=422, detail=tr("api.message.protocol_version")) if not message.message_id: diff --git a/src/coworker/application.py b/src/coworker/application.py index a9d1f71..15a1c92 100644 --- a/src/coworker/application.py +++ b/src/coworker/application.py @@ -64,6 +64,7 @@ from coworker.memory.short_term import ShortTermMemory from coworker.palaces.loader import PalaceLoader from coworker.prompts.system_prompt import SystemPromptBuilder +from coworker.relay import RelayClient from coworker.skills.loader import SkillLoader from coworker.tools.alarm_tools import AlarmManager, CancelAlarmTool, ListAlarmsTool, SetAlarmTool from coworker.tools.breathe_tool import BreatheTool @@ -914,6 +915,7 @@ def log_mem0_usage(entry: dict[str, Any]) -> None: runtime_key=desktop_update_runtime.runtime_key, token=desktop_update_runtime.token, ) + relay_client = RelayClient(api_app.app, config) if not setup_required: channel_system.install( @@ -947,6 +949,7 @@ def log_mem0_usage(entry: dict[str, Any]) -> None: mode_loader=mode_loader, desktop_update_sync=desktop_update_sync, inherited_config=inherited_config, + relay_client=relay_client, ) setup_channel_admin(channel_system.modules) api_app.setup_desktop_updates( @@ -1010,6 +1013,7 @@ def log_mem0_usage(entry: dict[str, Any]) -> None: await desktop_update_sync.start(run_immediately=config.desktop_updates.sync_on_start) await channel_system.registry.start() + await relay_client.start() server_task = asyncio.create_task(server.serve(), name="server") inbox_task: asyncio.Task | None = None loop_task: asyncio.Task | None = None @@ -1057,6 +1061,7 @@ def log_mem0_usage(entry: dict[str, Any]) -> None: # 噪声)。timeout_graceful_shutdown=3 仅作兜底,正常路径用不到。 api_app.signal_shutdown() await channel_system.registry.stop() + await relay_client.stop() server.should_exit = True if inbox_task is not None: inbox_watcher.stop() diff --git a/src/coworker/channels/stream/desktop/detail_store.py b/src/coworker/channels/stream/desktop/detail_store.py index 04fed9c..e3e9c69 100644 --- a/src/coworker/channels/stream/desktop/detail_store.py +++ b/src/coworker/channels/stream/desktop/detail_store.py @@ -57,8 +57,8 @@ def prune(self) -> None: except OSError as error: logger.warning(f"Failed to list desktop detail dir {directory}: {error}") return - cutoff = time.time() - _DETAIL_MAX_AGE_SECONDS - expired = [path for path in files if self._mtime(path) < cutoff] + cutoff = time.time_ns() - (_DETAIL_MAX_AGE_SECONDS * 1_000_000_000) + expired = [path for path in files if self._mtime_ns(path) < cutoff] expired_set = set(expired) for path in expired: self._unlink(path) @@ -66,16 +66,16 @@ def prune(self) -> None: excess = len(survivors) - _DETAIL_MAX_FILES if excess <= 0: return - survivors.sort(key=self._mtime) + survivors.sort(key=lambda path: (self._mtime_ns(path), path.name)) for path in survivors[:excess]: self._unlink(path) @staticmethod - def _mtime(path: Path) -> float: + def _mtime_ns(path: Path) -> int: try: - return path.stat().st_mtime + return path.stat().st_mtime_ns except OSError: - return float("inf") + return 2**63 - 1 @staticmethod def _unlink(path: Path) -> None: diff --git a/src/coworker/core/config.py b/src/coworker/core/config.py index db5d46d..3163cf4 100644 --- a/src/coworker/core/config.py +++ b/src/coworker/core/config.py @@ -219,6 +219,46 @@ class APIConfig(_EnvSettings): ) +class RelayConfig(_EnvSettings): + """Outbound connection to a self-hosted Coworker Relay.""" + + model_config = SettingsConfigDict(env_prefix="RELAY__", env_file=".env", extra="ignore") + + enabled: bool = False + url: str = "" + instance_id: str = "" + instance_credential: str = Field(default="", repr=False) + + @field_validator("url") + @classmethod + def _validate_url(cls, value: str) -> str: + value = value.strip().rstrip("/") + if not value: + return "" + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.hostname: + raise ValueError("relay url must be an absolute HTTPS URL") + if ( + parsed.username + or parsed.password + or parsed.path + or parsed.query + or parsed.fragment + ): + raise ValueError( + "relay url must not contain credentials, path, query, or fragment" + ) + return value + + @field_validator("instance_id") + @classmethod + def _validate_instance_id(cls, value: str) -> str: + value = value.strip() + if value and not re.fullmatch(r"cw_[A-Za-z0-9_-]{8,80}", value): + raise ValueError("relay instance_id has an invalid format") + return value + + class DesktopUpdateSourceBase(BaseModel): """A named upstream connection. ``id`` is the durable identity; ``name`` is display-only.""" @@ -420,6 +460,7 @@ class Config(_EnvSettings): llm: LLMConfig = Field(default_factory=lambda: LLMConfig(max_tokens=DEFAULT_LLM_MAX_TOKENS)) memory: MemoryConfig = Field(default_factory=MemoryConfig) api: APIConfig = Field(default_factory=APIConfig) + relay: RelayConfig = Field(default_factory=RelayConfig) desktop_updates: DesktopUpdatesConfig = Field(default_factory=DesktopUpdatesConfig) admin: AdminConfig = Field(default_factory=AdminConfig) i18n: I18NConfig = Field(default_factory=I18NConfig) diff --git a/src/coworker/i18n/catalogs/en/api.toml b/src/coworker/i18n/catalogs/en/api.toml index d9877dc..e369006 100644 --- a/src/coworker/i18n/catalogs/en/api.toml +++ b/src/coworker/i18n/catalogs/en/api.toml @@ -109,6 +109,30 @@ message_id_required = "message_id is required" event_type_required = "Desktop event type is required" payload_required = "Desktop payload is required" +[api.relay] +client_not_ready = "Relay client is not ready" +connect_failed = "Failed to connect to Relay: {{error}}" +test_failed = "Relay connectivity test failed: {{error}}" +url_invalid = "Relay URL must be an absolute HTTPS URL" +url_components_invalid = "Relay URL must not contain credentials, a path, a query, or a fragment" +communication_token_missing = "Communication token is not configured" +unreachable = "Unable to reach Relay: {{error}}" +enrollment_failed = "Relay enrollment failed (HTTP {{status}})" +enrollment_invalid = "Relay rejected the enrollment request" +pairing_rejected = "The pairing code is invalid, expired, or has already been used" +enrollment_incomplete = "Relay returned an incomplete enrollment response" +not_configured = "Relay is not configured" +connectivity_failed = "Relay connectivity test failed: {{error}}" +connectivity_rejected = "Relay end-to-end test was rejected (HTTP {{status}})" +tunnel_test_incomplete = "Relay response is missing its tunnel request identifier" +verifier_not_synced = "The communication token verifier has not been synchronized to Relay" +request_body_too_large = "Request body exceeds the Coworker Relay limit" +request_failed = "Coworker could not process the relayed request" +tunnel_not_connected = "Relay tunnel is not connected" +credential_rotation_failed = "Relay credential rotation failed: {{error}}" +credential_rotation_rejected = "Relay rejected credential rotation (HTTP {{status}})" +credential_rotation_incomplete = "Relay returned an incomplete credential rotation response" + [api.attachment] count_exceeded = "Attachment count exceeds the limit of {{limit}}" item_not_object = "Every attachments item must be an object." diff --git a/src/coworker/i18n/catalogs/zh-CN/api.toml b/src/coworker/i18n/catalogs/zh-CN/api.toml index 0757c85..8593be2 100644 --- a/src/coworker/i18n/catalogs/zh-CN/api.toml +++ b/src/coworker/i18n/catalogs/zh-CN/api.toml @@ -109,6 +109,30 @@ message_id_required = "必须提供 message_id" event_type_required = "必须提供桌面事件类型" payload_required = "必须提供桌面 payload" +[api.relay] +client_not_ready = "Relay 客户端尚未就绪" +connect_failed = "连接 Relay 失败:{{error}}" +test_failed = "Relay 连通性测试失败:{{error}}" +url_invalid = "Relay URL 必须是绝对 HTTPS URL" +url_components_invalid = "Relay URL 不能包含凭据、路径、查询参数或片段" +communication_token_missing = "尚未配置通信 Token" +unreachable = "无法连接 Relay:{{error}}" +enrollment_failed = "Relay 配对失败(HTTP {{status}})" +enrollment_invalid = "Relay 拒绝了配对请求" +pairing_rejected = "配对码错误、已过期或已经使用" +enrollment_incomplete = "Relay 返回的配对响应不完整" +not_configured = "尚未配置 Relay" +connectivity_failed = "Relay 连通性测试失败:{{error}}" +connectivity_rejected = "Relay 端到端测试被拒绝(HTTP {{status}})" +tunnel_test_incomplete = "Relay 响应缺少隧道请求标识" +verifier_not_synced = "通信 Token verifier 尚未同步到 Relay" +request_body_too_large = "请求正文超过 Coworker Relay 限制" +request_failed = "Coworker 无法处理 Relay 转发的请求" +tunnel_not_connected = "Relay 隧道尚未连接" +credential_rotation_failed = "Relay 实例凭据轮换失败:{{error}}" +credential_rotation_rejected = "Relay 拒绝实例凭据轮换(HTTP {{status}})" +credential_rotation_incomplete = "Relay 返回的实例凭据轮换响应不完整" + [api.attachment] count_exceeded = "附件数量超过限制:{{limit}}" item_not_object = "attachments 中每一项都必须是对象。" diff --git a/src/coworker/relay/__init__.py b/src/coworker/relay/__init__.py new file mode 100644 index 0000000..f6723cf --- /dev/null +++ b/src/coworker/relay/__init__.py @@ -0,0 +1,5 @@ +"""Built-in client for the self-hosted Coworker Relay.""" + +from .client import RelayClient, RelayConnectionError + +__all__ = ["RelayClient", "RelayConnectionError"] diff --git a/src/coworker/relay/client.py b/src/coworker/relay/client.py new file mode 100644 index 0000000..48d23a5 --- /dev/null +++ b/src/coworker/relay/client.py @@ -0,0 +1,604 @@ +"""A thin, outbound-only transport adapter for Coworker Relay. + +The client owns enrollment, the authenticated WSS connection, reconnects, and +verifier synchronization. Requests received from the tunnel are dispatched +through the existing ASGI application, so routing and authorization remain +owned by Coworker's normal HTTP stack. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import ipaddress +import json +import random +import re +import time +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx +from argon2 import PasswordHasher +from loguru import logger +from websockets.asyncio.client import ClientConnection, connect + +from coworker.core.config import ( + Config, + _deep_merge, + effective_communication_token, + load_admin_overrides, + write_admin_overrides, +) +from coworker.i18n import tr + +_PROTOCOL = 1 +_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024 +_MAX_FRAME_BYTES = 48 * 1024 * 1024 +_MAX_HEADER_COUNT = 128 +_MAX_HEADER_BYTES = 64 * 1024 +_MAX_TARGET_BYTES = 16 * 1024 +_HTTP_TOKEN = re.compile(rb"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") +_RELAY_HEADER_NAMES = ( + b"x-coworker-relay", + b"x-coworker-relay-instance", + b"x-coworker-relay-request-id", + b"x-coworker-relay-original-url", + b"x-coworker-relay-original-target", + b"forwarded", +) +_PASSWORD_HASHER = PasswordHasher( + time_cost=2, + memory_cost=19 * 1024, + parallelism=1, + hash_len=32, + salt_len=16, +) + + +class RelayConnectionError(RuntimeError): + """A safe-to-display relay connection failure.""" + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _websocket_url(base_url: str) -> str: + parsed = urlsplit(base_url) + return urlunsplit(("wss", parsed.netloc, "/_relay/v1/connect", "", "")) + + +class RelayClient: + def __init__(self, app: Any, config: Config) -> None: + self._app = app + self._config = config + self._supervisor: asyncio.Task[None] | None = None + self._socket: ClientConnection | None = None + self._send_lock = asyncio.Lock() + self._request_tasks: dict[str, asyncio.Task[None]] = {} + self._wake = asyncio.Event() + self._stopping = False + self._status = "disabled" + self._last_error = "" + self._connected_at = "" + self._last_heartbeat = "" + self._latency_ms: float | None = None + self._last_token_fingerprint = "" + self._pending_verifier_fingerprint = "" + + def snapshot(self, *, include_token: bool = False) -> dict[str, object]: + relay = self._config.relay + result: dict[str, object] = { + "enabled": relay.enabled, + "status": self._status, + "relay_url": relay.url, + "instance_id": relay.instance_id, + "public_base_url": ( + f"{relay.url}/i/{relay.instance_id}" if relay.url and relay.instance_id else "" + ), + "connected_at": self._connected_at, + "last_heartbeat": self._last_heartbeat, + "latency_ms": self._latency_ms, + "last_error": self._last_error, + "protocol_version": _PROTOCOL, + "verifier_synced": self._verifier_is_synced(), + } + if include_token: + result["communication_token"] = effective_communication_token(self._config) + return result + + async def start(self) -> None: + if self._supervisor is not None and not self._supervisor.done(): + return + self._stopping = False + self._supervisor = asyncio.create_task(self._run(), name="relay-client") + + async def stop(self) -> None: + self._stopping = True + self._wake.set() + socket = self._socket + if socket is not None: + await socket.close(code=1001, reason="coworker stopping") + if self._supervisor is not None: + self._supervisor.cancel() + await asyncio.gather(self._supervisor, return_exceptions=True) + await self._cancel_requests() + self._supervisor = None + self._socket = None + self._status = "disabled" if not self._config.relay.enabled else "disconnected" + + async def reconnect(self) -> None: + if self._config.relay.enabled: + self._status = "connecting" + self._wake.set() + socket = self._socket + if socket is not None: + await socket.close(code=1012, reason="reconnect requested") + await self.start() + + async def rotate_credential(self) -> dict[str, object]: + relay = self._config.relay + if not (relay.url and relay.instance_id and relay.instance_credential): + raise RelayConnectionError(tr("api.relay.not_configured")) + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client: + response = await client.post( + f"{relay.url}/_relay/v1/credential/rotate", + headers={ + "Authorization": f"Bearer {relay.instance_credential}", + "X-Coworker-Relay-Instance": relay.instance_id, + }, + ) + except httpx.HTTPError as error: + raise RelayConnectionError( + tr("api.relay.credential_rotation_failed", error=str(error)) + ) from error + if response.status_code != 200: + raise RelayConnectionError( + tr( + "api.relay.credential_rotation_rejected", + status=response.status_code, + ) + ) + credential = str(response.json().get("instance_credential", "")) + if not credential: + raise RelayConnectionError(tr("api.relay.credential_rotation_incomplete")) + relay_data = relay.model_dump(mode="json") + relay_data["instance_credential"] = credential + overrides = load_admin_overrides(self._config.admin.config_file) + write_admin_overrides( + self._config.admin.config_file, + _deep_merge(overrides, {"relay": relay_data}), + ) + relay.instance_credential = credential + await self.reconnect() + return self.snapshot() + + async def enroll(self, relay_url: str, pairing_code: str) -> dict[str, object]: + relay_url = relay_url.strip().rstrip("/") + parsed = urlsplit(relay_url) + if parsed.scheme != "https" or not parsed.hostname: + raise RelayConnectionError(tr("api.relay.url_invalid")) + if ( + parsed.username + or parsed.password + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + ): + raise RelayConnectionError(tr("api.relay.url_components_invalid")) + token = effective_communication_token(self._config) + if not token: + raise RelayConnectionError(tr("api.relay.communication_token_missing")) + verifier = _PASSWORD_HASHER.hash(token) + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=False) as client: + response = await client.post( + f"{relay_url}/_relay/v1/enroll", + json={ + "pairing_code": pairing_code.strip(), + "verifier": verifier, + "protocol_version": _PROTOCOL, + }, + ) + except httpx.HTTPError as error: + raise RelayConnectionError(tr("api.relay.unreachable", error=str(error))) from error + if response.status_code != 200: + if response.status_code == 401: + raise RelayConnectionError(tr("api.relay.pairing_rejected")) + if response.status_code == 400: + raise RelayConnectionError(tr("api.relay.enrollment_invalid")) + raise RelayConnectionError( + tr("api.relay.enrollment_failed", status=response.status_code) + ) + payload = response.json() + instance_id = str(payload.get("instance_id", "")) + credential = str(payload.get("instance_credential", "")) + if not instance_id or not credential: + raise RelayConnectionError(tr("api.relay.enrollment_incomplete")) + self._config.relay.url = relay_url + self._config.relay.instance_id = instance_id + self._config.relay.instance_credential = credential + self._config.relay.enabled = True + self._persist() + self._last_token_fingerprint = hashlib.sha256(token.encode()).hexdigest() + self._wake.set() + await self.start() + return self.snapshot() + + async def disconnect(self) -> None: + await self.stop() + self._config.relay.enabled = False + self._config.relay.url = "" + self._config.relay.instance_id = "" + self._config.relay.instance_credential = "" + self._last_token_fingerprint = "" + self._pending_verifier_fingerprint = "" + self._last_error = "" + self._persist() + self._status = "disabled" + + async def test(self) -> dict[str, object]: + relay = self._config.relay + if not (relay.url and relay.instance_id): + raise RelayConnectionError(tr("api.relay.not_configured")) + if self._socket is None or self._status != "connected": + raise RelayConnectionError(tr("api.relay.tunnel_not_connected")) + if not self._verifier_is_synced(): + raise RelayConnectionError(tr("api.relay.verifier_not_synced")) + token = effective_communication_token(self._config) + if not token: + raise RelayConnectionError(tr("api.relay.communication_token_missing")) + public_base_url = f"{relay.url}/i/{relay.instance_id}" + started = time.monotonic() + try: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + response = await client.get( + f"{public_base_url}/status", + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as error: + raise RelayConnectionError( + tr("api.relay.connectivity_failed", error=str(error)) + ) from error + if response.status_code != 200: + raise RelayConnectionError( + tr( + "api.relay.connectivity_rejected", + status=response.status_code, + ) + ) + request_id = response.headers.get("X-Coworker-Relay-Request-Id", "").strip() + try: + status_payload = response.json() + except ValueError as error: + raise RelayConnectionError(tr("api.relay.tunnel_test_incomplete")) from error + if not request_id or not isinstance(status_payload, dict) or not status_payload: + raise RelayConnectionError(tr("api.relay.tunnel_test_incomplete")) + return { + "ok": True, + "latency_ms": round((time.monotonic() - started) * 1000, 1), + "public_base_url": public_base_url, + "request_id": request_id, + } + + def _persist(self) -> None: + path = self._config.admin.config_file + overrides = load_admin_overrides(path) + relay_data = self._config.relay.model_dump(mode="json") + write_admin_overrides(path, _deep_merge(overrides, {"relay": relay_data})) + + async def _run(self) -> None: + delay = 1.0 + while not self._stopping: + relay = self._config.relay + if not ( + relay.enabled and relay.url and relay.instance_id and relay.instance_credential + ): + self._status = "disabled" + self._wake.clear() + await self._wake.wait() + continue + self._status = "connecting" + self._last_error = "" + try: + async with connect( + _websocket_url(relay.url), + additional_headers={ + "Authorization": f"Bearer {relay.instance_credential}", + "X-Coworker-Relay-Instance": relay.instance_id, + "X-Coworker-Relay-Protocol": str(_PROTOCOL), + }, + max_size=_MAX_FRAME_BYTES, + ping_interval=20, + ping_timeout=30, + open_timeout=15, + close_timeout=5, + ) as socket: + self._socket = socket + self._status = "connected" + self._connected_at = _now() + self._last_heartbeat = self._connected_at + delay = 1.0 + await self._sync_verifier(force=True) + verifier_task = asyncio.create_task( + self._watch_verifier(), + name="relay-verifier-watch", + ) + try: + await self._listen(socket) + finally: + verifier_task.cancel() + await asyncio.gather(verifier_task, return_exceptions=True) + except asyncio.CancelledError: + raise + except Exception as error: + self._last_error = str(error)[:500] + self._status = "disconnected" + logger.warning("Relay connection lost: {}", self._last_error) + finally: + self._socket = None + await self._cancel_requests() + if self._stopping: + break + self._wake.clear() + try: + await asyncio.wait_for(self._wake.wait(), delay + random.random()) + except TimeoutError: + pass + delay = min(delay * 2, 60.0) + + async def _listen(self, socket: ClientConnection) -> None: + async for raw in socket: + if not isinstance(raw, str): + continue + message = json.loads(raw) + kind = message.get("type") + if kind == "request": + request_id = str(message.get("request_id", "")) + if not request_id or request_id in self._request_tasks: + continue + request_task = asyncio.create_task( + self._handle_request(message), + name=f"relay-request:{request_id}", + ) + self._request_tasks[request_id] = request_task + request_task.add_done_callback(self._request_finished_callback(request_id)) + elif kind == "cancel": + cancelled_task = self._request_tasks.get(str(message.get("request_id", ""))) + if cancelled_task is not None: + cancelled_task.cancel() + elif kind == "ping": + sent_at = float(message.get("sent_at", 0)) + self._last_heartbeat = _now() + self._latency_ms = max(0.0, round((time.time() - sent_at) * 1000, 1)) + await self._send({"type": "pong", "sent_at": sent_at}) + elif kind == "verifier_required": + await self._sync_verifier(force=True) + elif kind == "verifier_ack": + self._accept_verifier_ack(str(message.get("generation", ""))) + + def _request_finished_callback( + self, + request_id: str, + ) -> Callable[[asyncio.Task[None]], None]: + def remove(_task: asyncio.Task[None]) -> None: + self._request_tasks.pop(request_id, None) + + return remove + + async def _watch_verifier(self) -> None: + while True: + await asyncio.sleep(5) + await self._sync_verifier() + + async def _sync_verifier(self, *, force: bool = False) -> None: + token = effective_communication_token(self._config) + if not token: + raise RelayConnectionError(tr("api.relay.communication_token_missing")) + fingerprint = hashlib.sha256(token.encode()).hexdigest() + if not force and fingerprint == self._last_token_fingerprint: + return + self._pending_verifier_fingerprint = fingerprint + await self._send( + { + "type": "verifier", + "verifier": _PASSWORD_HASHER.hash(token), + "generation": fingerprint, + } + ) + + def _verifier_is_synced(self) -> bool: + token = effective_communication_token(self._config) + if not token: + return False + return ( + not self._pending_verifier_fingerprint + and hashlib.sha256(token.encode()).hexdigest() == self._last_token_fingerprint + ) + + def _accept_verifier_ack(self, generation: str) -> None: + if generation == self._pending_verifier_fingerprint: + self._last_token_fingerprint = generation + self._pending_verifier_fingerprint = "" + + async def _handle_request(self, message: dict[str, Any]) -> None: + request_id = str(message.get("request_id", "")) + try: + if not request_id or len(request_id) > 128: + raise ValueError("invalid_relay_request_id") + body = base64.b64decode(str(message.get("body", "")), validate=True) + if len(body) > _MAX_REQUEST_BODY_BYTES: + raise ValueError(tr("api.relay.request_body_too_large")) + raw_headers = message.get("headers", []) + headers, relay_header_start = _validated_headers( + raw_headers, + message.get("relay_header_start"), + instance_id=self._config.relay.instance_id, + request_id=request_id, + ) + method = message.get("method") + if method not in {"GET", "POST", "DELETE"}: + raise ValueError("invalid_relay_request_method") + path = message.get("path") + raw_path_value = message.get("raw_path", path) + query_value = message.get("query", "") + if ( + not isinstance(path, str) + or not path.startswith("/") + or not isinstance(raw_path_value, str) + or not raw_path_value.startswith("/") + or not isinstance(query_value, str) + ): + raise ValueError("invalid_relay_request_target") + raw_path = raw_path_value.encode("ascii", "surrogateescape") + query = query_value.encode("ascii", "surrogateescape") + if len(path.encode("utf-8")) + len(raw_path) + len(query) > _MAX_TARGET_BYTES: + raise ValueError("relay_request_target_too_large") + client_ip = str(message.get("client_ip", "")) + try: + ipaddress.ip_address(client_ip) + except ValueError as error: + raise ValueError("invalid_relay_client_ip") from error + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": method, + "scheme": "https", + "path": path, + "raw_path": raw_path, + "query_string": query, + "root_path": "", + "headers": headers, + "client": (client_ip, 0), + "server": ("coworker-relay", 443), + "state": { + "coworker_relay": { + "authenticated_tunnel": True, + "instance_id": self._config.relay.instance_id, + "request_id": request_id, + "relay_header_start": relay_header_start, + } + }, + } + received = False + + async def receive() -> dict[str, object]: + nonlocal received + if not received: + received = True + return {"type": "http.request", "body": body, "more_body": False} + return {"type": "http.disconnect"} + + started = False + + async def send(event: dict[str, Any]) -> None: + nonlocal started + if event["type"] == "http.response.start": + started = True + await self._send( + { + "type": "response_start", + "request_id": request_id, + "status": int(event["status"]), + "headers": [ + [name.decode("latin-1"), value.decode("latin-1")] + for name, value in event.get("headers", []) + ], + } + ) + elif event["type"] == "http.response.body": + if not started: + raise RuntimeError("ASGI response body sent before response start") + chunk = bytes(event.get("body", b"")) + await self._send( + { + "type": "response_body", + "request_id": request_id, + "body": base64.b64encode(chunk).decode(), + "more": bool(event.get("more_body", False)), + } + ) + + await self._app(scope, receive, send) + except asyncio.CancelledError: + raise + except Exception as error: + logger.warning("Relay request {} failed: {}", request_id, error) + await self._send( + { + "type": "response_error", + "request_id": request_id, + "error": tr("api.relay.request_failed"), + } + ) + + async def _send(self, message: dict[str, object]) -> None: + socket = self._socket + if socket is None: + raise RelayConnectionError(tr("api.relay.tunnel_not_connected")) + async with self._send_lock: + await socket.send(json.dumps(message, separators=(",", ":"))) + + async def _cancel_requests(self) -> None: + tasks = list(self._request_tasks.values()) + self._request_tasks.clear() + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + +def _validated_headers( + raw_headers: object, + raw_boundary: object, + *, + instance_id: str, + request_id: str, +) -> tuple[list[tuple[bytes, bytes]], int]: + if not isinstance(raw_headers, list) or len(raw_headers) > _MAX_HEADER_COUNT: + raise ValueError("invalid_relay_request_headers") + headers: list[tuple[bytes, bytes]] = [] + total = 0 + for item in raw_headers: + if ( + not isinstance(item, (list, tuple)) + or len(item) != 2 + or not isinstance(item[0], str) + or not isinstance(item[1], str) + ): + raise ValueError("invalid_relay_request_header") + try: + name = item[0].encode("ascii") + value = item[1].encode("latin-1") + except UnicodeEncodeError as error: + raise ValueError("invalid_relay_request_header_encoding") from error + if not _HTTP_TOKEN.fullmatch(name) or b"\x00" in value or b"\r" in value or b"\n" in value: + raise ValueError("invalid_relay_request_header") + total += len(name) + len(value) + if total > _MAX_HEADER_BYTES: + raise ValueError("relay_request_headers_too_large") + headers.append((name, value)) + if ( + isinstance(raw_boundary, bool) + or not isinstance(raw_boundary, int) + or raw_boundary < 0 + or raw_boundary > len(headers) + ): + raise ValueError("invalid_relay_header_boundary") + relay_names = tuple(name.lower() for name, _ in headers[raw_boundary:]) + if relay_names != _RELAY_HEADER_NAMES: + raise ValueError("invalid_relay_added_headers") + relay_values = tuple(value for _, value in headers[raw_boundary:]) + if ( + relay_values[0] != b"v1" + or relay_values[1] != instance_id.encode("ascii") + or relay_values[2] != request_id.encode("ascii") + ): + raise ValueError("invalid_relay_added_header_values") + return headers, raw_boundary diff --git a/src/coworker/web/assets/AdminApp-B8P4UePQ.css b/src/coworker/web/assets/AdminApp-Cv-2W5Va.css similarity index 99% rename from src/coworker/web/assets/AdminApp-B8P4UePQ.css rename to src/coworker/web/assets/AdminApp-Cv-2W5Va.css index 679f7aa..a05f2f3 100644 --- a/src/coworker/web/assets/AdminApp-B8P4UePQ.css +++ b/src/coworker/web/assets/AdminApp-Cv-2W5Va.css @@ -1 +1 @@ -:root{--admin-ink:oklch(22% .012 260);--admin-paper:oklch(98.5% .006 95);--admin-surface:oklch(100% 0 0);--admin-vital:oklch(76% .14 28);--admin-peach:oklch(82% .1 62);--admin-sky:oklch(82% .09 245);--admin-amber:oklch(82% .12 95);--admin-red:oklch(60% .17 28);--admin-muted:oklch(54% .014 260);--admin-line:oklch(91% .008 95);--admin-soft:color-mix(in oklch, var(--admin-vital) 7%, var(--admin-surface));--admin-display:var(--font-display);--admin-body:var(--font-body);--admin-data:var(--font-mono);--admin-type-micro:clamp(10px, calc(9.5px + .04vw), 11px);--admin-type-caption:clamp(11px, calc(10.4px + .05vw), 12px);--admin-type-small:clamp(12px, calc(11.4px + .05vw), 13px);--admin-type-body:clamp(13px, calc(12.4px + .05vw), 14px);--admin-type-ui:clamp(14px, calc(13.4px + .04vw), 15px);--admin-field-label-height:18px;--admin-field-gap:7px}.admin-shell,.admin-login{--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}html:has(.admin-shell){--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}html:has(.admin-login){--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}.admin-shell,.admin-login{min-height:100dvh;color:var(--admin-ink);font-family:var(--admin-body);font-size:var(--admin-type-ui);font-synthesis:none;line-break:strict;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased}.admin-shell button,.admin-shell input,.admin-shell select,.admin-shell textarea,.admin-login button,.admin-login input,.admin-login select{font:inherit}.admin-shell button,.admin-login button{-webkit-tap-highlight-color:transparent}.admin-shell :where(button,a,input,select,textarea):focus-visible,.admin-login :where(button,a,input,select):focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 30%, transparent);outline-offset:2px}.admin-login{background:linear-gradient(90deg, color-mix(in oklch, var(--admin-ink) 5%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in oklch, var(--admin-ink) 5%, transparent) 1px, transparent 1px), radial-gradient(circle at 20% 15%, color-mix(in oklch, var(--admin-vital) 14%, transparent), transparent 34%), var(--admin-paper);background-size:42px 42px,42px 42px,auto,auto;place-items:center;padding:32px;display:grid;position:relative;overflow:hidden}.admin-login:after{content:"";background:linear-gradient(transparent, color-mix(in oklch, var(--admin-vital) 24%, transparent), transparent);pointer-events:none;width:1px;position:absolute;top:0;bottom:0;left:50%}.login-card{z-index:1;border:1px solid var(--admin-line);background:var(--admin-surface);width:min(880px,100%);min-height:540px;box-shadow:0 28px 90px color-mix(in oklch, var(--admin-ink) 14%, transparent);border-radius:24px;grid-template-columns:1.08fr .92fr;display:grid;position:relative;overflow:hidden}.login-presence{color:var(--admin-paper);background:radial-gradient(circle at 15% 15%, color-mix(in oklch, var(--admin-vital) 40%, transparent), transparent 34%), var(--admin-ink);flex-direction:column;justify-content:space-between;padding:48px;display:flex;position:relative;overflow:hidden}.login-presence:before,.login-presence:after{content:"";border:1px solid color-mix(in oklch, var(--admin-vital) 36%, transparent);border-radius:50%;position:absolute}.login-presence:before{width:380px;height:380px;top:-120px;right:-230px}.login-presence:after{width:250px;height:250px;top:-55px;right:-150px}.login-sigil{z-index:1;border:1px solid color-mix(in oklch, var(--admin-vital) 58%, transparent);width:76px;height:76px;color:color-mix(in oklch, var(--admin-vital) 72%, white);background:color-mix(in oklch, var(--admin-vital) 12%, transparent);border-radius:21px;place-items:center;margin-bottom:46px;display:grid;position:relative}.login-sigil i{opacity:.28;border:1px solid;border-radius:44% 56% 58% 42%;animation:7s linear infinite admin-orbit;position:absolute;inset:9px}.login-sigil i:nth-child(2){animation-duration:5s;animation-direction:reverse;inset:17px}.login-sigil i:nth-child(3){animation-duration:3.5s;inset:26px}@keyframes admin-orbit{to{border-radius:56% 44% 39% 61%;transform:rotate(1turn)}}.eyebrow{color:var(--admin-muted);font:650 11px/1.2 var(--admin-data);letter-spacing:.15em;text-transform:uppercase;margin:0 0 8px}.login-presence .eyebrow{color:color-mix(in oklch, var(--admin-vital) 44%, white)}.login-presence h1{font:660 clamp(34px, 4vw, 48px)/1.08 var(--admin-display);letter-spacing:-.045em;margin:0}.login-copy{max-width:330px;color:color-mix(in oklch, var(--admin-paper) 74%, var(--admin-ink));margin:16px 0 0;font-size:14px;line-height:1.8}.login-life-trace{align-items:center;gap:8px;height:72px;margin:auto 0 24px;display:flex;position:relative}.login-life-trace:before{content:"";background:color-mix(in oklch, var(--admin-vital) 38%, transparent);height:1px;position:absolute;left:0;right:0}.login-life-trace i{z-index:1;background:var(--admin-vital);border-radius:999px;width:3px;height:12px;animation:2.4s ease-in-out infinite login-beat}.login-life-trace i:nth-child(2),.login-life-trace i:nth-child(8){height:24px;animation-delay:.12s}.login-life-trace i:nth-child(3),.login-life-trace i:nth-child(7){height:42px;animation-delay:.24s}.login-life-trace i:nth-child(4),.login-life-trace i:nth-child(6){height:62px;animation-delay:.36s}.login-life-trace i:nth-child(5){height:34px;animation-delay:.48s}@keyframes login-beat{0%,72%,to{opacity:.48;transform:scaleY(.72)}18%{opacity:1;transform:scaleY(1)}}.login-assurance{z-index:1;color:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-ink));font-size:var(--admin-type-caption);border-top:1px solid #ffffff1c;justify-content:space-between;align-items:center;gap:16px;padding-top:18px;display:flex;position:relative}.login-assurance span:first-child{color:color-mix(in oklch, var(--admin-paper) 72%, var(--admin-ink));align-items:center;gap:8px;display:inline-flex}.login-assurance i{background:var(--admin-vital);width:7px;height:7px;box-shadow:0 0 0 5px color-mix(in oklch, var(--admin-vital) 16%, transparent);border-radius:50%}.login-access{background:var(--admin-surface);flex-direction:column;justify-content:center;padding:48px;display:flex}.access-step{color:var(--admin-vital);font:650 var(--admin-type-micro) var(--admin-data);letter-spacing:.14em;margin:0 0 52px}.login-access h2{font:660 24px/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.login-access>div>p{color:var(--admin-muted);margin:9px 0 0;font-size:13px;line-height:1.6}.login-access form{gap:15px;margin:30px 0 26px;display:grid}.login-access form label>span{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.token-input{border:1px solid var(--admin-line);height:48px;color:var(--admin-muted);background:#fff;border-radius:11px;align-items:center;gap:10px;padding:0 14px;transition:border-color .18s,box-shadow .18s;display:flex}.token-input:focus-within{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line));box-shadow:none}.token-input input{min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;flex:1}.token-input input:focus-visible{outline:0}.login-access .primary{width:100%;min-height:48px}.login-access>a{width:max-content;color:var(--admin-muted);align-items:center;gap:5px;font-size:12px;text-decoration:none;display:inline-flex}.login-access>a:hover{color:var(--admin-vital)}.form-error{color:var(--admin-red);margin:0;font-size:12px;line-height:1.5}.bootstrap-card{z-index:1;border:1px solid var(--admin-line);background:var(--admin-surface);width:min(1040px,100%);min-height:680px;box-shadow:0 28px 90px color-mix(in oklch, var(--admin-ink) 14%, transparent);border-radius:24px;grid-template-columns:minmax(300px,.78fr) minmax(520px,1.22fr);display:grid;position:relative;overflow:hidden}.bootstrap-rail{color:var(--admin-paper);background:radial-gradient(circle at 12% 10%, color-mix(in oklch, var(--admin-vital) 38%, transparent), transparent 30%), var(--admin-ink);padding:46px;position:relative;overflow:hidden}.bootstrap-rail:after{content:"";border:1px solid color-mix(in oklch, var(--admin-vital) 34%, transparent);border-radius:45% 55% 52% 48%;width:330px;height:330px;position:absolute;bottom:-90px;right:-240px;transform:rotate(24deg)}.bootstrap-rail .login-sigil{margin-bottom:40px}.bootstrap-rail .eyebrow{color:color-mix(in oklch, var(--admin-vital) 48%, white)}.bootstrap-rail h1{font:660 clamp(34px, 4vw, 48px)/1.12 var(--admin-display);letter-spacing:-.045em;margin:10px 0 18px}.bootstrap-rail>p:not(.eyebrow){max-width:300px;color:color-mix(in oklch, var(--admin-paper) 66%, var(--admin-ink));margin:0;font-size:13px;line-height:1.8}.awakening-circuit{gap:0;margin:58px 0 0;padding:0;list-style:none;display:grid;position:relative}.awakening-circuit:before{content:"";background:#ffffff24;width:1px;position:absolute;top:22px;bottom:22px;left:20px}.awakening-circuit li{color:#ffffff62;grid-template-columns:42px 1fr;align-items:center;gap:14px;min-height:76px;display:grid;position:relative}.awakening-circuit li>span{z-index:1;background:var(--admin-ink);border:1px solid #ffffff24;border-radius:50%;place-items:center;width:42px;height:42px;display:grid}.awakening-circuit li b{color:inherit;font-size:13px;display:block}.awakening-circuit li small{color:inherit;margin-top:4px;font-size:11px;display:block}.awakening-circuit li.done{color:color-mix(in oklch, var(--admin-vital) 62%, white)}.awakening-circuit li.active{color:#fff}.awakening-circuit li.active>span{border-color:var(--admin-vital);color:var(--admin-vital);box-shadow:0 0 0 7px color-mix(in oklch, var(--admin-vital) 12%, transparent)}.bootstrap-form-stage{flex-direction:column;justify-content:center;padding:46px 54px;display:flex}.bootstrap-heading .access-step{margin-bottom:22px}.bootstrap-heading h2,.bootstrap-restarting h2{font:660 26px/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.bootstrap-heading>p:last-child,.bootstrap-restarting>p{color:var(--admin-muted);margin:9px 0 0;font-size:13px;line-height:1.65}.bootstrap-heading code,.bootstrap-footnote code{font-family:var(--admin-data);font-size:.92em}.bootstrap-form{gap:20px;margin-top:30px;display:grid}.bootstrap-grid{grid-template-columns:1fr 1fr;gap:16px;display:grid}.bootstrap-grid label{min-width:0}.bootstrap-grid label.wide{grid-column:1/-1}.bootstrap-grid label>span{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.bootstrap-grid label>span em{color:var(--admin-muted);margin-left:5px;font-size:10px;font-style:normal;font-weight:500}.bootstrap-grid :is(input,select){border:1px solid var(--admin-line);width:100%;height:46px;color:var(--admin-ink);background:#fff;border-radius:10px;outline:0;padding:0 13px}.bootstrap-mode{grid-column:1/-1;grid-template-columns:1fr 1fr;gap:10px;display:grid}.bootstrap-mode>span{grid-column:1/-1;margin-bottom:-2px;font-size:12px;font-weight:650;display:block}.bootstrap-mode button{border:1px solid var(--admin-line);text-align:left;min-height:72px;color:var(--admin-muted);cursor:pointer;background:#fff;border-radius:12px;padding:13px 14px;transition:border-color .18s,background .18s,color .18s,box-shadow .18s}.bootstrap-mode button b{color:var(--admin-ink);font-size:12px;display:block}.bootstrap-mode button small{margin-top:5px;font-size:11px;line-height:1.45;display:block}.bootstrap-mode button.active{border-color:color-mix(in oklch, var(--admin-vital) 46%, var(--admin-line));color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-muted));background:var(--admin-soft);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.bootstrap-mode button.active b{color:var(--admin-vital)}.bootstrap-grid :is(input,select):focus{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line))}.bootstrap-model-field{min-width:0;position:relative}.bootstrap-model-field.open{z-index:20}.bootstrap-model-field>label{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.bootstrap-model-combobox{position:relative}.bootstrap-model-listbox{z-index:20;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:color-mix(in oklch, white 92%, var(--admin-surface));max-height:min(240px,35dvh);box-shadow:0 18px 42px color-mix(in oklch, var(--admin-ink) 16%, transparent);border-radius:12px;gap:4px;margin:0;padding:6px;list-style:none;display:grid;position:absolute;top:calc(100% + 7px);left:0;right:0;overflow-y:auto}.bootstrap-model-listbox li{min-height:44px;color:var(--admin-ink);font:600 11px/1.35 var(--admin-data);overflow-wrap:anywhere;cursor:pointer;border-radius:9px;padding:10px 11px}.bootstrap-model-listbox li[role=option]{justify-content:space-between;align-items:center;gap:10px;display:flex}.bootstrap-model-listbox li.active{background:color-mix(in oklch, var(--admin-vital) 12%, white);color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-ink))}.bootstrap-model-listbox li.selected{font-weight:760}.bootstrap-model-listbox li.selected svg{color:var(--admin-vital);flex:none}.bootstrap-model-empty{color:var(--admin-muted);cursor:default;align-items:center;display:flex}.bootstrap-form>.primary{width:100%;min-height:48px}.bootstrap-model-warning{border:1px solid color-mix(in oklch, var(--admin-amber) 32%, var(--admin-line));color:color-mix(in oklch, var(--admin-amber) 68%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 8%, var(--admin-surface));border-radius:10px;align-items:flex-start;gap:10px;padding:13px 14px;display:flex}.bootstrap-model-warning>svg{flex:none;margin-top:1px}.bootstrap-model-warning b{color:var(--admin-ink);font-size:12px;display:block}.bootstrap-model-warning p{color:var(--admin-muted);margin:4px 0 10px;font-size:11px;line-height:1.55}.bootstrap-model-warning label{color:var(--admin-ink);cursor:pointer;align-items:flex-start;gap:8px;font-size:11px;line-height:1.45;display:flex}.bootstrap-model-warning input{width:15px;height:15px;accent-color:var(--admin-vital);margin:1px 0 0}.bootstrap-footnote{color:var(--admin-muted);align-items:center;gap:7px;margin:22px 0 0;font-size:10px;display:flex}.bootstrap-restarting{text-align:center;align-self:center;max-width:430px}.bootstrap-restarting .access-step{margin:32px 0 18px}.restart-orbit{width:96px;height:96px;color:var(--admin-vital);place-items:center;margin:0 auto;display:grid;position:relative}.restart-orbit i{opacity:.35;border:1px solid;border-radius:45% 55%;animation:3s linear infinite admin-orbit;position:absolute;inset:5px}.restart-orbit i:last-child{animation-duration:2s;animation-direction:reverse;inset:18px}.primary,.ghost,.danger-outline,.danger-solid{border:1px solid #0000;border-radius:10px;justify-content:center;align-items:center;gap:8px;min-height:40px;padding:0 16px;font-size:12px;font-weight:680;transition:transform .16s,border-color .16s,background .16s,color .16s;display:inline-flex}.primary{color:#fff;background:var(--admin-ink);box-shadow:0 7px 18px color-mix(in oklch, var(--admin-ink) 18%, transparent)}.primary:hover{background:color-mix(in oklch, var(--admin-vital) 24%, var(--admin-ink));transform:translateY(-1px)}.primary:disabled,.danger-solid:disabled{cursor:not-allowed;opacity:.42;box-shadow:none;transform:none}.ghost{color:var(--admin-ink);border-color:var(--admin-line);background:var(--admin-surface)}.ghost:hover{border-color:color-mix(in oklch, var(--admin-vital) 50%, var(--admin-line));color:var(--admin-vital)}.danger-outline{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-surface));background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface))}.danger-outline:hover{border-color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 9%, var(--admin-surface))}.danger-solid{color:#fff;background:var(--admin-red)}.primary.mini,.ghost.mini{min-height:30px;font-size:var(--admin-type-caption);border-radius:8px;padding:0 10px}.admin-shell{background:radial-gradient(circle at 96% 4%, color-mix(in oklch, var(--admin-vital) 8%, transparent), transparent 28vw), radial-gradient(circle at 72% 96%, color-mix(in oklch, var(--admin-sky) 9%, transparent), transparent 30vw), var(--admin-paper);grid-template-columns:216px minmax(0,1fr);display:grid}.admin-sidebar{z-index:30;height:100dvh;color:color-mix(in oklch, var(--admin-paper) 84%, var(--admin-ink));background:var(--admin-ink);--scrollbar-thumb:#ffffff32;--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 58%, transparent);--scrollbar-track:transparent;flex-direction:column;padding:20px 14px 16px;display:flex;position:sticky;top:0;overflow:hidden}.admin-sidebar:after{content:"";background:linear-gradient(transparent 3%, color-mix(in oklch, var(--admin-vital) 72%, transparent) 24%, color-mix(in oklch, var(--admin-vital) 12%, transparent) 70%, transparent);opacity:.8;width:1px;height:100%;position:absolute;top:0;right:0}.life-quiet .admin-sidebar:after{background:linear-gradient(#0000,#ffffff16,#0000)}.life-resting .admin-sidebar:after{background:linear-gradient(transparent, color-mix(in oklch, var(--admin-amber) 50%, transparent), transparent)}.admin-brand{color:inherit;align-items:center;gap:12px;padding:6px 7px 25px;text-decoration:none;display:flex}.brand-mark{width:42px;height:42px;color:color-mix(in oklch, var(--admin-vital) 76%, white);background:#ffffff0a;border:1px solid #ffffff1d;border-radius:13px;flex:none;place-items:center;display:grid;position:relative}.brand-mark i{border:2px solid var(--admin-ink);background:var(--admin-vital);width:9px;height:9px;box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 16%, transparent);border-radius:50%;position:absolute;bottom:-2px;right:-2px}.life-resting .brand-mark i{background:var(--admin-amber)}.life-quiet .brand-mark i{box-shadow:none;background:#718084}.admin-brand b,.admin-brand span{display:block}.admin-brand b{max-width:132px;color:var(--admin-paper);font:650 14px var(--admin-display);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.admin-brand span{color:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-ink));letter-spacing:.1em;margin-top:3px;font-size:10px}.admin-sidebar nav{scrollbar-gutter:stable;gap:14px;padding:2px 4px 2px 0;display:grid;overflow-y:auto}.admin-sidebar nav::-webkit-scrollbar{width:6px}.admin-sidebar nav::-webkit-scrollbar-thumb{border-width:1px}.nav-group{gap:3px;display:grid}.nav-group>p{color:color-mix(in oklch, var(--admin-paper) 34%, var(--admin-ink));font:650 var(--admin-type-micro) var(--admin-data);letter-spacing:.08em;margin:0 0 4px;padding:0 11px}.nav-group button{width:100%;min-height:42px;color:color-mix(in oklch, var(--admin-paper) 62%, var(--admin-ink));text-align:left;background:0 0;border:1px solid #0000;border-radius:10px;align-items:center;gap:10px;padding:0 10px;transition:color .16s,background .16s,border-color .16s;display:flex;position:relative}.nav-group button:hover{color:var(--admin-paper);background:#ffffff0a}.nav-group button.active{color:var(--admin-paper);background:color-mix(in oklch, var(--admin-vital) 19%, transparent);border-color:#ffffff12}.nav-group button.active:before{content:"";background:var(--admin-vital);border-radius:0 999px 999px 0;width:3px;height:20px;position:absolute;left:-14px}.nav-group button span{flex:1;min-width:0;font-size:12px;font-weight:560}.nav-group button.active span{font-weight:680}.nav-chevron{opacity:0;transition:opacity .16s,transform .16s;transform:translate(-3px)}.nav-group button:hover .nav-chevron,.nav-group button.active .nav-chevron{opacity:.62;transform:translate(0)}.sidebar-foot{border-top:1px solid #ffffff12;gap:13px;margin-top:auto;padding:16px 8px 1px;display:grid}.sidebar-foot>span,.sidebar-foot button{color:color-mix(in oklch, var(--admin-paper) 44%, var(--admin-ink));text-align:left;background:0 0;border:0;align-items:center;gap:8px;font-size:10px;display:flex}.sidebar-foot button{padding:0}.sidebar-foot button:hover{color:color-mix(in oklch, var(--admin-paper) 86%, var(--admin-ink))}.sidebar-presence>i{background:var(--admin-vital);width:7px;height:7px;box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 14%, transparent);border-radius:50%}.life-resting .sidebar-presence>i{background:var(--admin-amber);box-shadow:none}.life-quiet .sidebar-presence>i{box-shadow:none;background:#718084}.admin-main{min-width:0}.admin-topbar{z-index:20;border-bottom:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-paper) 88%, transparent);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);justify-content:space-between;align-items:center;gap:28px;min-height:82px;padding:13px 34px;display:flex;position:sticky;top:0}.topbar-title{min-width:0}.topbar-title .eyebrow{margin-bottom:5px;font-size:9px}.topbar-title h1{font:660 22px/1.2 var(--admin-display);letter-spacing:-.035em;margin:0;display:inline}.topbar-title>span{color:var(--admin-muted);margin-left:14px;font-size:12px}.topbar-actions{align-items:center;gap:22px;display:flex}.topbar-actions>a{color:var(--admin-muted);white-space:nowrap;align-items:center;gap:5px;font-size:11px;text-decoration:none;display:inline-flex}.topbar-actions>a:hover{color:var(--admin-vital)}.admin-language-toggle{border:1px solid var(--admin-line);min-height:34px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 92%, var(--admin-paper));box-shadow:0 5px 16px color-mix(in oklch, var(--admin-ink) 7%, transparent);cursor:pointer;border-radius:999px;align-items:center;gap:6px;padding:0 10px;font-size:11px;transition:color .16s,border-color .16s,background .16s,transform .16s;display:inline-flex}.admin-language-toggle svg{color:var(--admin-vital)}.admin-language-toggle>span{white-space:nowrap}.admin-language-toggle:hover{color:var(--admin-ink);border-color:color-mix(in oklch, var(--admin-vital) 43%, var(--admin-line));background:var(--admin-soft);transform:translateY(-1px)}.admin-language-toggle:active{transform:translateY(0)}.admin-language-toggle-floating{z-index:100;position:fixed;top:18px;right:18px}.shell-life{color:var(--admin-muted);align-items:center;gap:11px;display:flex}.shell-life>span{white-space:nowrap;align-items:center;gap:7px;font-size:10px;display:inline-flex}.shell-life>span>i{background:var(--admin-vital);border-radius:50%;width:6px;height:6px}.life-resting .shell-life>span>i{background:var(--admin-amber)}.life-quiet .shell-life>span>i{background:#839093}.life-trace{justify-content:center;align-items:center;gap:4px;width:132px;height:26px;display:flex;position:relative;overflow:hidden}.life-trace:before{content:"";background:color-mix(in oklch, var(--admin-vital) 32%, var(--admin-line));height:1px;position:absolute;left:0;right:0}.life-trace:after{content:"";background:var(--admin-vital);width:8px;height:8px;box-shadow:0 0 12px color-mix(in oklch, var(--admin-vital) 65%, transparent);border-radius:50%;animation:2.4s linear infinite trace-travel;position:absolute;left:-12px}.life-trace i{z-index:1;background:color-mix(in oklch, var(--admin-vital) 75%, var(--admin-surface));border-radius:99px;width:2px;height:4px}.life-trace i:nth-child(2),.life-trace i:nth-child(6){height:10px}.life-trace i:nth-child(3),.life-trace i:nth-child(5){height:18px}.life-trace i:nth-child(4){height:8px}.life-resting .life-trace:before{background:color-mix(in oklch, var(--admin-amber) 32%, var(--admin-line))}.life-resting .life-trace:after{background:var(--admin-amber);animation-duration:5s}.life-resting .life-trace i{background:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-surface))}.life-quiet .life-trace:after{display:none}.life-quiet .life-trace i{background:#9aa6a7;height:3px}@keyframes trace-travel{to{transform:translate(154px)}}.admin-content{box-sizing:border-box;width:100%;max-width:2560px;margin-inline:auto;padding:28px clamp(34px,2.4vw,64px) 72px}.page-stack{gap:18px;display:grid}.presence-hero{--presence-color:var(--admin-vital);border:1px solid var(--admin-line);background:linear-gradient(90deg, color-mix(in oklch, var(--admin-ink) 4%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in oklch, var(--admin-ink) 4%, transparent) 1px, transparent 1px), var(--admin-surface);background-size:34px 34px;border-radius:18px;grid-template-columns:minmax(0,1fr) 330px;align-items:stretch;min-height:248px;display:grid;position:relative;overflow:hidden}.presence-hero.resting{--presence-color:var(--admin-amber)}.presence-hero.quiet{--presence-color:#869396}.presence-copy{z-index:1;align-self:center;padding:34px 38px;position:relative}.presence-copy .eyebrow{color:color-mix(in oklch, var(--presence-color) 72%, var(--admin-muted))}.presence-copy h1{font:670 clamp(36px, 4vw, 50px)/1.08 var(--admin-display);letter-spacing:-.05em;flex-wrap:wrap;align-items:center;gap:13px;margin:0;display:flex}.live-badge{border:1px solid color-mix(in oklch, var(--presence-color) 30%, transparent);color:color-mix(in oklch, var(--presence-color) 82%, var(--admin-ink));background:color-mix(in oklch, var(--presence-color) 10%, var(--admin-surface));font:650 10px var(--admin-body);letter-spacing:.03em;border-radius:999px;padding:6px 10px}.presence-readout{flex-wrap:wrap;gap:0;margin-top:32px;display:flex}.presence-readout>div{border-left:1px solid var(--admin-line);min-width:135px;padding:0 24px}.presence-readout>div:first-child{border-left:0;min-width:220px;padding-left:0}.presence-readout span,.presence-readout strong{display:block}.presence-readout span{color:var(--admin-muted);margin-bottom:6px;font-size:10px}.presence-readout strong{max-width:260px;font:610 12px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.pulse-organ{z-index:1;border-left:1px solid var(--admin-line);background:color-mix(in oklch, var(--presence-color) 7%, var(--admin-surface));justify-content:center;align-items:center;gap:8px;min-height:248px;display:flex;position:relative;overflow:hidden}.pulse-organ:before,.pulse-organ:after{content:"";border:1px solid color-mix(in oklch, var(--presence-color) 18%, transparent);border-radius:50%;width:210px;height:210px;position:absolute}.pulse-organ:after{width:150px;height:150px}.organ-line{background:color-mix(in oklch, var(--presence-color) 24%, var(--admin-line));height:1px;position:absolute;left:30px;right:30px}.pulse-organ>i{z-index:1;width:4px;height:var(--h);background:var(--presence-color);max-height:92px;box-shadow:0 0 14px color-mix(in oklch, var(--presence-color) 25%, transparent);animation:vital-beat 1.8s ease-in-out var(--d) infinite;border-radius:999px}.presence-hero.resting .pulse-organ>i{animation-duration:4s}.presence-hero.quiet .pulse-organ>i{opacity:.42;animation:none;transform:scaleY(.35)}.pulse-organ small{color:color-mix(in oklch, var(--presence-color) 55%, var(--admin-muted));font:650 9px var(--admin-data);letter-spacing:.12em;position:absolute;bottom:17px;right:20px}.presence-hero>.icon-btn{z-index:3;position:absolute;top:14px;right:14px}@keyframes vital-beat{0%,to{opacity:.5;transform:scaleY(.46)}42%{opacity:1;transform:scaleY(1)}}.vital-grid{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:16px;grid-template-columns:repeat(5,minmax(0,1fr));gap:1px;display:grid;overflow:hidden}.vital{background:var(--admin-surface);grid-template-columns:1fr auto;align-items:start;min-height:138px;padding:19px 20px;transition:background .16s;display:grid}.vital:hover{background:var(--admin-soft)}.vital>svg{color:var(--admin-vital)}.vital>span{color:var(--admin-muted);text-align:right;font-size:11px}.vital>strong{font:660 30px/1 var(--admin-data);letter-spacing:-.04em;font-variant-numeric:tabular-nums;grid-column:1/-1;margin-top:13px}.vital>small{color:var(--admin-muted);grid-column:1/-1;margin-top:8px;font-size:11px}.two-col{grid-template-columns:1fr 1fr;gap:18px;display:grid}.admin-panel{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:16px;min-width:0;padding:24px}.admin-panel>header{justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:22px;display:flex}.admin-panel>header h2{font:660 17px/1.3 var(--admin-display);letter-spacing:-.02em;margin:0}.admin-panel>header p{max-width:680px;color:var(--admin-muted);margin:6px 0 0;font-size:12px;line-height:1.6}.memory-meter{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:11px;grid-template-columns:repeat(3,1fr);gap:1px;display:grid;overflow:hidden}.memory-meter div{background:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-surface));padding:15px}.memory-meter span,.memory-meter b{display:block}.memory-meter span{color:var(--admin-muted);font-size:11px}.memory-meter b{font:620 13px var(--admin-data);margin-top:8px}.runtime-clock{color:var(--admin-vital);align-items:center;gap:17px;padding:11px;display:flex}.runtime-clock b,.runtime-clock span{display:block}.runtime-clock b{color:var(--admin-ink);font:620 14px var(--admin-data);font-variant-numeric:tabular-nums}.runtime-clock span{color:var(--admin-muted);margin-top:5px;font-size:11px}.notice{border:1px solid #0000;border-radius:10px;align-items:center;gap:9px;min-height:42px;padding:10px 13px;font-size:12px;line-height:1.5;display:flex}.notice.amber{color:color-mix(in oklch, var(--admin-amber) 72%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-amber) 34%, var(--admin-surface));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface))}.notice.success{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-vital) 28%, var(--admin-surface));background:color-mix(in oklch, var(--admin-vital) 8%, var(--admin-surface))}.notice.error{color:color-mix(in oklch, var(--admin-red) 82%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-red) 28%, var(--admin-surface));background:color-mix(in oklch, var(--admin-red) 7%, var(--admin-surface))}.state-box{border:1px dashed var(--admin-line);min-height:180px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 82%, transparent);border-radius:15px;align-content:center;place-items:center;gap:12px;font-size:12px;display:grid}.state-box.error{color:var(--admin-red)}.state-pulse{align-items:center;gap:4px;height:24px;display:flex}.state-pulse i{background:var(--admin-vital);border-radius:99px;width:3px;height:9px;animation:1.1s ease-in-out infinite state-beat}.state-pulse i:nth-child(2){height:21px;animation-delay:.1s}.state-pulse i:nth-child(3){height:13px;animation-delay:.2s}@keyframes state-beat{50%{opacity:.45;transform:scaleY(.45)}}.active-model{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:var(--admin-soft);border-radius:12px;align-items:center;gap:15px;padding:17px;display:flex}.active-model svg{color:var(--admin-vital)}.active-model span,.active-model strong{display:block}.active-model span{color:var(--admin-muted);font-size:11px}.active-model strong{font:650 15px var(--admin-data);margin-top:5px}.inline-form{grid-template-columns:1fr 1.3fr auto;gap:10px;margin-top:15px;display:grid}.inline-form input,.inline-form select,.field input,.field select,.field textarea,.inline-add input,.search-box input,.log-filters input,.log-filters select,.row-actions select,.editor-meta input,.tiny-input{border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);background:#fff;border-radius:9px;outline:0;padding:9px 12px;font-size:13px;transition:border-color .16s,box-shadow .16s,background .16s}.inline-form :where(input,select):focus,.field :where(input,select,textarea):focus,.inline-add input:focus,.search-box input:focus,.log-filters :where(input,select):focus,.editor-meta input:focus,.source-editor:focus,.code-area:focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.field-grid{grid-template-columns:1fr 1fr;align-items:start;gap:15px;display:grid}.field{align-content:start;align-self:start;gap:var(--admin-field-gap);min-width:0;display:grid}.field>span{min-height:var(--admin-field-label-height);color:color-mix(in oklch, var(--admin-ink) 84%, var(--admin-muted));align-items:center;gap:7px;font-size:12px;font-weight:650;line-height:1.5;display:flex}.field>:where(input,select,textarea){box-sizing:border-box;width:100%}.field small{color:var(--admin-muted);font-size:11px;line-height:1.45}.effect-badge{min-height:19px;font:8px var(--admin-data);white-space:nowrap;border-radius:5px;align-items:center;padding:0 6px;font-style:normal;display:inline-flex}.effect-badge.hot{color:var(--admin-vital);background:var(--admin-soft)}.provider-editor-head .effect-badge{vertical-align:2px;margin-left:6px}.field textarea{resize:vertical;min-height:105px}.switch{min-height:42px;color:var(--admin-muted);cursor:pointer;align-self:end;align-items:center;gap:9px;font-size:12px;display:flex;position:relative}.switch input{opacity:0;pointer-events:none;position:absolute}.switch>i{background:color-mix(in oklch, var(--admin-ink) 16%, var(--admin-surface));border-radius:99px;width:38px;height:22px;padding:3px;transition:background .18s}.switch>i:after{content:"";background:#fff;border-radius:50%;width:16px;height:16px;transition:transform .18s;display:block;box-shadow:0 2px 5px #0002}.switch input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 28%, transparent);outline-offset:2px}.switch input:checked+i{background:var(--admin-vital)}.switch input:checked+i:after{transform:translate(16px)}.code-area{border:1px solid var(--admin-line);width:100%;min-height:180px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-paper) 62%, var(--admin-surface));font:12px/1.7 var(--admin-data);resize:vertical;border-radius:10px;outline:0;padding:14px}.code-area.short{min-height:112px}.code-area.compact{min-height:128px}.code-area.invalid{border-color:color-mix(in oklch, var(--admin-red) 55%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 3%, white)}.field .field-error{color:var(--admin-red)}.panel-actions{justify-content:flex-end;align-items:center;gap:9px;margin-top:18px;display:flex}.settings-layout{grid-template-columns:188px minmax(0,1fr);align-items:start;gap:18px;display:grid}.subnav{border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);border-radius:13px;gap:3px;padding:7px;display:grid;position:sticky;top:104px}.subnav button{min-height:40px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:8px;justify-content:space-between;align-items:center;gap:8px;padding:0 10px;font-size:11px;display:flex}.subnav button:hover{color:var(--admin-ink);background:var(--admin-paper)}.subnav button.active{color:var(--admin-vital);background:var(--admin-soft);font-weight:680}.subnav button:disabled{opacity:.58;cursor:wait}.subnav button>span{align-items:center;gap:7px;display:inline-flex}.settings-dirty-dot{background:var(--admin-amber);width:6px;height:6px;box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-amber) 13%, transparent);border-radius:50%}.config-panel{max-width:1020px}.config-override-strip{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:color-mix(in oklch, var(--admin-soft) 58%, var(--admin-surface));border-radius:11px;gap:10px;margin-top:12px;padding:12px;display:grid}.config-override-strip>div:first-child{color:var(--admin-vital);align-items:flex-start;gap:9px;display:flex}.config-override-strip>div:first-child svg{flex:none;margin-top:1px}.config-override-strip b,.config-override-strip small{display:block}.config-override-strip b{font-size:11px}.config-override-strip small{color:var(--admin-muted);margin-top:2px;font-size:10px;line-height:1.45}.config-override-strip>div:last-child{flex-wrap:wrap;gap:6px;display:flex}.config-override-strip button{border:1px solid var(--admin-line);min-height:29px;color:var(--admin-muted);background:var(--admin-surface);cursor:pointer;border-radius:7px;align-items:center;gap:6px;padding:5px 8px;font-size:9px;display:inline-flex}.config-override-strip button:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line))}.config-override-strip button.pending{color:color-mix(in oklch, var(--admin-amber) 74%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-amber) 38%, var(--admin-line));background:color-mix(in oklch, var(--admin-amber) 7%, var(--admin-surface))}.config-override-strip button:focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:1px}.config-fields{grid-template-columns:1fr 1fr;align-items:start;gap:16px;margin-top:17px;display:grid}.config-switch{margin-top:calc(var(--admin-field-label-height) + var(--admin-field-gap));align-self:start}.config-fields .field:has(.code-area){grid-column:1/-1}.string-list-field,.transport-list-field{grid-column:1/-1}.string-list-editor{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;overflow:hidden}.string-list-items{display:grid}.string-list-item{border-bottom:1px solid var(--admin-line);grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;min-height:42px;padding:7px 8px 7px 12px;display:grid}.string-list-item code{overflow-wrap:anywhere;color:var(--admin-ink);font:11px/1.5 var(--admin-data)}.string-list-item button{width:28px;height:28px;color:var(--admin-muted);cursor:pointer;background:0 0;border:0;border-radius:7px;place-items:center;display:grid}.string-list-item button:hover{color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 7%, var(--admin-surface))}.string-list-item button:focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:1px}.string-list-empty{border-bottom:1px dashed var(--admin-line);color:var(--admin-muted);text-align:center;padding:18px 12px;font-size:11px}.string-list-add{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface));grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:9px;display:grid}.string-list-add input{border:1px solid var(--admin-line);min-width:0;min-height:38px;color:var(--admin-ink);font:11px var(--admin-data);background:#fff;border-radius:8px;outline:0;padding:8px 10px}.string-list-add input:focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.string-list-add button{white-space:nowrap}.transport-list-editor{grid-template-columns:1fr 1fr;gap:9px;display:grid}.transport-list-editor>label{border:1px solid var(--admin-line);background:var(--admin-surface);cursor:pointer;border-radius:10px;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:10px;min-height:62px;padding:10px 12px;display:grid}.transport-list-editor>label:has(input:checked){border-color:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));background:var(--admin-soft)}.transport-list-editor input{opacity:0;pointer-events:none;position:absolute}.transport-list-editor i{border:1px solid var(--admin-line);color:#0000;background:#fff;border-radius:6px;place-items:center;width:22px;height:22px;display:grid}.transport-list-editor input:checked+i{color:#fff;border-color:var(--admin-vital);background:var(--admin-vital)}.transport-list-editor input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:2px}.transport-list-editor b,.transport-list-editor small{display:block}.transport-list-editor b{font:650 11px var(--admin-data)}.transport-list-editor small{color:var(--admin-muted);margin-top:3px;font-size:10px}.llm-config-overview{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:13px;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:20px;margin-top:17px;padding:20px;display:grid}.llm-config-copy{align-items:flex-start;gap:13px;display:flex}.llm-config-copy>svg{color:var(--admin-vital);flex:none;margin-top:2px}.llm-config-copy span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.11em}.llm-config-copy h3{font:20px var(--admin-display);margin:5px 0}.llm-config-copy p{max-width:610px;color:var(--admin-muted);margin:0;font-size:11px;line-height:1.65}.llm-config-facts{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:9px;grid-template-columns:repeat(3,minmax(100px,auto));display:grid;overflow:hidden}.llm-config-facts span{border-right:1px solid var(--admin-line);min-width:0;color:var(--admin-muted);padding:11px 12px;font-size:9px}.llm-config-facts span:last-child{border-right:0}.llm-config-facts b{color:var(--admin-ink);font:650 11px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-bottom:5px;display:block;overflow:hidden}.config-section-heading{border-bottom:1px solid var(--admin-line);grid-column:1/-1;align-items:center;min-height:42px;padding-bottom:8px;display:flex}.config-section-heading b,.config-section-heading small{display:block}.config-section-heading b{font-size:13px}.config-section-heading small{color:var(--admin-muted);margin-top:4px;font-size:10px}.weixin-settings{gap:14px;display:grid}.weixin-overview{border:1px solid var(--admin-line);background:linear-gradient(120deg, color-mix(in oklch, #19ad67 7%, var(--admin-soft)), var(--admin-surface) 70%);border-radius:14px;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;padding:18px;display:grid}.weixin-overview.warning{border-color:color-mix(in oklch, var(--admin-amber) 36%, var(--admin-line))}.weixin-overview.disabled{background:var(--admin-paper);border-style:dashed}.weixin-overview>div{align-items:flex-start;gap:12px;min-width:0;display:flex}.weixin-overview>div>svg{color:#19ad67;flex:none;margin-top:2px}.weixin-overview span small,.weixin-overview span b,.weixin-overview span p{display:block}.weixin-overview span small{color:#168954;font:680 9px var(--admin-data);letter-spacing:.12em}.weixin-overview span b{font:18px var(--admin-display);margin-top:5px}.weixin-overview span p{color:var(--admin-muted);margin:4px 0 0;font-size:11px}.weixin-pairing{border:1px solid color-mix(in oklch, #19ad67 28%, var(--admin-line));background:linear-gradient(135deg, var(--admin-surface), color-mix(in oklch, #19ad67 6%, var(--admin-paper)));border-radius:15px;grid-template-columns:190px minmax(0,1fr);align-items:center;gap:24px;padding:20px;display:grid}.weixin-pairing.confirmed{border-color:color-mix(in oklch, #19ad67 42%, var(--admin-line))}.weixin-pairing.failed{border-color:color-mix(in oklch, var(--admin-amber) 42%, var(--admin-line));background:linear-gradient(135deg, var(--admin-surface), color-mix(in oklch, var(--admin-amber) 6%, var(--admin-paper)))}.weixin-qr-stage{aspect-ratio:1;border:1px solid var(--admin-line);background:#fff;border-radius:12px;place-items:center;padding:12px;display:grid;box-shadow:inset 0 0 0 5px #f4f7f5}.weixin-pairing.confirmed .weixin-qr-stage{color:#168954;background:oklch(97.6158% .0108966 155.498)}.weixin-pairing.failed .weixin-qr-stage{color:var(--admin-amber);background:color-mix(in oklch, var(--admin-amber) 7%, white)}.weixin-qr-stage img{object-fit:contain;width:100%;height:100%;display:block}.weixin-pairing-copy>span{color:#168954;font:680 9px var(--admin-data);letter-spacing:.12em}.weixin-pairing-copy h3{font:21px var(--admin-display);margin:7px 0}.weixin-pairing-copy p{max-width:620px;color:var(--admin-muted);margin:0 0 14px;font-size:11px;line-height:1.65}.weixin-verify{gap:8px;margin-bottom:10px;display:flex}.weixin-verify input{max-width:220px}.weixin-account-list{gap:8px;display:grid}.weixin-account-list article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:13px 14px;display:grid}.weixin-account-mark{color:#168954;background:color-mix(in oklch, #19ad67 10%, var(--admin-paper));border-radius:10px;place-items:center;width:38px;height:38px;display:grid}.weixin-account-copy input,.weixin-account-copy code,.weixin-account-copy small{display:block}.weixin-account-copy input{max-width:320px;min-height:32px;padding:4px 8px;font-weight:650}.weixin-account-copy code{color:var(--admin-muted);margin-top:5px;font-size:9px}.weixin-account-copy small{color:var(--admin-muted);margin-top:3px;font-size:9px}.provider-editor{grid-column:1/-1;gap:10px;display:grid}.provider-editor-head{justify-content:space-between;align-items:center;gap:16px;display:flex}.provider-editor-head b,.provider-editor-head small{display:block}.provider-editor-head b{font-size:15px}.provider-editor-head small{color:var(--admin-muted);margin-top:5px;font-size:11px;line-height:1.5}.provider-source-note{border-left:2px solid var(--admin-vital);color:var(--admin-vital);background:var(--admin-soft);align-items:flex-start;gap:10px;padding:12px 13px;display:flex}.provider-source-note p{margin:0}.provider-source-note b,.provider-source-note span{display:block}.provider-source-note b{color:var(--admin-ink);font-size:11px}.provider-source-note span{color:var(--admin-muted);margin-top:4px;font-size:10px;line-height:1.6}.provider-source-note code{color:var(--admin-ink);font:10px var(--admin-data)}.provider-effective{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:9px;flex-wrap:wrap;align-items:center;gap:8px;padding:11px 13px;display:flex}.provider-effective>b{width:100%;color:var(--admin-muted);font-size:10px}.provider-effective>span{background:var(--admin-soft);border-radius:7px;align-items:center;gap:8px;padding:6px 9px;font-size:10px;display:flex}.provider-effective strong{color:var(--admin-ink)}.provider-effective code{color:var(--admin-vital);font:10px var(--admin-data)}.provider-effective small{color:var(--admin-muted)}.provider-row{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:11px;grid-template-columns:1fr 1fr;align-items:start;gap:14px 16px;padding:18px 62px 18px 18px;display:grid;position:relative}.provider-row .field:nth-of-type(3){grid-column:1/-1}.provider-row .field>span{font-size:11px}.provider-row .field small{font-size:10px}.provider-remove{position:absolute;top:14px;right:14px}.provider-empty{border:1px dashed var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);text-align:center;border-radius:11px;padding:22px;font-size:11px}.desktop-update-settings{gap:16px;margin-top:17px;display:grid}.desktop-sync-overview{border:1px solid var(--admin-line);background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:14px;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);align-items:center;gap:18px;padding:18px;display:grid}.desktop-sync-overview.ready{border-color:color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line))}.desktop-sync-overview.warning{border-color:color-mix(in oklch, var(--admin-amber) 36%, var(--admin-line));background:color-mix(in oklch, var(--admin-amber) 6%, var(--admin-surface))}.desktop-sync-overview.disabled{background:var(--admin-paper);border-style:dashed}.desktop-sync-overview-copy{align-items:flex-start;gap:12px;min-width:0;display:flex}.desktop-sync-overview-copy>svg{color:var(--admin-vital);flex:none;margin-top:3px}.desktop-sync-overview.warning .desktop-sync-overview-copy>svg{color:var(--admin-amber)}.desktop-sync-overview-copy span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.12em}.desktop-sync-overview-copy h3{font:19px var(--admin-display);margin:5px 0}.desktop-sync-overview-copy p{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;margin:0;font-size:11px;line-height:1.6;overflow:hidden}.desktop-sync-overview-copy b{color:var(--admin-ink)}.desktop-source-toolbar{justify-content:space-between;align-items:center;gap:14px;display:flex}.desktop-source-toolbar b,.desktop-source-toolbar small{display:block}.desktop-source-toolbar b{font-size:14px}.desktop-source-toolbar small{color:var(--admin-muted);margin-top:4px;font-size:10px}.desktop-source-toolbar>div:last-child{flex-wrap:wrap;justify-content:flex-end;gap:8px;display:flex}.desktop-source-workbench{grid-template-columns:minmax(300px,.42fr) minmax(0,1fr);align-items:start;gap:14px;display:grid}.desktop-source-list{gap:9px;display:grid}.desktop-source-item{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;overflow:hidden}.desktop-source-item.selected{border-color:color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));box-shadow:inset 3px 0 0 var(--admin-vital)}.desktop-source-item.warning:not(.selected){border-color:color-mix(in oklch, var(--admin-amber) 30%, var(--admin-line))}.desktop-source-item>button{width:100%;min-height:66px;color:inherit;text-align:left;background:0 0;border:0;justify-content:space-between;align-items:flex-start;gap:10px;padding:12px;display:flex}.desktop-source-item>button:hover{background:var(--admin-paper)}.desktop-source-item b,.desktop-source-item small{display:block}.desktop-source-item b{color:var(--admin-ink);font-size:12px}.desktop-source-item small{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;margin-top:5px;font-size:10px;line-height:1.45;overflow:hidden}.desktop-source-badge{color:var(--admin-muted);background:var(--admin-paper);font:normal 9px var(--admin-data);border-radius:99px;flex:none;padding:4px 7px}.desktop-source-badge.active{color:var(--admin-vital);background:var(--admin-soft)}.desktop-source-badge.warning{color:color-mix(in oklch, var(--admin-amber) 75%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 11%, var(--admin-surface))}.desktop-source-row-actions{grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;padding:0 10px 10px;display:grid}.desktop-source-row-actions .mini{min-width:0;padding-inline:6px;font-size:9px}.desktop-source-detail{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;min-width:0;padding:16px}.desktop-source-detail>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.desktop-source-detail>header span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.12em}.desktop-source-detail>header h3{font:18px var(--admin-display);margin:5px 0 4px}.desktop-source-detail>header p{overflow-wrap:anywhere;color:var(--admin-muted);font:10px var(--admin-data);margin:0}.desktop-source-detail .config-fields{margin-top:14px}.desktop-source-form{gap:16px;margin-top:16px;display:grid}.desktop-form-row{align-items:start;gap:16px;display:grid}.desktop-form-row.two{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.desktop-option-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.desktop-option-card{border:1px solid var(--admin-line);background:var(--admin-paper);cursor:pointer;border-radius:10px;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:4px 10px;min-height:72px;padding:12px;display:grid;position:relative}.desktop-option-card input{width:18px;height:18px;accent-color:var(--admin-vital);grid-row:1/span 2;margin:1px 0 0}.desktop-option-card span{color:var(--admin-ink);font-size:11px;font-weight:650}.desktop-option-card small{color:var(--admin-muted);font-size:9px;line-height:1.5}.desktop-option-card:has(input:checked){border-color:color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:var(--admin-soft)}.desktop-global-settings{gap:0;display:grid}.unit-field{grid-template-columns:minmax(0,1fr) 92px;gap:8px;display:grid}.field>small+small{margin-top:-3px}.desktop-source-empty{place-items:center;min-height:110px;display:grid}.admin-settings-status{gap:14px;margin-top:17px;display:grid}.admin-security-hero{border:1px solid color-mix(in oklch, var(--admin-vital) 25%, var(--admin-line));background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:13px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:16px;min-height:132px;padding:22px;display:grid;position:relative;overflow:hidden}.admin-security-hero:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";border-radius:50%;width:190px;height:190px;position:absolute;top:-70px;right:-40px}.admin-security-hero.missing{border-color:color-mix(in oklch, var(--admin-red) 28%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface))}.security-seal{border:1px solid color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));width:62px;height:62px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;place-items:center;display:grid;position:relative}.security-seal i{border:2px solid var(--admin-surface);background:var(--admin-vital);border-radius:50%;width:9px;height:9px;position:absolute;bottom:4px;right:4px}.admin-security-hero>div:nth-child(2)>span{color:var(--admin-vital);font:680 8px var(--admin-data);letter-spacing:.13em}.admin-security-hero h3{font:20px var(--admin-display);margin:6px 0 5px}.admin-security-hero p{max-width:620px;color:var(--admin-muted);margin:0;font-size:10px;line-height:1.6}.admin-security-hero>b{z-index:1;color:var(--admin-vital);background:var(--admin-surface);border-radius:6px;padding:6px 9px;font-size:9px;position:relative}.admin-security-hero.missing>b{color:var(--admin-red)}.admin-setting-cards{grid-template-columns:1fr 1fr;gap:10px;display:grid}.admin-setting-cards article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;grid-template-columns:auto minmax(0,1fr);gap:11px;min-height:110px;padding:16px;display:grid}.admin-setting-cards article>svg{color:var(--admin-vital)}.admin-setting-cards span,.admin-setting-cards b,.admin-setting-cards code,.admin-setting-cards small{display:block}.admin-setting-cards span{color:var(--admin-muted);font-size:9px}.admin-setting-cards b,.admin-setting-cards code{overflow-wrap:anywhere;color:var(--admin-ink);font:650 11px/1.5 var(--admin-data);margin-top:6px}.admin-setting-cards small{color:var(--admin-muted);margin-top:7px;font-size:9px;line-height:1.55}.admin-security-note{border-left:2px solid var(--admin-amber);color:var(--admin-amber);background:color-mix(in oklch, var(--admin-amber) 6%, var(--admin-surface));align-items:flex-start;gap:10px;padding:13px;display:flex}.admin-security-note p{margin:0}.admin-security-note b,.admin-security-note span{display:block}.admin-security-note b{color:var(--admin-ink);font-size:10px}.admin-security-note span{color:var(--admin-muted);margin-top:4px;font-size:9px;line-height:1.55}.admin-security-note code{color:var(--admin-ink);font:9px var(--admin-data)}.config-panel>.panel-actions{z-index:4;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 10%, transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-radius:12px;margin:22px -8px -8px;padding:10px;position:sticky;bottom:20px}.tabbar{border:1px solid var(--admin-line);background:var(--admin-surface);scrollbar-width:none;border-radius:11px;gap:4px;width:max-content;max-width:100%;padding:5px;display:flex;overflow:auto}.tabbar::-webkit-scrollbar{display:none}.tabbar button{min-height:34px;color:var(--admin-muted);white-space:nowrap;background:0 0;border:0;border-radius:7px;padding:0 13px;font-size:11px}.tabbar button:hover{color:var(--admin-ink);background:var(--admin-paper)}.tabbar button.active{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));background:var(--admin-soft);font-weight:680}.tabbar.compact{width:100%}.tabbar.compact button{flex:1;padding:0 7px}.inline-add,.search-box,.log-filters{align-items:center;gap:8px;display:flex}.inline-add button,.search-box button{color:#fff;background:var(--admin-ink);border:0;border-radius:8px;min-height:38px;padding:0 13px;font-size:11px;font-weight:680}.record-list{border:1px solid var(--admin-line);border-radius:12px;gap:0;display:grid;overflow:hidden}.record{border:0;border-bottom:1px solid var(--admin-line);background:var(--admin-surface);justify-content:space-between;align-items:center;gap:17px;min-height:72px;padding:13px 15px;transition:background .16s;display:flex}.record:last-child{border-bottom:0}.record:hover{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface))}.record>div:first-child{min-width:0}.record b,.record small{display:block}.record b{overflow-wrap:anywhere;margin:6px 0 4px;font-size:13px;line-height:1.45}.record small{color:var(--admin-muted);font:10px/1.5 var(--admin-data)}.bubble-list{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;overflow:hidden}.bubble-record{border-bottom:1px solid var(--admin-line)}.bubble-record:last-child{border-bottom:0}.bubble-record-head{justify-content:space-between;align-items:center;gap:17px;min-height:72px;padding:13px 15px;transition:background .16s;display:flex}.bubble-record-head:hover,.bubble-record.open .bubble-record-head{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface))}.bubble-record .record-main{min-width:0}.bubble-record-tags{align-items:center;gap:5px;display:flex}.bubble-mode{color:var(--admin-vital);background:var(--admin-soft);font:680 9px var(--admin-data);border-radius:99px;padding:4px 7px}.bubble-handoff-tag{border:1px solid color-mix(in oklch, var(--admin-vital) 26%, var(--admin-line));color:var(--admin-vital);background:color-mix(in oklch, var(--admin-vital) 7%, var(--admin-surface));font:680 9px var(--admin-data);border-radius:99px;align-items:center;gap:4px;padding:4px 7px;display:inline-flex}.bubble-record-title{overflow-wrap:anywhere;-webkit-line-clamp:2;-webkit-box-orient:vertical;margin:7px 0 5px;font-size:13px;line-height:1.5;display:-webkit-box;overflow:hidden}.bubble-record-routing{flex-wrap:wrap;gap:5px;margin:0 0 6px;display:flex}.bubble-record-routing>span{border:1px solid var(--admin-line);min-width:0;max-width:min(100%,320px);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-paper) 58%, var(--admin-surface));font:600 9px/1.3 var(--admin-data);border-radius:6px;align-items:center;gap:4px;padding:3px 6px;display:inline-flex}.bubble-record-routing code{color:var(--admin-ink);font:inherit;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.bubble-record-meta{color:var(--admin-muted);font:10px/1.5 var(--admin-data);text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.bubble-history{background:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-surface));padding:12px 15px 16px}.bubble-history .short-message-list{max-height:640px}.bubble-history-loading{color:var(--admin-muted);text-align:center;padding:22px;font-size:11px}.bubble-load-more{min-width:180px;margin:14px auto 0;display:block}.status-pill{color:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 11%, var(--admin-surface));font:680 9px var(--admin-data);text-transform:uppercase;border-radius:99px;padding:4px 7px}.status-pill.running,.status-pill.in_progress{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));background:color-mix(in oklch, var(--admin-vital) 10%, var(--admin-surface))}.status-pill.error{color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 9%, var(--admin-surface))}.status-pill.done,.status-pill.completed{color:var(--admin-muted);background:var(--admin-paper)}.row-actions{align-items:center;gap:8px;display:flex}.task-compose{grid-template-columns:1fr 1.2fr auto;align-items:stretch;gap:9px;margin-bottom:14px;display:grid}.task-compose input,.task-compose textarea{border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);font:12px/1.5 var(--admin-body);background:#fff;border-radius:9px;outline:0;padding:9px 12px}.task-compose textarea{resize:vertical;height:42px}.task-compose :where(input,textarea):focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.list-toolbar{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.task-filters{background:var(--admin-paper);border-radius:9px;align-items:center;gap:4px;padding:4px;display:flex}.task-filters button{min-height:30px;color:var(--admin-muted);background:0 0;border:0;border-radius:6px;padding:0 10px;font-size:10px}.task-filters button.active{color:var(--admin-vital);background:var(--admin-surface);font-weight:680;box-shadow:0 1px 5px oklch(22% .012 260/.05)}.record-main{flex:1;min-width:0}.record-details{max-width:720px;color:var(--admin-muted);-webkit-line-clamp:2;white-space:pre-line;-webkit-box-orient:vertical;margin:2px 0 5px;font-size:11px;line-height:1.55;display:-webkit-box;overflow:hidden}.task-record.completed .record-main>b{color:var(--admin-muted);text-decoration:line-through;-webkit-text-decoration-color:color-mix(in oklch, var(--admin-muted) 45%, transparent);text-decoration-color:color-mix(in oklch, var(--admin-muted) 45%, transparent)}.task-modal{width:min(560px,100vw - 32px)}.task-modal .field{margin-top:13px}.task-modal .field textarea{min-height:150px}.alarm-compose{grid-template-columns:1.15fr .75fr 1.6fr auto;align-items:end;gap:10px;display:grid}.alarm-compose .primary{min-height:42px}.alarm-summary{border-left:2px solid var(--admin-vital);min-height:46px;color:var(--admin-muted);background:linear-gradient(90deg, var(--admin-soft), transparent 55%);align-items:center;gap:8px;margin:14px 0 9px;padding:0 3px 0 11px;font-size:11px;display:flex}.alarm-summary svg{color:var(--admin-vital)}.alarm-summary span{flex:1}.alarm-summary .icon-btn{width:34px;height:34px}.alarm-record{grid-template-columns:76px minmax(0,1fr) auto;display:grid}.alarm-time{border-right:1px solid var(--admin-line);text-align:center;place-content:center;align-self:stretch;padding-right:15px;display:grid}.alarm-time strong,.alarm-time b{margin:0;display:block}.alarm-time strong{color:var(--admin-muted);font:9px var(--admin-data);text-transform:uppercase}.alarm-time b{color:var(--admin-ink);font:680 16px var(--admin-data);margin-top:4px}.alarm-due{color:var(--admin-vital);font:680 9px var(--admin-data);margin-bottom:1px;display:inline-block}.danger-icon,.icon-btn{border:1px solid var(--admin-line);width:38px;height:38px;color:var(--admin-muted);background:var(--admin-surface);border-radius:9px;flex:none;place-items:center;padding:0;transition:color .16s,border-color .16s,background .16s;display:grid}.icon-btn:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));background:var(--admin-soft)}.danger-icon:hover{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 6%, var(--admin-surface))}.empty{min-height:150px;color:color-mix(in oklch, var(--admin-muted) 75%, var(--admin-surface));background:var(--admin-surface);align-content:center;place-items:center;gap:8px;display:grid}.empty p{margin:0;font-size:12px}.memory-tabs{align-items:center}.memory-tabs button{justify-content:center;align-items:center;gap:6px;min-width:126px;display:flex}.short-watermark{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:linear-gradient(118deg, var(--admin-soft), var(--admin-surface) 58%);border-radius:16px;grid-template-columns:minmax(0,1.35fr) minmax(300px,.65fr);display:grid;position:relative;overflow:hidden}.short-watermark:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";pointer-events:none;border-radius:45% 55% 51% 49%;width:300px;height:300px;position:absolute;bottom:-190px;right:-60px;transform:rotate(18deg)}.watermark-reading{grid-template-columns:auto minmax(0,1fr);align-items:center;gap:24px;min-height:210px;padding:28px 30px;display:grid}.watermark-orbit{background:conic-gradient(var(--admin-vital) var(--water), color-mix(in oklch, var(--admin-line) 72%, transparent) 0);width:126px;height:126px;box-shadow:0 14px 40px color-mix(in oklch, var(--admin-vital) 11%, transparent);border-radius:50%;place-items:center;padding:7px;display:grid}.watermark-orbit>span{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, var(--admin-line));background:var(--admin-surface);border-radius:50%;align-content:center;place-items:center;width:100%;height:100%;display:grid}.watermark-orbit b{font:680 27px var(--admin-data);letter-spacing:-.04em}.watermark-orbit small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:5px}.watermark-reading h2{font:680 clamp(24px, 2.4vw, 36px) var(--admin-data);letter-spacing:-.04em;margin:8px 0 13px}.watermark-reading h2 small{color:var(--admin-muted);letter-spacing:0;font-size:.42em;font-weight:500}.watermark-reading>div:last-child>p:last-child{color:var(--admin-muted);font-size:var(--admin-type-caption);margin:9px 0 0}.watermark-track{background:var(--admin-line);border-radius:99px;width:min(480px,100%);height:5px;overflow:hidden}.watermark-track i{border-radius:inherit;background:linear-gradient(90deg, var(--admin-peach), var(--admin-vital));height:100%;display:block}.watermark-facts{z-index:1;border-left:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 84%, transparent);align-content:center;gap:0;padding:25px 56px 25px 24px;display:grid;position:relative}.watermark-facts span{border-bottom:1px solid var(--admin-line);padding:11px 0}.watermark-facts small,.watermark-facts b{display:block}.watermark-facts small{color:var(--admin-muted);font-size:var(--admin-type-micro)}.watermark-facts b{overflow-wrap:anywhere;font:610 var(--admin-type-small)/1.45 var(--admin-data);margin-top:4px}.watermark-facts p{color:var(--admin-muted);font-size:var(--admin-type-micro);align-items:flex-start;gap:6px;margin:12px 0 0;line-height:1.55;display:flex}.watermark-facts p svg{color:var(--admin-vital);flex:none;margin-top:1px}.watermark-refresh{z-index:2;position:absolute;top:14px;right:14px}.short-memory-grid{grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);align-items:start;gap:18px;display:grid}.short-tree-panel,.short-tail-panel{min-width:0}.short-tree,.short-message-list{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;max-height:720px;display:grid;overflow:auto}.short-tree-node{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);position:relative}.short-tree-node:last-child{border-bottom:0}.short-tree-node>summary{cursor:pointer;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:68px;padding:10px 12px;list-style:none;display:grid}.short-tree-node>summary::-webkit-details-marker{display:none}.short-message>summary::-webkit-details-marker{display:none}.pinned-context-list summary::-webkit-details-marker{display:none}.short-tree-node>summary:hover{background:var(--admin-paper)}.tree-level{border:1px solid color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line));width:32px;height:32px;color:var(--admin-vital);background:var(--admin-soft);font:680 var(--admin-type-caption) var(--admin-data);border-radius:50%;place-items:center;display:grid}.tree-node-copy{min-width:0}.tree-node-copy b,.tree-node-copy small{display:block}.tree-node-copy b{font:590 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.tree-node-copy small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:5px}.raw-state{color:var(--admin-vital);background:var(--admin-soft);font-size:var(--admin-type-micro);white-space:nowrap;border-radius:5px;padding:4px 6px}.raw-state.summary-only{color:var(--admin-muted);background:var(--admin-paper)}.tree-node-detail{padding:0 13px 13px 60px}.tree-node-detail>p{border-left:2px solid color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));color:var(--admin-ink);background:var(--admin-paper);font-size:var(--admin-type-small);white-space:pre-wrap;margin:0;padding:12px;line-height:1.7}.tree-children{border:1px solid var(--admin-line);border-radius:9px;margin-top:9px;overflow:hidden}.tree-children .short-tree-node>summary{min-height:60px;padding-left:calc(10px + var(--indent))}.short-message{border-bottom:1px solid var(--admin-line)}.short-message:last-child{border-bottom:0}.short-message>summary{cursor:pointer;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:76px;padding:9px 12px;list-style:none;display:grid}.short-message>summary:hover{background:var(--admin-paper)}.message-index{color:color-mix(in oklch, var(--admin-muted) 64%, var(--admin-surface));font:680 var(--admin-type-micro) var(--admin-data)}.short-message>summary b,.short-message>summary small{display:block}.short-message>summary b{font-size:var(--admin-type-small)}.short-message>summary small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);margin-top:3px}.message-summary-copy{min-width:0}.message-preview{color:color-mix(in oklch, var(--admin-ink) 74%, var(--admin-muted));font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;margin-top:6px;font-style:normal;line-height:1.45;display:block;overflow:hidden}.short-message[open] .message-preview{visibility:hidden}.short-message>summary>i{max-width:130px;color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-micro);text-overflow:ellipsis;white-space:nowrap;border-radius:5px;padding:4px 6px;font-style:normal;overflow:hidden}.role-assistant>summary{border-left:2px solid var(--admin-vital)}.role-user>summary{border-left:2px solid var(--admin-sky)}.role-tool>summary{border-left:2px solid var(--admin-amber)}.role-system>summary{border-left:2px solid var(--admin-muted)}.short-message-body{padding:0 13px 13px 56px}.short-message-body pre,.pinned-context-list pre{color:var(--admin-ink);background:var(--admin-paper);font:var(--admin-type-caption)/1.7 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:8px;margin:0;padding:12px;overflow:auto}.short-message-body>p{color:var(--admin-muted);font:var(--admin-type-micro)/1.5 var(--admin-data);margin:8px 0 0}.message-reasoning,.message-tool-section{margin-top:9px}.message-reasoning>b,.message-tool-section>b{color:var(--admin-muted);font-size:var(--admin-type-micro);align-items:center;gap:5px;margin-bottom:6px;display:flex}.message-reasoning>pre{border-left:2px solid var(--admin-vital);color:var(--admin-muted)}.message-tools{gap:7px;margin-top:8px;display:grid}.tool-exchange{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:8px;overflow:hidden}.tool-exchange>summary{cursor:pointer;justify-content:space-between;align-items:center;gap:8px;min-height:34px;padding:6px 8px;list-style:none;display:flex}.tool-exchange>summary::-webkit-details-marker{display:none}.tool-exchange>summary span{color:var(--admin-vital);font:var(--admin-type-micro) var(--admin-data);align-items:center;gap:5px;display:inline-flex}.tool-exchange>summary small{color:var(--admin-muted);font-size:var(--admin-type-micro)}.tool-exchange>div{grid-template-columns:38px minmax(0,1fr);gap:6px 8px;padding:0 8px 8px;display:grid}.tool-exchange label{color:var(--admin-muted);font-size:var(--admin-type-micro);padding-top:8px}.tool-exchange pre{max-height:240px;font-size:var(--admin-type-micro);margin:0;padding:8px}.pinned-context-list{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.pin-compose{grid-template-columns:minmax(150px,.45fr) minmax(260px,1fr) auto;align-items:stretch;gap:9px;margin-bottom:14px;display:grid}.pin-compose :where(input,textarea){border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);font:var(--admin-type-small)/1.5 var(--admin-body);background:#fff;border-radius:9px;outline:0;padding:9px 12px}.pin-compose textarea{resize:vertical;height:42px}.pin-compose :where(input,textarea):focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.pinned-context-list details{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;min-width:0;overflow:hidden}.pinned-context-list summary{cursor:pointer;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;min-height:64px;padding:10px 12px;list-style:none;display:grid}.pinned-context-list summary>svg:first-child{color:var(--admin-vital)}.pinned-context-list .pin-delete{width:32px;height:32px;color:var(--admin-muted)}.pinned-context-list .pin-delete:hover{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 6%, var(--admin-surface))}.pinned-context-list summary b,.pinned-context-list summary small{display:block}.pinned-context-list summary b{font-size:var(--admin-type-small)}.pinned-context-list summary small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.pinned-context-list details>pre{margin:0 12px 12px}.pinned-context-list details>p{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);overflow-wrap:anywhere;align-items:center;gap:5px;margin:0 12px 12px;display:flex}.memory-maintenance .mild{grid-template-columns:auto minmax(0,1fr) 76px auto}.memory-panel{overflow:hidden}.memory-search-stage{border:1px solid color-mix(in oklch, var(--admin-vital) 25%, var(--admin-line));background:linear-gradient(118deg, var(--admin-soft), var(--admin-surface) 62%);border-radius:15px;grid-template-columns:auto minmax(210px,.7fr) minmax(320px,1.3fr);align-items:center;gap:18px;margin:-2px 0 22px;padding:24px;display:grid;position:relative;overflow:hidden}.memory-search-stage:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";pointer-events:none;border-radius:44% 56% 48% 52%;width:260px;height:260px;position:absolute;bottom:-126px;right:-72px;transform:rotate(22deg)}.memory-search-mark{border:1px solid color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line));width:58px;height:58px;color:var(--admin-vital);background:var(--admin-surface);box-shadow:0 10px 30px color-mix(in oklch, var(--admin-vital) 10%, transparent);border-radius:50%;place-items:center;display:grid;position:relative}.memory-search-mark i{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, transparent);border-radius:44% 56% 53% 47%;position:absolute;inset:-6px}.memory-search-mark i:last-child{opacity:.65;border-style:dashed;inset:-12px;transform:rotate(28deg)}.memory-search-copy{min-width:0}.memory-search-copy>span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.13em}.memory-search-copy h3{font:680 20px var(--admin-display);letter-spacing:-.025em;margin:5px 0 4px}.memory-search-copy p{color:var(--admin-muted);margin:0;font-size:10px;line-height:1.6}.memory-query{z-index:1;border:1px solid color-mix(in oklch, var(--admin-ink) 16%, var(--admin-surface));min-height:52px;color:var(--admin-muted);background:var(--admin-surface);box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 7%, transparent);border-radius:12px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;padding:5px 5px 5px 15px;transition:border-color .16s,box-shadow .16s;display:grid;position:relative}.memory-query:focus-within{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line));box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 7%, transparent)}.memory-query input{min-width:0;height:40px;color:var(--admin-ink);background:0 0;border:0;outline:0;padding:0 11px;font-size:12px}.memory-query input:focus-visible{outline:0}.memory-query-clear{width:30px;height:30px;color:var(--admin-muted);background:0 0;border:0;border-radius:7px;place-items:center;padding:0;display:grid}.memory-query-clear:hover{color:var(--admin-ink);background:var(--admin-paper)}.memory-query-submit{color:#fff;background:var(--admin-ink);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:5px;min-height:40px;padding:0 13px;font-size:10px;font-weight:680;display:flex}.memory-query-submit:disabled{cursor:not-allowed;opacity:.45}.memory-examples{z-index:1;flex-wrap:wrap;grid-column:3;align-items:center;gap:6px;margin-top:-9px;display:flex}.memory-examples span{color:var(--admin-muted);margin-right:2px;font-size:9px}.memory-examples button{border:1px solid var(--admin-line);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 78%, transparent);border-radius:99px;padding:4px 8px;font-size:9px}.memory-examples button:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line))}.memory-notice{margin-bottom:14px}.memory-result-head{color:var(--admin-muted);justify-content:space-between;align-items:center;gap:14px;margin:0 2px 10px;display:flex}.memory-result-head>div{align-items:center;gap:7px;min-width:0;font-size:10px;display:flex}.memory-result-head>div span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-result-head b{color:var(--admin-vital);font:680 10px var(--admin-data);flex:none}.memory-results{grid-template-columns:1fr 1fr;gap:11px;display:grid}.memory-results article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;grid-template-columns:36px minmax(0,1fr);min-height:190px;transition:border-color .16s,transform .16s,box-shadow .16s;display:grid;overflow:hidden}.memory-results article:hover{border-color:color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line));box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 6%, transparent);transform:translateY(-1px)}.memory-results article.editing{border-color:color-mix(in oklch, var(--admin-vital) 56%, var(--admin-line));box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 7%, transparent)}.memory-rank{border-right:1px solid var(--admin-line);color:color-mix(in oklch, var(--admin-muted) 55%, var(--admin-surface));background:color-mix(in oklch, var(--admin-paper) 58%, var(--admin-surface));font:680 9px var(--admin-data);text-align:center;padding-top:18px}.memory-card-body{flex-direction:column;min-width:0;padding:16px;display:flex}.memory-results article header,.memory-results article footer{align-items:center;gap:8px;display:flex}.memory-results article header{color:var(--admin-muted);font:680 9px var(--admin-data);letter-spacing:.05em;text-transform:uppercase;justify-content:space-between}.memory-results article header>span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-results article p{color:color-mix(in oklch, var(--admin-ink) 94%, var(--admin-muted));white-space:pre-line;flex:1;margin:15px 0 18px;font-size:13px;line-height:1.75}.memory-score{flex:none;align-items:center;gap:7px;display:flex}.memory-score>i{background:var(--admin-paper);border-radius:99px;width:42px;height:3px;overflow:hidden}.memory-score>i>b{border-radius:inherit;background:var(--admin-vital);height:100%;display:block}.memory-score small{font:9px var(--admin-data);letter-spacing:0;text-transform:none}.memory-id{max-width:110px;font:9px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-results article footer{border-top:1px solid var(--admin-line);justify-content:space-between;margin-top:auto;padding-top:11px}.memory-tags{flex-wrap:wrap;gap:5px;min-width:0;display:flex}.memory-tags>i{color:var(--admin-muted);background:var(--admin-paper);border-radius:99px;padding:4px 7px;font-size:9px;font-style:normal}.memory-tags>span{color:color-mix(in oklch, var(--admin-muted) 65%, var(--admin-surface));font-size:9px}.memory-actions{flex:none;align-items:center;gap:5px;display:flex}.memory-actions .ghost{align-items:center;gap:5px;display:flex}.memory-actions .danger-icon{width:30px;height:30px}.memory-editor{flex:1;gap:10px;margin:12px 0;display:grid}.memory-editor label{gap:6px;display:grid}.memory-editor label>span{color:var(--admin-vital);font-size:9px;font-weight:680}.memory-edit{border:1px solid var(--admin-vital);width:100%;min-height:112px;color:var(--admin-ink);background:var(--admin-surface);font:12px/1.7 var(--admin-body);resize:vertical;border-radius:9px;outline:0;padding:11px}.memory-edit:focus{border-color:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));box-shadow:none}.memory-edit:focus-visible{outline:0}.memory-tag-edit{border:1px solid var(--admin-line);width:100%;min-height:38px;color:var(--admin-ink);background:var(--admin-surface);border-radius:8px;outline:0;padding:8px 10px;font-size:11px}.memory-tag-edit:focus{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line))}.memory-tag-edit:focus-visible{outline:0}.memory-recalling,.memory-empty{border:1px dashed var(--admin-line);min-height:210px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-paper) 38%, var(--admin-surface));text-align:center;border-radius:12px;align-content:center;place-items:center;gap:8px;padding:28px;display:grid}.memory-recalling{grid-template-columns:auto auto}.memory-empty svg{color:color-mix(in oklch, var(--admin-vital) 60%, var(--admin-muted))}.memory-empty b{color:var(--admin-ink);font-size:13px}.memory-empty p{max-width:430px;margin:0;font-size:10px;line-height:1.65}.memory-empty.searched{background:var(--admin-surface)}.memory-footnote{color:var(--admin-muted);justify-content:flex-end;align-items:center;gap:6px;margin:13px 2px 0;font-size:9px;display:flex}.memory-footnote svg{color:var(--admin-vital)}.log-table{border:1px solid var(--admin-line);max-height:600px;font:11px/1.45 var(--admin-data);border-radius:11px;display:grid;overflow:auto}.log-table>div{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:84px 122px minmax(0,1fr);gap:12px;padding:10px 12px;display:grid}.log-table>div:last-child{border-bottom:0}.log-table>div:hover{background:var(--admin-soft)}.log-table>.log-head{z-index:2;color:color-mix(in oklch, var(--admin-paper) 84%, var(--admin-ink));background:var(--admin-ink);letter-spacing:.08em;font-size:9px;position:sticky;top:0}.log-table>.log-head:hover{background:var(--admin-ink)}.log-table time{color:var(--admin-muted);font-variant-numeric:tabular-nums}.event-type{color:var(--admin-vital)}.log-table code{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.history-navigator{border:1px solid color-mix(in oklch, var(--admin-vital) 20%, var(--admin-line));background:linear-gradient(100deg, var(--admin-soft), color-mix(in oklch, var(--admin-surface) 92%, var(--admin-soft)));border-radius:11px;justify-content:space-between;align-items:center;gap:14px;margin-bottom:12px;padding:11px 13px;display:flex}.history-position{align-items:center;gap:10px;min-width:0;display:flex}.history-position b,.history-position small{display:block}.history-position b{color:var(--admin-ink);font-size:11px}.history-detail-line{align-items:center;gap:8px;min-width:0;margin-top:3px;display:flex}.history-position small{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.history-sequence-total{border-left:1px solid color-mix(in oklch, var(--admin-vital) 26%, var(--admin-line));color:color-mix(in oklch, var(--admin-vital) 76%, var(--admin-ink));font:680 9px var(--admin-data);font-variant-numeric:tabular-nums;white-space:nowrap;flex:none;padding-left:8px}.history-marker{border:1px solid color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));width:30px;height:30px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;flex:none;place-items:center;display:grid;position:relative}.history-marker:after{background:color-mix(in oklch, var(--admin-vital) 34%, transparent);content:"";width:1px;height:14px;position:absolute;top:100%;left:50%}.history-marker.earlier{color:var(--admin-amber);border-color:color-mix(in oklch, var(--admin-amber) 38%, var(--admin-line))}.history-actions{flex:none;gap:6px;display:flex}.history-actions button{align-items:center;gap:4px;display:inline-flex}.history-log-filters{flex-wrap:wrap;justify-content:flex-end;max-width:100%}.sequence-range{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:color-mix(in oklch, var(--admin-soft) 62%, var(--admin-surface));border-radius:10px;align-items:center;gap:5px;min-height:42px;padding:3px 4px 3px 8px;display:flex}.sequence-range label{align-items:center;gap:5px;min-width:0;display:flex}.sequence-range label>span{color:var(--admin-muted);font:680 8px var(--admin-data);letter-spacing:.04em;white-space:nowrap}.sequence-range input{width:86px;min-height:32px;font:11px var(--admin-data);font-variant-numeric:tabular-nums;border-radius:6px;flex:0 86px;padding:5px 8px}.sequence-separator{color:color-mix(in oklch, var(--admin-vital) 66%, var(--admin-muted));font:700 13px var(--admin-data)}.sequence-locate{white-space:nowrap;min-height:32px;padding-inline:9px}.history-sequence-error{margin-bottom:12px}.lifecycle-log-table{max-height:min(680px,100dvh - 310px)}.lifecycle-log-table>.log-head{grid-template-columns:142px 138px minmax(0,1fr) 70px}.lifecycle-log-table>article{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:142px 138px minmax(0,1fr) auto;align-items:start;gap:8px 12px;padding:10px 12px;transition:background .16s;display:grid}.lifecycle-log-table>article:hover,.lifecycle-log-table>article.open{background:color-mix(in oklch, var(--admin-soft) 64%, var(--admin-surface))}.lifecycle-log-table>article:last-child{border-bottom:0}.lifecycle-log-table>article>time{padding-top:2px}.lifecycle-log-table .event-type{text-overflow:ellipsis;white-space:nowrap;padding-top:2px;font-size:10px;overflow:hidden}.interaction-row-copy{min-width:0}.interaction-row-copy code{display:block}.interaction-row-copy small{color:var(--admin-muted);font:9px/1.35 var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;display:block;overflow:hidden}.interaction-detail-toggle{align-self:start;min-width:52px}.interaction-detail{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, var(--admin-line));background:color-mix(in oklch, var(--admin-paper) 50%, var(--admin-surface));border-radius:8px;grid-column:1/-1;margin-top:1px;padding:10px}.interaction-detail pre{max-height:360px;color:color-mix(in oklch, var(--admin-paper) 90%, var(--admin-ink));background:var(--admin-ink);font:10px/1.55 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:6px;margin:0;padding:11px;overflow:auto}.interaction-detail>small{color:var(--admin-amber);margin-top:8px;font-size:9px;display:block}.history-empty{grid-column:1/-1}.history-empty .empty-state{min-height:210px}.danger-list{gap:9px;display:grid}.danger-card{border:1px solid color-mix(in oklch, var(--admin-red) 30%, var(--admin-line));color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface));border-radius:12px;align-items:center;gap:14px;padding:16px;display:flex}.danger-card.mild{color:var(--admin-ink);border-color:var(--admin-line);background:var(--admin-surface)}.danger-card>div{flex:1;min-width:0}.danger-card b{font-size:13px}.danger-card p{color:var(--admin-muted);margin:5px 0 0;font-size:11px;line-height:1.55}.danger-card small{color:var(--admin-vital);margin-top:6px;font-size:10px;display:block}.tiny-input{width:76px}.modal-layer{z-index:100;background:color-mix(in oklch, var(--admin-ink) 58%, transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);place-items:center;padding:20px;display:grid;position:fixed;inset:0}.confirm-modal{border:1px solid color-mix(in oklch, var(--admin-red) 24%, var(--admin-line));width:min(440px,100%);color:var(--admin-ink);background:var(--admin-surface);border-radius:17px;padding:29px;box-shadow:0 30px 90px #0004}.confirm-modal>svg{color:var(--admin-red)}.confirm-modal h3{font:660 20px var(--admin-display);margin:14px 0 7px}.confirm-modal>p{color:var(--admin-muted);margin:0 0 21px;font-size:12px;line-height:1.65}.identity-form{grid-template-columns:1fr 1fr;gap:16px;display:grid}.identity-form .field:nth-child(n+3){grid-column:1/-1}.identity-form textarea{min-height:140px}.system-prompt-facts{align-items:center;gap:9px;margin-bottom:12px;display:flex}.system-prompt-facts span,.system-prompt-facts em{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font:11px var(--admin-data);border-radius:9px;align-items:baseline;gap:5px;padding:6px 9px;font-style:normal;display:inline-flex}.system-prompt-facts b{color:var(--admin-ink);font-size:13px}.system-prompt-facts em{color:var(--admin-vital);margin-left:auto}.system-prompt-preview{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:13px;overflow:hidden}.system-prompt-preview summary{color:var(--admin-ink);cursor:pointer;grid-template-columns:auto 1fr auto;align-items:center;gap:9px;padding:13px 15px;list-style:none;display:grid}.system-prompt-preview summary::-webkit-details-marker{display:none}.system-prompt-preview summary svg{color:var(--admin-vital)}.system-prompt-preview summary span{font-size:13px;font-weight:680}.system-prompt-preview summary small{color:var(--admin-muted);font-size:11px}.system-prompt-preview[open] summary{border-bottom:1px solid var(--admin-line)}.system-prompt-preview pre{max-height:620px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-surface) 70%, var(--admin-paper));font:12px/1.7 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text;margin:0;padding:18px;overflow:auto}.content-workspace{gap:14px;display:grid}.capability-strip{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:repeat(3,1fr);display:grid;overflow:hidden}.capability-strip button{border:0;border-right:1px solid var(--admin-line);min-height:78px;color:var(--admin-muted);text-align:left;background:0 0;grid-template-columns:1fr auto;gap:4px 12px;padding:15px 17px;display:grid;position:relative}.capability-strip button:last-child{border-right:0}.capability-strip button:after{background:var(--admin-vital);content:"";transform-origin:0;height:3px;transition:transform .2s;position:absolute;bottom:0;left:0;right:0;transform:scaleX(0)}.capability-strip button:hover{background:var(--admin-paper)}.capability-strip button.active{color:var(--admin-ink);background:linear-gradient(115deg, var(--admin-soft), var(--admin-surface) 72%)}.capability-strip button.active:after{transform:scaleX(1)}.capability-strip span{font-size:15px;font-weight:720}.capability-strip b{color:var(--admin-vital);font:680 16px var(--admin-data)}.capability-strip small{grid-column:1/-1;font-size:12px;font-weight:450;line-height:1.45}.content-layout{grid-template-columns:310px minmax(0,1fr);align-items:stretch;gap:14px;display:grid}.content-layout:has(.content-files){grid-template-columns:278px 218px minmax(0,1fr)}.content-index{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:15px;flex-direction:column;min-height:720px;padding:11px;display:flex}.content-index-head{justify-content:space-between;align-items:center;padding:3px 3px 10px;display:flex}.content-index-head span,.content-index-head b{display:block}.content-index-head span{color:var(--admin-muted);font:10px var(--admin-data);letter-spacing:.09em;text-transform:uppercase}.content-index-head b{margin-top:5px;font-size:14px}.content-index-head .icon-btn{width:32px;height:32px}.content-search{border:1px solid var(--admin-line);min-height:38px;color:var(--admin-muted);background:var(--admin-paper);border-radius:9px;align-items:center;gap:8px;padding:0 10px;display:flex}.content-search:focus-within{border-color:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 9%, transparent)}.content-search input{width:100%;min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;font-size:13px}.content-items{scrollbar-width:thin;flex:1;align-content:start;gap:3px;min-height:0;margin:10px -3px 10px 0;padding-right:4px;display:grid;overflow:auto}.content-items button{width:100%;min-height:70px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:9px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;display:grid}.content-items button:hover{color:var(--admin-ink);background:var(--admin-paper)}.content-items button.active{color:var(--admin-vital);background:var(--admin-soft)}.content-health{background:var(--admin-red);width:7px;height:7px;box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-red) 10%, transparent);border-radius:50%}.content-health.valid{background:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.content-item-copy{min-width:0}.content-item-copy b{color:var(--admin-ink);text-overflow:ellipsis;white-space:nowrap;font-size:13px;display:block;overflow:hidden}.content-item-copy small{-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:5px;font-size:11px;font-weight:450;line-height:1.45;display:-webkit-box;overflow:hidden}.content-no-result{min-height:130px;color:var(--admin-muted);place-items:center;font-size:12px;display:grid}.new-content{border:1px dashed var(--admin-line);width:100%;min-height:44px;color:var(--admin-muted);background:0 0;border-radius:9px;justify-content:center;align-items:center;gap:7px;margin-top:auto;font-size:12px;display:flex}.new-content:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));background:var(--admin-soft)}.content-files{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:15px;flex-direction:column;min-width:0;min-height:720px;display:flex;overflow:hidden}.content-files>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:center;min-height:54px;padding:8px 9px 8px 13px;display:flex}.content-files>header div{min-width:0;color:var(--admin-vital);align-items:center;gap:7px;display:flex}.content-files>header span{color:var(--admin-ink);font:680 12px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.content-files>header .icon-btn{width:32px;height:32px}.file-create{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);gap:5px;padding:8px;display:flex}.file-create input{border:1px solid var(--admin-line);width:100%;min-width:0;min-height:36px;font:11px var(--admin-data);border-radius:7px;outline:0;padding:0 8px}.file-create input:focus{border-color:var(--admin-vital)}.file-create button{color:#fff;background:var(--admin-vital);border:0;border-radius:7px;place-items:center;width:34px;display:grid}.file-create button:disabled{opacity:.35}.file-tree{flex:1;align-content:start;gap:2px;padding:7px;display:grid;overflow:auto}.file-tree button{min-height:54px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:8px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 9px;display:grid}.file-tree button:hover:not(:disabled){color:var(--admin-ink);background:var(--admin-surface)}.file-tree button.active{color:var(--admin-vital);background:var(--admin-soft)}.file-tree button:disabled{cursor:not-allowed;opacity:.5}.file-tree button>span{min-width:0}.file-tree b,.file-tree small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.file-tree b{color:var(--admin-ink);font:11px var(--admin-data)}.file-tree small{font:10px var(--admin-data);margin-top:4px}.file-tree i{color:var(--admin-vital);background:var(--admin-surface);font:9px var(--admin-data);border-radius:4px;padding:3px 5px;font-style:normal}.content-files>footer{border-top:1px solid var(--admin-line);color:var(--admin-muted);text-align:center;padding:10px;font-size:10px;line-height:1.45}.content-editor{flex-direction:column;min-width:0;min-height:720px;display:flex}.editor-file{align-items:center;gap:7px;min-width:0;display:flex}.field input.invalid{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 52%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 3%, white)}.source-create-card{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:linear-gradient(112deg, var(--admin-soft), var(--admin-surface) 62%);border-radius:11px;grid-template-columns:auto minmax(0,1fr) minmax(220px,300px);align-items:center;gap:14px;margin-bottom:12px;padding:14px 16px;display:grid}.source-create-mark{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));width:42px;height:42px;color:var(--admin-vital);background:var(--admin-surface);border-radius:9px;place-items:center;display:grid}.source-create-card>div:nth-child(2)>span{color:var(--admin-vital);font:680 8px var(--admin-data);letter-spacing:.12em}.source-create-card h3{font:16px var(--admin-display);margin:4px 0 2px}.source-create-card p{color:var(--admin-muted);margin:0;font-size:9px;line-height:1.5}.source-create-card label{grid-template-columns:auto minmax(0,1fr);align-items:center;gap:4px 8px;display:grid}.source-create-card label>span{color:var(--admin-muted);font-size:9px;font-weight:680}.source-create-card input{border:1px solid var(--admin-line);min-width:0;min-height:38px;color:var(--admin-ink);font:12px var(--admin-data);background:#fff;border-radius:8px;outline:0;padding:0 10px}.source-create-card input:focus{border-color:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 9%, transparent)}.source-create-card input.invalid{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 52%, var(--admin-line))}.source-create-card small{color:var(--admin-muted);grid-column:2;font-size:8px}.source-workbench{border:1px solid color-mix(in oklch, var(--admin-ink) 22%, var(--admin-line));background:var(--admin-surface);border-radius:11px;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden;box-shadow:inset 0 1px #fff}.source-toolbar{border-bottom:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-paper) 70%, var(--admin-surface));justify-content:space-between;align-items:center;gap:14px;min-height:46px;padding:0 13px;display:flex}.editor-file>svg{color:var(--admin-vital);flex:none}.editor-file code{min-width:0;color:var(--admin-muted);font:11px var(--admin-data);white-space:nowrap;display:flex}.editor-file code b{max-width:220px;color:var(--admin-ink);text-overflow:ellipsis;font-weight:650;overflow:hidden}.editor-file code i{color:color-mix(in oklch, var(--admin-muted) 48%, transparent);padding:0 2px;font-style:normal}.source-status{border:1px solid color-mix(in oklch, var(--admin-muted) 55%, transparent);background:var(--admin-surface);border-radius:50%;flex:none;width:7px;height:7px}.source-status.dirty{border-color:var(--admin-amber);background:var(--admin-amber);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-amber) 10%, transparent)}.source-readout{white-space:nowrap;align-items:center;gap:5px;display:flex}.source-readout span,.source-readout b{font:8px var(--admin-data);border-radius:5px;padding:4px 6px}.source-readout span{color:var(--admin-vital);background:var(--admin-soft)}.source-readout b{color:var(--admin-muted);font-weight:500}.source-schema{border-bottom:1px solid var(--admin-line);min-height:38px;color:var(--admin-muted);background:var(--admin-surface);align-items:center;gap:7px;padding:0 14px;display:flex}.source-schema span{font-size:8px;font-weight:680}.source-schema code{color:var(--admin-vital);font:9px var(--admin-data)}.source-schema>i{background:var(--admin-line);width:1px;height:13px;margin:0 2px}.source-schema p{text-overflow:ellipsis;white-space:nowrap;margin:0;font-size:9px;overflow:hidden}.source-schema kbd{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font:8px var(--admin-data);white-space:nowrap;border-bottom-width:2px;border-radius:4px;margin-left:auto;padding:3px 6px}.source-editor{width:100%;min-height:510px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-paper) 35%, var(--admin-surface));caret-color:var(--admin-vital);font:13px/1.78 var(--admin-data);resize:vertical;tab-size:2;border:0;outline:0;flex:1;padding:22px 24px}.source-editor:focus{background:var(--admin-surface);box-shadow:inset 3px 0 var(--admin-vital)}.content-editor>.panel-actions{z-index:4;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-radius:12px;margin:18px -8px -8px;padding:10px;position:sticky;bottom:20px}.save-state{color:var(--admin-muted);margin-right:auto;font-size:11px}.save-state.dirty{color:color-mix(in oklch, var(--admin-amber) 74%, var(--admin-ink))}.content-welcome{min-height:540px;color:var(--admin-muted);text-align:center;flex:1;align-content:center;place-items:center;display:grid}.content-welcome h3{color:var(--admin-ink);font:22px var(--admin-display);margin:18px 0 5px}.content-welcome p{max-width:380px;margin:0 0 20px;font-size:11px;line-height:1.65}.welcome-orbit{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));width:72px;height:72px;color:var(--admin-vital);background:var(--admin-soft);border-radius:50%;place-items:center;display:grid;position:relative}.welcome-orbit i{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, transparent);border-radius:50%;width:88px;height:42px;position:absolute;transform:rotate(24deg)}.welcome-orbit i:last-child{transform:rotate(-24deg)}.audit-workspace{gap:14px;display:grid}.audit-vitals{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:repeat(4,minmax(0,1fr));display:grid;overflow:hidden}.audit-vitals article{border-right:1px solid var(--admin-line);grid-template-columns:auto 1fr auto;align-items:center;gap:6px 9px;min-height:92px;padding:15px 17px;display:grid}.audit-vitals article:last-child{border-right:0}.audit-vitals svg{color:var(--admin-vital)}.audit-vitals article.alert svg,.audit-vitals article.alert b{color:var(--admin-red)}.audit-vitals span{color:var(--admin-muted);font-size:10px}.audit-vitals b{color:var(--admin-ink);font:680 22px var(--admin-data)}.audit-vitals small{color:var(--admin-muted);grid-column:2/-1;font-size:9px}.audit-switcher{justify-content:space-between;align-items:center;gap:12px;display:flex}.audit-switcher>div{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;gap:4px;padding:4px;display:flex}.audit-switcher>div button{min-height:34px;color:var(--admin-muted);background:0 0;border:0;border-radius:7px;align-items:center;gap:7px;padding:0 12px;font-size:10px;display:flex}.audit-switcher>div button.active{color:var(--admin-vital);background:var(--admin-soft);font-weight:680}.audit-filters{grid-template-columns:minmax(220px,1fr) 130px auto;align-items:center;gap:9px;margin-bottom:14px;display:grid}.audit-filters label{border:1px solid var(--admin-line);min-height:40px;color:var(--admin-muted);background:var(--admin-paper);border-radius:9px;align-items:center;gap:7px;padding:0 10px;display:flex}.audit-filters label:focus-within{border-color:var(--admin-vital)}.audit-filters input{width:100%;min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;font-size:11px}.audit-filters select{border:1px solid var(--admin-line);min-height:40px;color:var(--admin-ink);background:var(--admin-surface);border-radius:9px;padding:0 9px;font-size:10px}.audit-filters>span{color:var(--admin-muted);font:9px var(--admin-data);white-space:nowrap}.audit-timeline{max-height:650px;padding-right:5px;overflow:auto}.audit-timeline article{grid-template-columns:18px 105px minmax(0,1fr);gap:10px;min-height:82px;display:grid}.audit-rail{justify-content:center;display:flex;position:relative}.audit-rail:after{background:var(--admin-line);content:"";width:1px;position:absolute;top:22px;bottom:-4px}.audit-timeline article:last-child .audit-rail:after{display:none}.audit-rail i{z-index:1;border:2px solid var(--admin-surface);background:var(--admin-vital);width:9px;height:9px;box-shadow:0 0 0 2px color-mix(in oklch, var(--admin-vital) 20%, var(--admin-line));border-radius:50%;margin-top:17px;position:relative}.audit-timeline article.failed .audit-rail i{background:var(--admin-red);box-shadow:0 0 0 2px color-mix(in oklch, var(--admin-red) 20%, var(--admin-line))}.audit-timeline time{padding-top:11px}.audit-timeline time b,.audit-timeline time span{display:block}.audit-timeline time b{color:var(--admin-ink);font:650 12px var(--admin-data)}.audit-timeline time span{color:var(--admin-muted);font:8px var(--admin-data);margin-top:3px}.audit-event{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;margin-bottom:8px;padding:11px 13px}.audit-event header{align-items:center;gap:7px;display:flex}.audit-event header span{color:var(--admin-vital);background:var(--admin-soft);font:8px var(--admin-data);text-transform:uppercase;border-radius:4px;padding:3px 5px}.audit-event header b{font-size:11px}.audit-event header i{color:var(--admin-vital);margin-left:auto;font-size:9px;font-style:normal}.audit-timeline article.failed .audit-event header i{color:var(--admin-red)}.audit-event>code{color:var(--admin-ink);font:9px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:7px;display:block;overflow:hidden}.audit-event p{color:var(--admin-muted);margin:7px 0 0;font-size:10px;line-height:1.5}.audit-event footer{color:var(--admin-muted);font:8px var(--admin-data);margin-top:7px}.runtime-callout{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));color:var(--admin-vital);background:var(--admin-soft);border-radius:11px;align-items:center;gap:12px;padding:14px;display:flex}.runtime-callout b,.runtime-callout span{display:block}.runtime-callout b{color:var(--admin-ink);font-size:12px}.runtime-callout span{color:var(--admin-muted);margin-top:4px;font-size:9px}.runtime-task-grid{grid-template-columns:1fr 1fr;gap:8px;margin-top:11px;display:grid}.runtime-task-grid details{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;min-width:0}.runtime-task-grid details.current{border-color:color-mix(in oklch, var(--admin-amber) 35%, var(--admin-line))}.runtime-task-grid summary{cursor:pointer;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:12px;display:grid}.task-signal{background:var(--admin-soft);border-radius:50%;place-items:center;width:18px;height:18px;display:grid}.task-signal i{background:var(--admin-vital);border-radius:50%;width:6px;height:6px}.runtime-task-grid details.done .task-signal i{background:var(--admin-muted)}.runtime-task-grid summary b,.runtime-task-grid summary code{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.runtime-task-grid summary b{font-size:10px}.runtime-task-grid summary code{color:var(--admin-muted);font:8px var(--admin-data);margin-top:3px}.task-state{color:var(--admin-vital);background:var(--admin-soft);white-space:nowrap;border-radius:5px;padding:4px 6px;font-size:8px}.task-waiting{border-top:1px solid var(--admin-line);background:var(--admin-paper);padding:12px}.task-waiting>span{color:var(--admin-muted);font-size:8px;display:block}.task-waiting>code{overflow-wrap:anywhere;color:var(--admin-ink);font:9px/1.55 var(--admin-data);margin-top:5px;display:block}.task-waiting pre{max-height:230px;color:color-mix(in oklch, var(--admin-paper) 86%, var(--admin-ink));background:var(--admin-ink);font:8px/1.55 var(--admin-data);border-radius:7px;margin:10px 0 0;padding:10px;overflow:auto}.log-table,.content-items,.file-tree,.source-editor,.audit-timeline,.task-waiting pre{overscroll-behavior:contain;scrollbar-gutter:stable}.admin-shell small{font-size:var(--admin-type-caption);line-height:1.55}.admin-shell button{font-size:var(--admin-type-small);line-height:1.35}.admin-shell :where(input,select,textarea){font-size:var(--admin-type-body);line-height:1.55}.admin-shell :where(code,pre,kbd,time){font-variant-numeric:tabular-nums}.admin-shell :where(p,small){text-wrap:pretty}.admin-brand span,.sidebar-foot>span,.sidebar-foot button,.topbar-title .eyebrow,.shell-life>span,.live-badge,.presence-readout span,.pulse-organ small,.config-section-heading small,.provider-source-note span,.provider-source-note code,.provider-row .field small,.admin-security-hero p,.admin-setting-cards span,.admin-setting-cards small,.admin-security-note b,.admin-security-note span,.admin-security-note code,.record small,.task-filters button,.memory-results article header,.memory-results article footer>i,.danger-card small,.content-index-head span,.file-tree small,.content-files>footer,.source-create-card p,.source-create-card label>span,.source-schema p,.audit-vitals span,.audit-vitals small,.audit-switcher>div button,.audit-filters select,.audit-filters>span,.audit-event>code,.audit-event p,.runtime-callout span,.runtime-task-grid summary b,.runtime-task-grid summary code,.task-waiting>code,.task-waiting pre{font-size:var(--admin-type-caption)}.effect-badge,.llm-config-copy span,.admin-security-hero>div:nth-child(2)>span,.admin-security-hero>b,.status-pill,.alarm-time strong,.alarm-due,.file-tree i,.source-create-card>div:nth-child(2)>span,.source-create-card small,.source-readout span,.source-readout b,.source-schema span,.source-schema code,.source-schema kbd,.audit-timeline time span,.audit-event header span,.audit-event header i,.audit-event footer,.task-state,.task-waiting>span{font-size:var(--admin-type-micro);line-height:1.45}.admin-panel>header p,.field small,.topbar-actions>a,.vital>span,.memory-meter span,.runtime-clock span,.active-model span,.llm-config-copy p,.provider-source-note b,.provider-source-note p,.provider-row .field>span,.provider-empty,.llm-config-facts span,.record-details,.alarm-summary,.danger-card p,.capability-strip small,.content-item-copy small,.log-table,.log-table>.log-head,.save-state,.content-welcome p,.audit-filters input,.audit-event header b,.audit-event p,.runtime-callout span{font-size:var(--admin-type-small);line-height:1.65}.audit-vitals article{min-height:104px;padding:18px 20px}.audit-vitals span{font-size:var(--admin-type-small);line-height:1.45}.audit-vitals b{font-size:clamp(24px,22px + .22vw,30px);line-height:1}.audit-vitals small{font-size:var(--admin-type-caption);line-height:1.5}.audit-switcher>div button,.audit-filters input,.audit-filters select{font-size:var(--admin-type-small)}.audit-filters>span{font-size:var(--admin-type-caption)}.audit-timeline article{min-height:94px}.audit-timeline time b{font-size:var(--admin-type-body);line-height:1.25}.audit-timeline time span{font-size:var(--admin-type-caption);line-height:1.45}.audit-event{padding:14px 16px}.audit-event header span{font-size:var(--admin-type-micro)}.audit-event header b{font-size:var(--admin-type-body);line-height:1.45}.audit-event header i{font-size:var(--admin-type-caption)}.audit-event>code{font-size:var(--admin-type-small);line-height:1.55}.audit-event p{font-size:var(--admin-type-body);line-height:1.7}.audit-event footer{font-size:var(--admin-type-caption);line-height:1.5}.runtime-callout b{font-size:var(--admin-type-body);line-height:1.45}.runtime-callout span{font-size:var(--admin-type-small);line-height:1.6}.runtime-task-grid summary{padding:15px}.runtime-task-grid summary b{font-size:var(--admin-type-body);line-height:1.45}.runtime-task-grid summary code{font-size:var(--admin-type-caption);line-height:1.5}.task-state{font-size:var(--admin-type-caption);line-height:1.35}.task-waiting{padding:15px}.task-waiting>span{font-size:var(--admin-type-caption)}.task-waiting>code{font-size:var(--admin-type-small);line-height:1.65}.task-waiting pre{font-size:var(--admin-type-caption);line-height:1.65}.release-page{max-width:1440px;margin:0 auto}.release-hero{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:linear-gradient(110deg, var(--admin-soft), var(--admin-surface) 62%), var(--admin-surface);border-radius:17px;grid-template-columns:auto minmax(0,1fr) minmax(270px,auto);align-items:center;gap:20px;min-height:150px;padding:25px 28px;display:grid;position:relative;overflow:hidden}.release-hero:after{border:1px solid color-mix(in oklch, var(--admin-vital) 14%, transparent);content:"";pointer-events:none;border-radius:50%;width:240px;height:240px;position:absolute;top:-100px;right:19%}.release-hero.empty{border-style:dashed}.release-signal{border:1px solid color-mix(in oklch, var(--admin-vital) 32%, var(--admin-line));width:68px;height:68px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;place-items:center;display:grid;position:relative}.release-signal i{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, transparent);border-radius:43% 57% 52% 48%;animation:8s linear infinite admin-orbit;position:absolute;inset:7px}.release-signal i:last-child{animation-duration:5s;animation-direction:reverse;inset:15px}.release-hero h2{font:660 clamp(21px, 2vw, 28px)/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.release-hero>div:nth-child(2)>p:last-child{max-width:620px;color:var(--admin-muted);font-size:var(--admin-type-small);margin:8px 0 0;line-height:1.65}.release-hero-platforms{z-index:1;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 90%, transparent);border-radius:11px;min-width:250px;padding:16px;position:relative}.release-hero-platforms>span{color:var(--admin-vital);font:680 var(--admin-type-micro) var(--admin-data);letter-spacing:.1em}.release-hero-platforms>div{flex-wrap:wrap;gap:5px;min-height:25px;margin-top:9px;display:flex}.release-hero-platforms b{color:var(--admin-ink);background:var(--admin-soft);font:610 var(--admin-type-caption) var(--admin-data);border-radius:5px;padding:5px 7px}.release-hero-platforms small{color:var(--admin-muted)}.release-hero-platforms time{color:var(--admin-muted);font:var(--admin-type-caption) var(--admin-data);margin-top:9px;display:block}.release-fleet{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:minmax(190px,.36fr) minmax(0,1fr);display:grid;overflow:hidden}.release-fleet>header{border-right:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:flex-start;gap:12px;padding:19px 18px;display:flex}.release-fleet h3{font:650 var(--admin-type-body) var(--admin-display);margin:3px 0 0}.release-fleet .icon-btn:disabled{opacity:.45}.release-fleet-state{min-height:88px;color:var(--admin-muted);font-size:var(--admin-type-small);align-items:center;gap:10px;padding:18px 22px;display:flex}.release-fleet-state svg{color:var(--admin-vital);flex:none}.release-fleet-state span b,.release-fleet-state span{display:block}.release-fleet-state span b{color:var(--admin-ink);margin-bottom:3px}.release-fleet-state.error svg{color:var(--admin-danger)}.release-fleet-state .spin{animation:1.2s linear infinite admin-spin}.release-fleet-body{grid-template-columns:minmax(250px,.55fr) minmax(340px,1fr);min-width:0;display:grid}.release-fleet-summary{border-right:1px solid var(--admin-line);grid-template-columns:repeat(3,minmax(0,1fr));align-items:stretch;display:grid}.release-fleet-summary span{min-width:0;color:var(--admin-muted);font-size:var(--admin-type-micro);text-align:center;flex-direction:column;justify-content:center;padding:16px 13px;display:flex}.release-fleet-summary span+span{border-left:1px solid var(--admin-line)}.release-fleet-summary b{color:var(--admin-ink);font:670 21px/1.1 var(--admin-data);margin-bottom:2px}.release-fleet-summary .online b{color:var(--admin-vital)}.release-fleet-summary .outdated b{color:var(--admin-amber)}.release-version-tracks{gap:9px;max-height:150px;padding:15px 17px;display:grid;overflow:auto}.release-version-track{grid-template-columns:minmax(82px,auto) minmax(80px,1fr) minmax(86px,auto);align-items:center;gap:11px;min-width:0;display:grid}.release-version-label{align-items:center;gap:6px;min-width:0;display:flex}.release-version-label b{color:var(--admin-ink);font:620 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-version-label span{color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-micro);white-space:nowrap;border-radius:4px;padding:2px 5px}.release-version-track.latest .release-version-label span{color:var(--admin-vital);background:var(--admin-soft)}.release-version-track.outdated .release-version-label span{color:color-mix(in oklch, var(--admin-amber) 78%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface))}.release-version-rail{background:var(--admin-paper);border-radius:999px;height:6px;overflow:hidden}.release-version-rail i{border-radius:inherit;background:color-mix(in oklch, var(--admin-vital) 55%, var(--admin-line));height:100%;display:block}.release-version-track.latest .release-version-rail i{background:var(--admin-vital)}.release-version-track.outdated .release-version-rail i{background:var(--admin-amber)}.release-version-track.unknown .release-version-rail i{background:var(--admin-muted)}.release-version-track small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);text-align:right;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-sync-card{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:minmax(280px,1.2fr) minmax(340px,1fr);align-items:center;gap:15px 22px;padding:18px 20px;display:grid}.release-sync-card.running{border-color:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));box-shadow:inset 3px 0 0 var(--admin-vital)}.release-sync-card.failed,.release-sync-card.conflict{border-color:color-mix(in oklch, var(--admin-danger) 36%, var(--admin-line))}.release-sync-main{align-items:center;gap:13px;min-width:0;display:flex}.release-sync-icon{width:42px;height:42px;color:var(--admin-vital);background:var(--admin-soft);border-radius:10px;flex:none;place-items:center;display:grid}.release-sync-card.running .release-sync-icon svg{animation:1.2s linear infinite admin-spin}.release-sync-main h3{font:650 var(--admin-type-body) var(--admin-display);margin:2px 0 4px}.release-sync-main p:last-child{min-width:0;color:var(--admin-muted);font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;margin:0;overflow:hidden}.release-sync-main code{color:var(--admin-ink);font-family:var(--admin-data)}.release-sync-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.release-sync-facts span{background:var(--admin-soft);border-radius:8px;min-width:0;padding:9px 10px}.release-sync-facts b{color:var(--admin-ink);font:620 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.release-sync-facts span{color:var(--admin-muted);font-size:var(--admin-type-micro)}.release-sync-progress{grid-column:1/-1}.release-sync-progress>div{color:var(--admin-muted);font-size:var(--admin-type-caption);justify-content:space-between;gap:12px;margin-bottom:6px;display:flex}.release-sync-progress>i{background:var(--admin-soft);border-radius:999px;height:5px;display:block;overflow:hidden}.release-sync-progress>i>em{border-radius:inherit;background:var(--admin-vital);height:100%;transition:width .2s;display:block}.release-sync-card>.notice{grid-column:1/-1;margin:0}.release-sync-footer{border-top:1px solid var(--admin-line);grid-column:1/-1;justify-content:space-between;align-items:center;gap:16px;padding-top:12px;display:flex}.release-sync-footer p{color:var(--admin-muted);font-size:var(--admin-type-caption);align-items:center;gap:7px;margin:0;display:flex}.release-sync-footer p svg{color:var(--admin-vital);flex:none}.release-sync-footer>div{gap:8px;display:flex}@keyframes admin-spin{to{transform:rotate(360deg)}}.release-layout{grid-template-columns:315px minmax(0,1fr);align-items:start;gap:18px;display:grid}.release-index{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:15px;min-width:0;position:sticky;top:106px;overflow:hidden}.release-index>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:center;min-height:62px;padding:12px 14px;display:flex}.release-index>header span,.release-index>header b{display:block}.release-index>header span{color:var(--admin-vital);font:680 var(--admin-type-micro) var(--admin-data);letter-spacing:.1em}.release-index>header b{font-size:var(--admin-type-small);margin-top:4px}.release-new{border:1px dashed color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line));width:calc(100% - 20px);min-height:53px;color:var(--admin-vital);background:var(--admin-soft);text-align:left;border-radius:9px;align-items:center;gap:10px;margin:10px;padding:8px 11px;display:flex}.release-new:hover,.release-new.active{background:color-mix(in oklch, var(--admin-vital) 12%, var(--admin-surface));border-style:solid}.release-new span,.release-new b,.release-new small{display:block}.release-new b{color:var(--admin-ink);font-size:var(--admin-type-small)}.release-new small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:3px}.release-trace{max-height:calc(100dvh - 286px);padding:2px 10px 12px;overflow:auto}.release-trace>button{width:100%;min-height:94px;color:var(--admin-muted);text-align:left;background:0 0;border:0;grid-template-columns:18px minmax(0,1fr);padding:0;display:grid}.trace-node{justify-content:center;height:100%;display:flex;position:relative}.trace-node:before{background:var(--admin-line);content:"";width:1px;position:absolute;top:0;bottom:0;left:50%}.release-trace>button:first-child .trace-node:before{top:22px}.release-trace>button:last-child .trace-node:before{bottom:calc(100% - 22px)}.trace-node i{z-index:1;border:2px solid var(--admin-surface);background:color-mix(in oklch, var(--admin-muted) 38%, var(--admin-line));width:9px;height:9px;box-shadow:0 0 0 1px var(--admin-line);border-radius:50%;margin-top:18px;position:relative}.release-trace>button.latest .trace-node i{background:var(--admin-vital);box-shadow:0 0 0 4px var(--admin-soft)}.trace-copy{border:1px solid #0000;border-radius:9px;min-width:0;margin:6px 0;padding:10px 11px;transition:background .15s,border-color .15s}.release-trace>button:hover .trace-copy{background:var(--admin-paper)}.release-trace>button.active .trace-copy{border-color:color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:var(--admin-soft)}.trace-copy>span:first-child{justify-content:space-between;align-items:center;gap:8px;display:flex}.trace-copy b{color:var(--admin-ink);font:650 var(--admin-type-body) var(--admin-data)}.trace-copy em{color:var(--admin-muted);background:var(--admin-paper);font:normal var(--admin-type-micro) var(--admin-data);border-radius:4px;padding:3px 5px}.release-trace>button.latest .trace-copy em{color:var(--admin-vital);background:var(--admin-soft)}.trace-copy>small{color:var(--admin-muted);font-size:var(--admin-type-caption);-webkit-line-clamp:1;-webkit-box-orient:vertical;margin-top:6px;line-height:1.4;display:-webkit-box;overflow:hidden}.trace-platforms{color:var(--admin-ink);font:var(--admin-type-micro) var(--admin-data);margin-top:7px;display:inline-block}.trace-copy time{float:right;color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);margin-top:7px}.release-index-empty{color:var(--admin-muted);font-size:var(--admin-type-small);text-align:center;padding:30px 16px}.release-workspace{gap:16px;min-width:0;display:grid}.release-create{max-width:820px}.release-create form{gap:16px;display:grid}.release-create .field textarea{min-height:150px}.release-state{min-height:25px;color:var(--admin-muted);background:var(--admin-paper);font:var(--admin-type-caption) var(--admin-data);border-radius:6px;align-items:center;padding:0 8px;display:inline-flex}.release-state.latest{color:var(--admin-vital);background:var(--admin-soft)}.release-meta{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:9px;grid-template-columns:1fr 1fr .7fr;gap:1px;display:grid;overflow:hidden}.release-meta span{min-width:0;color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-caption);padding:12px 13px}.release-meta b{color:var(--admin-ink);font:610 var(--admin-type-small) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-bottom:5px;display:block;overflow:hidden}.release-provenance{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-caption);border-radius:8px;flex-wrap:wrap;align-items:center;gap:7px 10px;margin-top:8px;padding:8px 11px;display:flex}.release-provenance strong{color:var(--admin-ink);font-weight:620}.release-provenance code{min-width:0;color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-notes{max-width:760px;color:var(--admin-muted);font-size:12px;line-height:1.65}.release-notes.preview{max-height:148px;position:relative;overflow:hidden}.release-notes.preview:after{background:linear-gradient(transparent, var(--admin-paper));content:"";pointer-events:none;height:38px;position:absolute;bottom:0;left:0;right:0}.release-notes-toggle{color:var(--admin-vital);cursor:pointer;background:0 0;border:0;margin-top:7px;padding:0;font-size:11px}.release-notes-toggle:hover{text-underline-offset:3px;text-decoration:underline}.release-notes :first-child{margin-top:6px}.release-notes :last-child{margin-bottom:0}.release-notes p{margin:6px 0}.release-notes h1,.release-notes h2,.release-notes h3,.release-notes h4{color:var(--admin-ink);font:650 13px/1.35 var(--admin-display);margin:10px 0 5px}.release-notes ul,.release-notes ol{margin:6px 0;padding-left:19px}.release-notes li{margin:2px 0}.release-notes a{color:var(--admin-vital);text-underline-offset:2px;text-decoration:underline}.release-notes code{color:var(--admin-ink);background:var(--admin-soft);font-family:var(--admin-data);border-radius:4px;padding:1px 4px}.release-notes pre{background:var(--admin-soft);border-radius:6px;margin:8px 0;padding:8px 10px;overflow-x:auto}.release-notes pre code{background:0 0;padding:0}.release-notes blockquote{border-left:2px solid var(--admin-vital);margin:8px 0;padding-left:10px}.release-asset-grid{grid-template-columns:1fr 1fr;gap:12px;margin-top:15px;display:grid}.release-asset-lane{border:1px solid var(--admin-line);border-radius:10px;min-width:0;overflow:hidden}.release-asset-lane>header{border-bottom:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:center;gap:10px;padding:12px 13px;display:flex}.release-asset-lane>header b,.release-asset-lane>header small{display:block}.release-asset-lane>header b{font-size:var(--admin-type-small)}.release-asset-lane>header small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:3px}.release-asset-lane>header>span{min-width:23px;height:23px;color:var(--admin-vital);background:var(--admin-soft);font:var(--admin-type-caption) var(--admin-data);border-radius:6px;place-items:center;display:grid}.release-asset-lane article{border-bottom:1px solid var(--admin-line);grid-template-columns:minmax(125px,.7fr) minmax(0,1fr) auto;align-items:center;gap:10px;min-height:59px;padding:9px 11px;display:grid}.release-asset-lane article:last-child{border-bottom:0}.asset-platform{align-items:center;gap:6px;min-width:0;display:flex}.asset-platform i{background:var(--admin-vital);border-radius:50%;flex:none;width:6px;height:6px}.asset-platform span{font:var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.asset-file{min-width:0}.asset-file b,.asset-file small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.asset-file b{font:560 var(--admin-type-caption) var(--admin-data)}.asset-file small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:4px}.release-asset-lane article>button{width:29px;height:29px;color:var(--admin-vital);background:var(--admin-soft);cursor:pointer;border:0;border-radius:7px;place-items:center;display:grid}.release-asset-lane article>button:hover{background:color-mix(in oklch, var(--admin-vital) 15%, var(--admin-surface))}.release-lane-empty{color:var(--admin-muted);font-size:var(--admin-type-caption);text-align:center;margin:0;padding:25px 12px}.release-dropzone{border:1px dashed color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));min-height:118px;color:var(--admin-vital);background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface));cursor:pointer;border-radius:12px;justify-content:center;align-items:center;gap:13px;padding:20px;transition:border-color .16s,background .16s,transform .16s;display:flex}.release-dropzone:hover,.release-dropzone.dragging{border-style:solid;border-color:var(--admin-vital);background:color-mix(in oklch, var(--admin-vital) 11%, var(--admin-surface));transform:translateY(-1px)}.release-dropzone.busy{opacity:.68;pointer-events:none}.release-dropzone span,.release-dropzone b,.release-dropzone small{display:block}.release-dropzone b{color:var(--admin-ink);font-size:var(--admin-type-body)}.release-dropzone small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:5px}.release-dropzone input{opacity:0;width:1px;height:1px;position:absolute}.release-upload-hint{color:var(--admin-muted);font-size:var(--admin-type-caption);flex-wrap:wrap;align-items:center;gap:6px;margin-top:12px;display:flex}.release-upload-hint code{color:var(--admin-ink);background:var(--admin-paper);font-size:var(--admin-type-micro);border-radius:5px;padding:4px 6px}.release-queue{border:1px solid var(--admin-line);border-radius:11px;margin-top:14px;overflow:hidden}.release-queue>header{border-bottom:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:center;gap:10px;min-height:48px;padding:8px 11px;display:flex}.release-queue>header>div{color:var(--admin-vital);align-items:center;gap:7px;display:flex}.release-queue>header span{color:var(--admin-ink);font-size:var(--admin-type-small)}.release-queue>.notice{white-space:pre-line;margin:10px}.release-queue-rows{display:grid}.release-queue-rows article{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:minmax(180px,1fr) minmax(145px,.6fr) 100px minmax(130px,.6fr) auto;align-items:center;gap:8px;min-height:64px;padding:8px 10px;display:grid}.release-queue-rows article.success{background:color-mix(in oklch, var(--admin-vital) 5%, var(--admin-surface))}.release-queue-rows article.invalid,.release-queue-rows article.error{background:color-mix(in oklch, var(--admin-red) 4%, var(--admin-surface))}.queue-file{min-width:0}.queue-file b,.queue-file small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.queue-file b{font:570 var(--admin-type-small) var(--admin-data)}.queue-file small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:4px}.release-queue-rows select{border:1px solid var(--admin-line);min-width:0;height:36px;color:var(--admin-ink);background:var(--admin-surface);font-size:var(--admin-type-caption);border-radius:7px;outline:0;padding:0 8px}.queue-state{color:var(--admin-muted);font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.invalid .queue-state,.error .queue-state{color:var(--admin-red)}.success .queue-state{color:var(--admin-vital)}.queue-actions{justify-content:flex-end;align-items:center;gap:5px;display:flex}.orphan-signatures{border-bottom:1px solid var(--admin-line);color:var(--admin-muted);font-size:var(--admin-type-caption);flex-wrap:wrap;align-items:center;gap:6px;padding:9px 11px;display:flex}.orphan-signatures button{color:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface));font:var(--admin-type-micro) var(--admin-data);border:0;border-radius:5px;align-items:center;gap:5px;padding:4px 6px;display:inline-flex}.release-queue>.panel-actions{background:var(--admin-paper);margin:0;padding:11px}.queue-summary{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-right:auto}.publish-platforms{flex-wrap:wrap;gap:8px;display:flex}.publish-platforms label{border:1px solid var(--admin-line);background:var(--admin-paper);cursor:pointer;border-radius:8px;align-items:center;gap:7px;min-height:38px;padding:0 10px 0 8px;display:flex;position:relative}.publish-platforms input{opacity:0;position:absolute}.publish-platforms i{border:1px solid var(--admin-line);color:#0000;background:var(--admin-surface);border-radius:5px;place-items:center;width:18px;height:18px;display:grid}.publish-platforms input:checked+i{border-color:var(--admin-vital);color:#fff;background:var(--admin-vital)}.publish-platforms input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:2px}.publish-platforms span{font:var(--admin-type-caption) var(--admin-data)}.publish-note{border-left:2px solid var(--admin-amber);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-amber) 7%, var(--admin-surface));font-size:var(--admin-type-caption);align-items:flex-start;gap:8px;margin-top:14px;padding:10px 11px;line-height:1.55;display:flex}.publish-note svg{color:var(--admin-amber);flex:none}.release-publish-empty{min-height:90px;color:var(--admin-muted);font-size:var(--admin-type-small);justify-content:center;align-items:center;gap:10px;display:flex}.release-publish-empty svg{color:var(--admin-vital)}.login-presence h1,.login-access h2,.topbar-title h1,.presence-copy h1,.admin-panel>header h2{letter-spacing:-.015em}@media (prefers-reduced-motion:reduce){.login-sigil i,.login-life-trace i,.life-trace:after,.pulse-organ>i,.state-pulse i,.restart-orbit i,.release-signal i{animation:none!important}.primary:hover{transform:none}}@media (width<=1120px){.release-fleet{grid-template-columns:180px minmax(0,1fr)}.release-fleet-body{grid-template-columns:minmax(220px,.55fr) minmax(300px,1fr)}.release-layout{grid-template-columns:275px minmax(0,1fr)}.release-asset-grid{grid-template-columns:1fr}.release-queue-rows article{grid-template-columns:minmax(170px,1fr) minmax(135px,.7fr) 92px auto}.queue-state{grid-area:2/1/auto/4}.queue-actions{grid-area:1/4/3}}@media (width<=820px){.weixin-overview{grid-template-columns:1fr}.weixin-overview>button{justify-content:center;width:100%}.weixin-pairing{grid-template-columns:1fr}.weixin-qr-stage{width:min(100%,240px)}.release-hero{grid-template-columns:auto minmax(0,1fr);padding:20px}.release-fleet{grid-template-columns:1fr}.release-fleet>header{border-right:0;border-bottom:1px solid var(--admin-line);align-items:center}.release-sync-card{grid-template-columns:1fr}.release-sync-facts{grid-template-columns:repeat(3,minmax(0,1fr))}.release-sync-footer{flex-direction:column;align-items:flex-start}.release-sync-footer>div{justify-content:flex-end;width:100%}.release-hero-platforms{grid-column:1/-1;min-width:0}.release-layout{grid-template-columns:1fr}.release-index{position:static}.release-trace{max-height:none;padding-bottom:10px;display:flex;overflow-x:auto}.release-trace>button{flex:0 0 250px}.release-meta{grid-template-columns:1fr}.release-meta span{border-bottom:1px solid var(--admin-line)}.release-meta span:last-child{border-bottom:0}.release-queue-rows article{grid-template-columns:minmax(0,1fr) 145px 92px auto}}@media (width<=560px){.release-hero,.release-fleet-body{grid-template-columns:1fr}.release-fleet-summary{border-right:0;border-bottom:1px solid var(--admin-line)}.release-version-track{grid-template-columns:minmax(80px,auto) 1fr}.release-version-track small{text-align:left;grid-column:1/-1}.release-sync-facts{grid-template-columns:1fr}.release-sync-main p:last-child{white-space:normal}.release-sync-footer>div{flex-direction:column}.release-sync-footer button{justify-content:center;width:100%}.release-signal{width:56px;height:56px}.release-hero-platforms{grid-column:auto}.release-asset-lane article{grid-template-columns:1fr auto}.asset-file{grid-area:2/1}.release-asset-lane article>button{grid-area:1/2/3}.release-dropzone{justify-content:flex-start;align-items:flex-start}.release-queue-rows article{grid-template-columns:1fr 1fr auto;padding:11px}.queue-file{grid-column:1/-1}.release-queue-rows select:first-of-type{grid-column:1/3}.queue-state{white-space:normal;grid-area:auto/1/auto/3}.queue-actions{grid-area:2/3/4}.release-queue>.panel-actions{align-items:stretch}.release-queue>.panel-actions .primary{justify-content:center;width:100%}.queue-summary{width:100%}.publish-platforms{grid-template-columns:1fr;display:grid}.release-publish-panel .panel-actions>button{justify-content:center;width:100%}}@media (width>=1680px){.admin-shell{grid-template-columns:240px minmax(0,1fr)}.admin-sidebar{padding:24px 16px 18px}.admin-brand b{max-width:150px}.nav-group button.active:before{left:-16px}.admin-topbar{min-height:88px;padding-inline:clamp(40px,2.4vw,64px)}.topbar-title h1{font-size:24px}.topbar-title>span{font-size:13px}.page-stack{gap:22px}.presence-hero{grid-template-columns:minmax(0,1fr) clamp(380px,22vw,520px);min-height:280px}.presence-copy{padding:40px 46px}.pulse-organ{min-height:280px}.vital{min-height:152px;padding:23px 24px}.admin-panel{padding:28px}.content-layout{grid-template-columns:clamp(310px,16vw,390px) minmax(0,1fr);gap:18px}.content-layout:has(.content-files){grid-template-columns:clamp(278px,14vw,350px) clamp(218px,11vw,280px) minmax(0,1fr)}}@media (width>=821px) and (height>=900px){.content-workspace{--content-workbench-height:clamp(720px, calc(100dvh - 282px), 1320px)}.content-layout{height:var(--content-workbench-height)}.content-index,.content-files,.content-editor{height:100%;min-height:0}.content-editor{overflow:hidden}.source-editor{resize:none;min-height:240px}.content-editor>.panel-actions{flex:none;position:static}.log-table{max-height:max(600px,100dvh - 300px)}.audit-timeline{max-height:max(650px,100dvh - 360px)}}@media (width>=1680px) and (height>=1200px){.presence-hero,.pulse-organ{min-height:clamp(300px,18dvh,380px)}.vital{min-height:clamp(152px,9dvh,190px)}}@media (width<=1120px){.short-memory-grid{grid-template-columns:1fr}.short-tree,.short-message-list{max-height:560px}.admin-shell{grid-template-columns:78px minmax(0,1fr)}.admin-sidebar{padding-inline:10px}.admin-brand{justify-content:center;padding-inline:0}.admin-brand>div:last-child,.nav-group>p,.nav-group button span,.nav-chevron,.sidebar-foot>span,.sidebar-foot button span{display:none}.nav-group button{justify-content:center;padding:0}.nav-group button.active:before{left:-10px}.sidebar-foot button{justify-content:center}.topbar-title>span{display:none}.presence-hero{grid-template-columns:minmax(0,1fr) 280px}.presence-readout>div{min-width:120px;padding-inline:16px}.presence-readout>div:first-child{min-width:200px}.vital-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.content-layout{grid-template-columns:280px minmax(0,1fr)}.content-layout:has(.content-files){grid-template-columns:240px 200px minmax(0,1fr)}.llm-config-overview{grid-template-columns:1fr}.llm-config-facts{width:100%}}@media (width<=820px){.short-watermark{grid-template-columns:1fr}.watermark-facts{border-top:1px solid var(--admin-line);border-left:0;padding:20px 54px 20px 24px}.pin-compose,.pinned-context-list{grid-template-columns:1fr}.admin-bootstrap{align-items:start;padding:18px;overflow:auto}.bootstrap-card{grid-template-columns:1fr}.bootstrap-rail{padding:32px}.bootstrap-rail .login-sigil{margin-bottom:28px}.bootstrap-rail h1 br{display:none}.awakening-circuit{grid-template-columns:repeat(3,1fr);margin-top:34px}.awakening-circuit:before{width:auto;height:1px;inset:20px 15% auto}.awakening-circuit li{text-align:center;grid-template-columns:1fr;justify-items:center;gap:7px}.bootstrap-form-stage{padding:36px 32px}.admin-shell{display:block}.admin-sidebar{z-index:60;width:100%;height:76px;padding:7px 8px calc(7px + env(safe-area-inset-bottom));border-top:1px solid #ffffff16;display:block;position:fixed;inset:auto 0 0}.admin-sidebar:after,.admin-brand,.sidebar-foot{display:none}.admin-sidebar nav{scrollbar-width:none;scrollbar-gutter:auto;gap:0;height:100%;padding:0;display:flex;overflow:auto hidden}.admin-sidebar nav::-webkit-scrollbar{display:none}.nav-group{display:contents}.nav-group>p{display:none}.nav-group button{text-align:center;border-radius:9px;flex-direction:column;flex:0 0 84px;justify-content:center;gap:4px;width:auto;min-height:60px;padding:0 6px}.nav-group button span{width:100%;color:inherit;font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;flex:none;display:block;overflow:hidden}.nav-group button.active:before{border-radius:0 0 99px 99px;width:24px;height:3px;top:-7px;left:50%;transform:translate(-50%)}.admin-topbar{min-height:78px;padding:12px 18px}.topbar-title .eyebrow{margin-bottom:4px}.topbar-title h1{font-size:20px}.topbar-actions{gap:10px}.topbar-actions>a,.shell-life>span{display:none}.life-trace{width:96px}.admin-content{padding:20px 16px 108px}.presence-hero{grid-template-columns:1fr}.pulse-organ{border-top:1px solid var(--admin-line);border-left:0;min-height:130px}.pulse-organ:before{width:180px;height:180px}.pulse-organ:after{width:120px;height:120px}.presence-copy{padding:28px}.two-col,.settings-layout,.content-layout{grid-template-columns:1fr}.content-layout:has(.content-files){grid-template-columns:1fr}.settings-layout{gap:12px}.subnav{z-index:8;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);scrollbar-width:none;max-width:calc(100vw - 32px);padding:5px;display:flex;position:sticky;top:88px;overflow:auto}.subnav::-webkit-scrollbar{display:none}.subnav button{white-space:nowrap;flex:none}.content-index{grid-template-columns:minmax(0,1fr) minmax(180px,.8fr) auto;align-items:center;gap:9px;min-height:auto;display:grid}.content-items{grid-column:1/-1;max-height:none;margin:2px 0;padding:0;display:flex;overflow:auto}.content-items button{flex:0 0 220px}.content-no-result{width:100%;min-height:70px}.new-content{width:max-content;min-width:132px}.content-editor{min-height:620px}.content-files{min-height:auto}.content-files>header{min-height:48px}.file-tree{max-height:none;display:flex;overflow:auto}.file-tree button{flex:0 0 220px}.content-files>footer{display:none}.provider-row{grid-template-columns:1fr 1fr}.desktop-sync-overview,.desktop-source-workbench,.desktop-form-row.two,.desktop-option-grid{grid-template-columns:1fr}.tabbar:not(.compact){grid-template-columns:repeat(3,1fr);width:100%;display:grid;overflow:visible}.tabbar:not(.compact) button{width:100%}.audit-table>div{grid-template-columns:130px minmax(120px,1fr) 54px}.audit-table>.audit-head,.audit-table code,.audit-table small{display:none}.audit-vitals{grid-template-columns:1fr 1fr}.audit-vitals article:nth-child(2){border-right:0}.audit-vitals article:nth-child(-n+2){border-bottom:1px solid var(--admin-line)}.runtime-task-grid{grid-template-columns:1fr}}@media (width<=560px){.admin-language-toggle-floating{top:12px;right:12px}.admin-login{background:var(--admin-surface);min-height:100vh;padding:0;display:block;overflow:auto}.admin-login:after{display:none}.login-card{width:100%;min-height:100vh;box-shadow:none;border:0;border-radius:0;display:block}.login-presence{min-height:320px;padding:30px 24px}.login-sigil{width:62px;height:62px;margin-bottom:28px}.login-presence h1{font-size:36px}.login-copy{margin-top:11px}.login-life-trace{opacity:.7;justify-content:flex-end;height:58px;margin:0;position:absolute;bottom:62px;left:24px;right:24px}.login-assurance{margin-top:34px}.login-assurance span:last-child{display:none}.login-access{min-height:calc(100vh - 320px);padding:34px 24px 42px}.admin-bootstrap{padding:0}.bootstrap-card{min-height:100vh;box-shadow:none;border:0;border-radius:0}.bootstrap-rail{padding:28px 22px}.bootstrap-rail .login-sigil{width:58px;height:58px}.bootstrap-rail h1{font-size:34px}.bootstrap-rail>p:not(.eyebrow){font-size:12px}.awakening-circuit li{min-height:68px}.awakening-circuit li>span{width:38px;height:38px}.awakening-circuit li small{display:none}.bootstrap-form-stage{padding:32px 22px 40px}.bootstrap-grid{grid-template-columns:1fr}.bootstrap-grid label.wide{grid-column:auto}.bootstrap-footnote{align-items:flex-start;line-height:1.5}.access-step{margin-bottom:30px}.admin-topbar{min-height:72px;padding:10px 14px}.topbar-title .eyebrow{font-size:var(--admin-type-micro)}.topbar-title h1{font-size:19px}.life-trace{width:74px}.admin-content{padding:15px 12px 104px}.page-stack{gap:13px}.memory-tabs{grid-template-columns:1fr 1fr!important}.memory-tabs button{min-width:0}.watermark-reading{grid-template-columns:1fr;justify-items:start;gap:18px;min-height:0;padding:22px 20px}.watermark-orbit{width:104px;height:104px}.watermark-orbit b{font-size:23px}.watermark-reading h2{margin-top:6px;font-size:25px}.watermark-facts{padding:18px 20px}.watermark-refresh{top:10px;right:10px}.short-tree-node>summary{grid-template-columns:34px minmax(0,1fr)}.short-tree-node .raw-state{grid-column:2;width:max-content}.tree-node-detail{padding:0 10px 10px 46px}.short-message>summary{grid-template-columns:26px minmax(0,1fr)}.short-message>summary>i{grid-column:2;width:max-content;max-width:100%}.short-message-body{padding:0 10px 10px 46px}.memory-maintenance .mild{grid-template-columns:auto minmax(0,1fr)}.memory-maintenance .mild input,.memory-maintenance .mild button{grid-column:2}.memory-maintenance .mild button{width:max-content}.presence-copy{padding:24px 20px}.presence-copy h1{gap:9px;font-size:34px}.presence-readout{grid-template-columns:1fr 1fr;gap:0;margin-top:25px;display:grid}.presence-readout>div,.presence-readout>div:first-child{border-top:1px solid var(--admin-line);border-left:0;min-width:0;padding:12px 10px}.presence-readout>div:first-child{grid-column:1/-1;padding-inline:0}.presence-readout>div:nth-child(2){padding-left:0}.pulse-organ{min-height:112px}.pulse-organ>i{max-height:68px}.vital-grid{grid-template-columns:1fr 1fr}.vital{min-height:124px;padding:16px}.vital>strong{font-size:26px}.two-col{gap:13px}.admin-panel{border-radius:13px;padding:18px}.admin-panel>header{flex-direction:column;gap:12px;margin-bottom:18px}.admin-panel>header h2{font-size:16px}.admin-panel>header p{font-size:var(--admin-type-small)}.memory-meter{grid-template-columns:1fr}.memory-search-stage{grid-template-columns:auto minmax(0,1fr);gap:15px;padding:20px}.memory-search-mark{width:48px;height:48px}.memory-query{grid-column:1/-1;grid-template-columns:auto minmax(0,1fr) auto;padding:5px 5px 5px 12px}.memory-query-clear{grid-column:3}.memory-query-submit{grid-column:1/-1;justify-content:center;min-height:42px;margin-top:4px}.memory-examples{grid-column:1/-1;margin-top:-2px}.memory-results article{grid-template-columns:30px minmax(0,1fr)}.memory-card-body{padding:14px}.memory-results article footer{flex-direction:column;align-items:flex-start}.memory-actions{align-self:flex-end}.memory-footnote{justify-content:flex-start;align-items:flex-start;line-height:1.5}.inline-form,.task-compose,.alarm-compose,.field-grid,.config-fields,.memory-results,.identity-form{grid-template-columns:1fr}.config-fields .field,.identity-form .field{grid-column:auto!important}.string-list-field,.transport-list-field{grid-column:1/-1!important}.transport-list-editor{grid-template-columns:1fr}.system-prompt-facts{flex-wrap:wrap}.system-prompt-facts em{margin-left:0}.system-prompt-preview summary{grid-template-columns:auto 1fr}.system-prompt-preview summary small{grid-column:2}.config-switch{margin-top:0}.inline-add,.search-box,.log-filters{flex-wrap:wrap;width:100%}.inline-add input,.search-box input,.log-filters input{flex:150px}.record,.bubble-record-head{flex-direction:column;align-items:flex-start}.bubble-history{padding-left:14px}.alarm-record{grid-template-columns:64px minmax(0,1fr) auto;align-items:center;display:grid}.alarm-record .danger-icon{align-self:center}.provider-row{grid-template-columns:1fr;padding-top:52px}.provider-row .field:nth-of-type(n){grid-column:auto}.llm-config-facts{grid-template-columns:1fr}.llm-config-facts span{border-right:0;border-bottom:1px solid var(--admin-line)}.llm-config-facts span:last-child{border-bottom:0}.admin-security-hero{grid-template-columns:auto minmax(0,1fr);padding:18px}.admin-security-hero>b{grid-column:2;width:max-content}.admin-setting-cards{grid-template-columns:1fr}.row-actions{justify-content:flex-end;width:100%}.row-actions select{flex:1}.danger-card{grid-template-columns:auto 1fr;align-items:start;display:grid}.danger-card>button,.danger-card>.tiny-input{grid-column:2;width:100%}.log-table>div{grid-template-columns:70px 98px minmax(130px,1fr);min-width:390px}.history-navigator{flex-direction:column;align-items:flex-start}.history-detail-line{flex-wrap:wrap;align-items:flex-start;gap:3px 8px}.history-position small{flex:100%}.history-sequence-total{border-left:0;padding-left:0}.history-log-filters{justify-content:flex-start}.sequence-range{width:100%}.sequence-range label{flex:1}.sequence-range input{flex:1 1 0;width:100%;min-width:0}.history-actions{width:100%}.history-actions button{flex:1;justify-content:center}.lifecycle-log-table>.log-head{display:none}.lifecycle-log-table>article{grid-template-columns:68px minmax(0,1fr) auto;gap:5px 8px;min-width:0}.lifecycle-log-table>article>time{grid-row:1/span 2;font-size:9px;line-height:1.45}.lifecycle-log-table .event-type{grid-area:1/2}.interaction-row-copy{grid-area:2/2}.interaction-detail-toggle{grid-area:1/3/span 2;align-self:center}.interaction-detail{grid-column:1/-1}.audit-table>div{grid-template-columns:1fr auto;gap:6px 12px;padding:13px}.audit-table time{grid-column:1/-1}.audit-table b{font-size:12px}.audit-vitals article{min-height:78px;padding:12px}.audit-vitals b{font-size:18px}.audit-vitals small{display:none}.audit-filters{grid-template-columns:1fr auto}.audit-filters label{grid-column:1/-1}.audit-timeline article{grid-template-columns:14px 64px minmax(0,1fr);gap:6px}.audit-event header{flex-wrap:wrap}.audit-event header i{margin-left:0}.audit-switcher>div{flex:1}.audit-switcher>div button{flex:1;justify-content:center;padding-inline:8px}.diagnostic-list{overflow-x:hidden}.diagnostic-list details{min-width:0}.diagnostic-list summary{grid-template-columns:auto minmax(0,1fr);align-items:start}.diagnostic-list summary code{overflow-wrap:anywhere;white-space:normal;grid-column:2;line-height:1.55}.panel-actions{flex-wrap:wrap}.config-panel>.panel-actions,.content-editor>.panel-actions{bottom:84px}.capability-strip button{min-height:64px;padding:12px 10px}.capability-strip small{display:none}.content-index{grid-template-columns:1fr auto}.content-search{grid-area:2/1/auto/-1}.content-items{grid-row:3}.new-content{grid-row:4}.source-create-card{grid-template-columns:auto minmax(0,1fr)}.source-create-card label{grid-column:1/-1;margin-top:4px}.source-toolbar{flex-direction:column;align-items:flex-start;padding-block:10px}.source-readout{max-width:100%;overflow:auto}.source-schema p,.source-schema>i{display:none}.source-editor{min-height:460px;padding:18px 16px;font-size:12px}.confirm-modal{padding:24px 20px}} +:root{--admin-ink:oklch(22% .012 260);--admin-paper:oklch(98.5% .006 95);--admin-surface:oklch(100% 0 0);--admin-vital:oklch(76% .14 28);--admin-peach:oklch(82% .1 62);--admin-sky:oklch(82% .09 245);--admin-amber:oklch(82% .12 95);--admin-red:oklch(60% .17 28);--admin-muted:oklch(54% .014 260);--admin-line:oklch(91% .008 95);--admin-soft:color-mix(in oklch, var(--admin-vital) 7%, var(--admin-surface));--admin-display:var(--font-display);--admin-body:var(--font-body);--admin-data:var(--font-mono);--admin-type-micro:clamp(10px, calc(9.5px + .04vw), 11px);--admin-type-caption:clamp(11px, calc(10.4px + .05vw), 12px);--admin-type-small:clamp(12px, calc(11.4px + .05vw), 13px);--admin-type-body:clamp(13px, calc(12.4px + .05vw), 14px);--admin-type-ui:clamp(14px, calc(13.4px + .04vw), 15px);--admin-field-label-height:18px;--admin-field-gap:7px}.admin-shell,.admin-login{--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}html:has(.admin-shell){--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}html:has(.admin-login){--scrollbar-thumb:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));--scrollbar-track:color-mix(in oklch, var(--admin-paper) 58%, transparent)}.admin-shell,.admin-login{min-height:100dvh;color:var(--admin-ink);font-family:var(--admin-body);font-size:var(--admin-type-ui);font-synthesis:none;line-break:strict;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased}.admin-shell button,.admin-shell input,.admin-shell select,.admin-shell textarea,.admin-login button,.admin-login input,.admin-login select{font:inherit}.admin-shell button,.admin-login button{-webkit-tap-highlight-color:transparent}.admin-shell :where(button,a,input,select,textarea):focus-visible,.admin-login :where(button,a,input,select):focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 30%, transparent);outline-offset:2px}.admin-login{background:linear-gradient(90deg, color-mix(in oklch, var(--admin-ink) 5%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in oklch, var(--admin-ink) 5%, transparent) 1px, transparent 1px), radial-gradient(circle at 20% 15%, color-mix(in oklch, var(--admin-vital) 14%, transparent), transparent 34%), var(--admin-paper);background-size:42px 42px,42px 42px,auto,auto;place-items:center;padding:32px;display:grid;position:relative;overflow:hidden}.admin-login:after{content:"";background:linear-gradient(transparent, color-mix(in oklch, var(--admin-vital) 24%, transparent), transparent);pointer-events:none;width:1px;position:absolute;top:0;bottom:0;left:50%}.login-card{z-index:1;border:1px solid var(--admin-line);background:var(--admin-surface);width:min(880px,100%);min-height:540px;box-shadow:0 28px 90px color-mix(in oklch, var(--admin-ink) 14%, transparent);border-radius:24px;grid-template-columns:1.08fr .92fr;display:grid;position:relative;overflow:hidden}.login-presence{color:var(--admin-paper);background:radial-gradient(circle at 15% 15%, color-mix(in oklch, var(--admin-vital) 40%, transparent), transparent 34%), var(--admin-ink);flex-direction:column;justify-content:space-between;padding:48px;display:flex;position:relative;overflow:hidden}.login-presence:before,.login-presence:after{content:"";border:1px solid color-mix(in oklch, var(--admin-vital) 36%, transparent);border-radius:50%;position:absolute}.login-presence:before{width:380px;height:380px;top:-120px;right:-230px}.login-presence:after{width:250px;height:250px;top:-55px;right:-150px}.login-sigil{z-index:1;border:1px solid color-mix(in oklch, var(--admin-vital) 58%, transparent);width:76px;height:76px;color:color-mix(in oklch, var(--admin-vital) 72%, white);background:color-mix(in oklch, var(--admin-vital) 12%, transparent);border-radius:21px;place-items:center;margin-bottom:46px;display:grid;position:relative}.login-sigil i{opacity:.28;border:1px solid;border-radius:44% 56% 58% 42%;animation:7s linear infinite admin-orbit;position:absolute;inset:9px}.login-sigil i:nth-child(2){animation-duration:5s;animation-direction:reverse;inset:17px}.login-sigil i:nth-child(3){animation-duration:3.5s;inset:26px}@keyframes admin-orbit{to{border-radius:56% 44% 39% 61%;transform:rotate(1turn)}}.eyebrow{color:var(--admin-muted);font:650 11px/1.2 var(--admin-data);letter-spacing:.15em;text-transform:uppercase;margin:0 0 8px}.login-presence .eyebrow{color:color-mix(in oklch, var(--admin-vital) 44%, white)}.login-presence h1{font:660 clamp(34px, 4vw, 48px)/1.08 var(--admin-display);letter-spacing:-.045em;margin:0}.login-copy{max-width:330px;color:color-mix(in oklch, var(--admin-paper) 74%, var(--admin-ink));margin:16px 0 0;font-size:14px;line-height:1.8}.login-life-trace{align-items:center;gap:8px;height:72px;margin:auto 0 24px;display:flex;position:relative}.login-life-trace:before{content:"";background:color-mix(in oklch, var(--admin-vital) 38%, transparent);height:1px;position:absolute;left:0;right:0}.login-life-trace i{z-index:1;background:var(--admin-vital);border-radius:999px;width:3px;height:12px;animation:2.4s ease-in-out infinite login-beat}.login-life-trace i:nth-child(2),.login-life-trace i:nth-child(8){height:24px;animation-delay:.12s}.login-life-trace i:nth-child(3),.login-life-trace i:nth-child(7){height:42px;animation-delay:.24s}.login-life-trace i:nth-child(4),.login-life-trace i:nth-child(6){height:62px;animation-delay:.36s}.login-life-trace i:nth-child(5){height:34px;animation-delay:.48s}@keyframes login-beat{0%,72%,to{opacity:.48;transform:scaleY(.72)}18%{opacity:1;transform:scaleY(1)}}.login-assurance{z-index:1;color:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-ink));font-size:var(--admin-type-caption);border-top:1px solid #ffffff1c;justify-content:space-between;align-items:center;gap:16px;padding-top:18px;display:flex;position:relative}.login-assurance span:first-child{color:color-mix(in oklch, var(--admin-paper) 72%, var(--admin-ink));align-items:center;gap:8px;display:inline-flex}.login-assurance i{background:var(--admin-vital);width:7px;height:7px;box-shadow:0 0 0 5px color-mix(in oklch, var(--admin-vital) 16%, transparent);border-radius:50%}.login-access{background:var(--admin-surface);flex-direction:column;justify-content:center;padding:48px;display:flex}.access-step{color:var(--admin-vital);font:650 var(--admin-type-micro) var(--admin-data);letter-spacing:.14em;margin:0 0 52px}.login-access h2{font:660 24px/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.login-access>div>p{color:var(--admin-muted);margin:9px 0 0;font-size:13px;line-height:1.6}.login-access form{gap:15px;margin:30px 0 26px;display:grid}.login-access form label>span{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.token-input{border:1px solid var(--admin-line);height:48px;color:var(--admin-muted);background:#fff;border-radius:11px;align-items:center;gap:10px;padding:0 14px;transition:border-color .18s,box-shadow .18s;display:flex}.token-input:focus-within{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line));box-shadow:none}.token-input input{min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;flex:1}.token-input input:focus-visible{outline:0}.login-access .primary{width:100%;min-height:48px}.login-access>a{width:max-content;color:var(--admin-muted);align-items:center;gap:5px;font-size:12px;text-decoration:none;display:inline-flex}.login-access>a:hover{color:var(--admin-vital)}.form-error{color:var(--admin-red);margin:0;font-size:12px;line-height:1.5}.bootstrap-card{z-index:1;border:1px solid var(--admin-line);background:var(--admin-surface);width:min(1040px,100%);min-height:680px;box-shadow:0 28px 90px color-mix(in oklch, var(--admin-ink) 14%, transparent);border-radius:24px;grid-template-columns:minmax(300px,.78fr) minmax(520px,1.22fr);display:grid;position:relative;overflow:hidden}.bootstrap-rail{color:var(--admin-paper);background:radial-gradient(circle at 12% 10%, color-mix(in oklch, var(--admin-vital) 38%, transparent), transparent 30%), var(--admin-ink);padding:46px;position:relative;overflow:hidden}.bootstrap-rail:after{content:"";border:1px solid color-mix(in oklch, var(--admin-vital) 34%, transparent);border-radius:45% 55% 52% 48%;width:330px;height:330px;position:absolute;bottom:-90px;right:-240px;transform:rotate(24deg)}.bootstrap-rail .login-sigil{margin-bottom:40px}.bootstrap-rail .eyebrow{color:color-mix(in oklch, var(--admin-vital) 48%, white)}.bootstrap-rail h1{font:660 clamp(34px, 4vw, 48px)/1.12 var(--admin-display);letter-spacing:-.045em;margin:10px 0 18px}.bootstrap-rail>p:not(.eyebrow){max-width:300px;color:color-mix(in oklch, var(--admin-paper) 66%, var(--admin-ink));margin:0;font-size:13px;line-height:1.8}.awakening-circuit{gap:0;margin:58px 0 0;padding:0;list-style:none;display:grid;position:relative}.awakening-circuit:before{content:"";background:#ffffff24;width:1px;position:absolute;top:22px;bottom:22px;left:20px}.awakening-circuit li{color:#ffffff62;grid-template-columns:42px 1fr;align-items:center;gap:14px;min-height:76px;display:grid;position:relative}.awakening-circuit li>span{z-index:1;background:var(--admin-ink);border:1px solid #ffffff24;border-radius:50%;place-items:center;width:42px;height:42px;display:grid}.awakening-circuit li b{color:inherit;font-size:13px;display:block}.awakening-circuit li small{color:inherit;margin-top:4px;font-size:11px;display:block}.awakening-circuit li.done{color:color-mix(in oklch, var(--admin-vital) 62%, white)}.awakening-circuit li.active{color:#fff}.awakening-circuit li.active>span{border-color:var(--admin-vital);color:var(--admin-vital);box-shadow:0 0 0 7px color-mix(in oklch, var(--admin-vital) 12%, transparent)}.bootstrap-form-stage{flex-direction:column;justify-content:center;padding:46px 54px;display:flex}.bootstrap-heading .access-step{margin-bottom:22px}.bootstrap-heading h2,.bootstrap-restarting h2{font:660 26px/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.bootstrap-heading>p:last-child,.bootstrap-restarting>p{color:var(--admin-muted);margin:9px 0 0;font-size:13px;line-height:1.65}.bootstrap-heading code,.bootstrap-footnote code{font-family:var(--admin-data);font-size:.92em}.bootstrap-form{gap:20px;margin-top:30px;display:grid}.bootstrap-grid{grid-template-columns:1fr 1fr;gap:16px;display:grid}.bootstrap-grid label{min-width:0}.bootstrap-grid label.wide{grid-column:1/-1}.bootstrap-grid label>span{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.bootstrap-grid label>span em{color:var(--admin-muted);margin-left:5px;font-size:10px;font-style:normal;font-weight:500}.bootstrap-grid :is(input,select){border:1px solid var(--admin-line);width:100%;height:46px;color:var(--admin-ink);background:#fff;border-radius:10px;outline:0;padding:0 13px}.bootstrap-mode{grid-column:1/-1;grid-template-columns:1fr 1fr;gap:10px;display:grid}.bootstrap-mode>span{grid-column:1/-1;margin-bottom:-2px;font-size:12px;font-weight:650;display:block}.bootstrap-mode button{border:1px solid var(--admin-line);text-align:left;min-height:72px;color:var(--admin-muted);cursor:pointer;background:#fff;border-radius:12px;padding:13px 14px;transition:border-color .18s,background .18s,color .18s,box-shadow .18s}.bootstrap-mode button b{color:var(--admin-ink);font-size:12px;display:block}.bootstrap-mode button small{margin-top:5px;font-size:11px;line-height:1.45;display:block}.bootstrap-mode button.active{border-color:color-mix(in oklch, var(--admin-vital) 46%, var(--admin-line));color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-muted));background:var(--admin-soft);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.bootstrap-mode button.active b{color:var(--admin-vital)}.bootstrap-grid :is(input,select):focus{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line))}.bootstrap-model-field{min-width:0;position:relative}.bootstrap-model-field.open{z-index:20}.bootstrap-model-field>label{margin-bottom:8px;font-size:12px;font-weight:650;display:block}.bootstrap-model-combobox{position:relative}.bootstrap-model-listbox{z-index:20;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:color-mix(in oklch, white 92%, var(--admin-surface));max-height:min(240px,35dvh);box-shadow:0 18px 42px color-mix(in oklch, var(--admin-ink) 16%, transparent);border-radius:12px;gap:4px;margin:0;padding:6px;list-style:none;display:grid;position:absolute;top:calc(100% + 7px);left:0;right:0;overflow-y:auto}.bootstrap-model-listbox li{min-height:44px;color:var(--admin-ink);font:600 11px/1.35 var(--admin-data);overflow-wrap:anywhere;cursor:pointer;border-radius:9px;padding:10px 11px}.bootstrap-model-listbox li[role=option]{justify-content:space-between;align-items:center;gap:10px;display:flex}.bootstrap-model-listbox li.active{background:color-mix(in oklch, var(--admin-vital) 12%, white);color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-ink))}.bootstrap-model-listbox li.selected{font-weight:760}.bootstrap-model-listbox li.selected svg{color:var(--admin-vital);flex:none}.bootstrap-model-empty{color:var(--admin-muted);cursor:default;align-items:center;display:flex}.bootstrap-form>.primary{width:100%;min-height:48px}.bootstrap-model-warning{border:1px solid color-mix(in oklch, var(--admin-amber) 32%, var(--admin-line));color:color-mix(in oklch, var(--admin-amber) 68%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 8%, var(--admin-surface));border-radius:10px;align-items:flex-start;gap:10px;padding:13px 14px;display:flex}.bootstrap-model-warning>svg{flex:none;margin-top:1px}.bootstrap-model-warning b{color:var(--admin-ink);font-size:12px;display:block}.bootstrap-model-warning p{color:var(--admin-muted);margin:4px 0 10px;font-size:11px;line-height:1.55}.bootstrap-model-warning label{color:var(--admin-ink);cursor:pointer;align-items:flex-start;gap:8px;font-size:11px;line-height:1.45;display:flex}.bootstrap-model-warning input{width:15px;height:15px;accent-color:var(--admin-vital);margin:1px 0 0}.bootstrap-footnote{color:var(--admin-muted);align-items:center;gap:7px;margin:22px 0 0;font-size:10px;display:flex}.bootstrap-restarting{text-align:center;align-self:center;max-width:430px}.bootstrap-restarting .access-step{margin:32px 0 18px}.restart-orbit{width:96px;height:96px;color:var(--admin-vital);place-items:center;margin:0 auto;display:grid;position:relative}.restart-orbit i{opacity:.35;border:1px solid;border-radius:45% 55%;animation:3s linear infinite admin-orbit;position:absolute;inset:5px}.restart-orbit i:last-child{animation-duration:2s;animation-direction:reverse;inset:18px}.primary,.ghost,.danger-outline,.danger-solid{border:1px solid #0000;border-radius:10px;justify-content:center;align-items:center;gap:8px;min-height:40px;padding:0 16px;font-size:12px;font-weight:680;transition:transform .16s,border-color .16s,background .16s,color .16s;display:inline-flex}.primary{color:#fff;background:var(--admin-ink);box-shadow:0 7px 18px color-mix(in oklch, var(--admin-ink) 18%, transparent)}.primary:hover{background:color-mix(in oklch, var(--admin-vital) 24%, var(--admin-ink));transform:translateY(-1px)}.primary:disabled,.danger-solid:disabled{cursor:not-allowed;opacity:.42;box-shadow:none;transform:none}.ghost{color:var(--admin-ink);border-color:var(--admin-line);background:var(--admin-surface)}.ghost:hover{border-color:color-mix(in oklch, var(--admin-vital) 50%, var(--admin-line));color:var(--admin-vital)}.danger-outline{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-surface));background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface))}.danger-outline:hover{border-color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 9%, var(--admin-surface))}.danger-solid{color:#fff;background:var(--admin-red)}.primary.mini,.ghost.mini{min-height:30px;font-size:var(--admin-type-caption);border-radius:8px;padding:0 10px}.admin-shell{background:radial-gradient(circle at 96% 4%, color-mix(in oklch, var(--admin-vital) 8%, transparent), transparent 28vw), radial-gradient(circle at 72% 96%, color-mix(in oklch, var(--admin-sky) 9%, transparent), transparent 30vw), var(--admin-paper);grid-template-columns:216px minmax(0,1fr);display:grid}.admin-sidebar{z-index:30;height:100dvh;color:color-mix(in oklch, var(--admin-paper) 84%, var(--admin-ink));background:var(--admin-ink);--scrollbar-thumb:#ffffff32;--scrollbar-thumb-hover:color-mix(in oklch, var(--admin-vital) 58%, transparent);--scrollbar-track:transparent;flex-direction:column;padding:20px 14px 16px;display:flex;position:sticky;top:0;overflow:hidden}.admin-sidebar:after{content:"";background:linear-gradient(transparent 3%, color-mix(in oklch, var(--admin-vital) 72%, transparent) 24%, color-mix(in oklch, var(--admin-vital) 12%, transparent) 70%, transparent);opacity:.8;width:1px;height:100%;position:absolute;top:0;right:0}.life-quiet .admin-sidebar:after{background:linear-gradient(#0000,#ffffff16,#0000)}.life-resting .admin-sidebar:after{background:linear-gradient(transparent, color-mix(in oklch, var(--admin-amber) 50%, transparent), transparent)}.admin-brand{color:inherit;align-items:center;gap:12px;padding:6px 7px 25px;text-decoration:none;display:flex}.brand-mark{width:42px;height:42px;color:color-mix(in oklch, var(--admin-vital) 76%, white);background:#ffffff0a;border:1px solid #ffffff1d;border-radius:13px;flex:none;place-items:center;display:grid;position:relative}.brand-mark i{border:2px solid var(--admin-ink);background:var(--admin-vital);width:9px;height:9px;box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 16%, transparent);border-radius:50%;position:absolute;bottom:-2px;right:-2px}.life-resting .brand-mark i{background:var(--admin-amber)}.life-quiet .brand-mark i{box-shadow:none;background:#718084}.admin-brand b,.admin-brand span{display:block}.admin-brand b{max-width:132px;color:var(--admin-paper);font:650 14px var(--admin-display);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.admin-brand span{color:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-ink));letter-spacing:.1em;margin-top:3px;font-size:10px}.admin-sidebar nav{scrollbar-gutter:stable;gap:14px;padding:2px 4px 2px 0;display:grid;overflow-y:auto}.admin-sidebar nav::-webkit-scrollbar{width:6px}.admin-sidebar nav::-webkit-scrollbar-thumb{border-width:1px}.nav-group{gap:3px;display:grid}.nav-group>p{color:color-mix(in oklch, var(--admin-paper) 34%, var(--admin-ink));font:650 var(--admin-type-micro) var(--admin-data);letter-spacing:.08em;margin:0 0 4px;padding:0 11px}.nav-group button{width:100%;min-height:42px;color:color-mix(in oklch, var(--admin-paper) 62%, var(--admin-ink));text-align:left;background:0 0;border:1px solid #0000;border-radius:10px;align-items:center;gap:10px;padding:0 10px;transition:color .16s,background .16s,border-color .16s;display:flex;position:relative}.nav-group button:hover{color:var(--admin-paper);background:#ffffff0a}.nav-group button.active{color:var(--admin-paper);background:color-mix(in oklch, var(--admin-vital) 19%, transparent);border-color:#ffffff12}.nav-group button.active:before{content:"";background:var(--admin-vital);border-radius:0 999px 999px 0;width:3px;height:20px;position:absolute;left:-14px}.nav-group button span{flex:1;min-width:0;font-size:12px;font-weight:560}.nav-group button.active span{font-weight:680}.nav-chevron{opacity:0;transition:opacity .16s,transform .16s;transform:translate(-3px)}.nav-group button:hover .nav-chevron,.nav-group button.active .nav-chevron{opacity:.62;transform:translate(0)}.sidebar-foot{border-top:1px solid #ffffff12;gap:13px;margin-top:auto;padding:16px 8px 1px;display:grid}.sidebar-foot>span,.sidebar-foot button{color:color-mix(in oklch, var(--admin-paper) 44%, var(--admin-ink));text-align:left;background:0 0;border:0;align-items:center;gap:8px;font-size:10px;display:flex}.sidebar-foot button{padding:0}.sidebar-foot button:hover{color:color-mix(in oklch, var(--admin-paper) 86%, var(--admin-ink))}.sidebar-presence>i{background:var(--admin-vital);width:7px;height:7px;box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 14%, transparent);border-radius:50%}.life-resting .sidebar-presence>i{background:var(--admin-amber);box-shadow:none}.life-quiet .sidebar-presence>i{box-shadow:none;background:#718084}.admin-main{min-width:0}.admin-topbar{z-index:20;border-bottom:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-paper) 88%, transparent);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px);justify-content:space-between;align-items:center;gap:28px;min-height:82px;padding:13px 34px;display:flex;position:sticky;top:0}.topbar-title{min-width:0}.topbar-title .eyebrow{margin-bottom:5px;font-size:9px}.topbar-title h1{font:660 22px/1.2 var(--admin-display);letter-spacing:-.035em;margin:0;display:inline}.topbar-title>span{color:var(--admin-muted);margin-left:14px;font-size:12px}.topbar-actions{align-items:center;gap:22px;display:flex}.topbar-actions>a{color:var(--admin-muted);white-space:nowrap;align-items:center;gap:5px;font-size:11px;text-decoration:none;display:inline-flex}.topbar-actions>a:hover{color:var(--admin-vital)}.admin-language-toggle{border:1px solid var(--admin-line);min-height:34px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 92%, var(--admin-paper));box-shadow:0 5px 16px color-mix(in oklch, var(--admin-ink) 7%, transparent);cursor:pointer;border-radius:999px;align-items:center;gap:6px;padding:0 10px;font-size:11px;transition:color .16s,border-color .16s,background .16s,transform .16s;display:inline-flex}.admin-language-toggle svg{color:var(--admin-vital)}.admin-language-toggle>span{white-space:nowrap}.admin-language-toggle:hover{color:var(--admin-ink);border-color:color-mix(in oklch, var(--admin-vital) 43%, var(--admin-line));background:var(--admin-soft);transform:translateY(-1px)}.admin-language-toggle:active{transform:translateY(0)}.admin-language-toggle-floating{z-index:100;position:fixed;top:18px;right:18px}.shell-life{color:var(--admin-muted);align-items:center;gap:11px;display:flex}.shell-life>span{white-space:nowrap;align-items:center;gap:7px;font-size:10px;display:inline-flex}.shell-life>span>i{background:var(--admin-vital);border-radius:50%;width:6px;height:6px}.life-resting .shell-life>span>i{background:var(--admin-amber)}.life-quiet .shell-life>span>i{background:#839093}.life-trace{justify-content:center;align-items:center;gap:4px;width:132px;height:26px;display:flex;position:relative;overflow:hidden}.life-trace:before{content:"";background:color-mix(in oklch, var(--admin-vital) 32%, var(--admin-line));height:1px;position:absolute;left:0;right:0}.life-trace:after{content:"";background:var(--admin-vital);width:8px;height:8px;box-shadow:0 0 12px color-mix(in oklch, var(--admin-vital) 65%, transparent);border-radius:50%;animation:2.4s linear infinite trace-travel;position:absolute;left:-12px}.life-trace i{z-index:1;background:color-mix(in oklch, var(--admin-vital) 75%, var(--admin-surface));border-radius:99px;width:2px;height:4px}.life-trace i:nth-child(2),.life-trace i:nth-child(6){height:10px}.life-trace i:nth-child(3),.life-trace i:nth-child(5){height:18px}.life-trace i:nth-child(4){height:8px}.life-resting .life-trace:before{background:color-mix(in oklch, var(--admin-amber) 32%, var(--admin-line))}.life-resting .life-trace:after{background:var(--admin-amber);animation-duration:5s}.life-resting .life-trace i{background:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-surface))}.life-quiet .life-trace:after{display:none}.life-quiet .life-trace i{background:#9aa6a7;height:3px}@keyframes trace-travel{to{transform:translate(154px)}}.admin-content{box-sizing:border-box;width:100%;max-width:2560px;margin-inline:auto;padding:28px clamp(34px,2.4vw,64px) 72px}.page-stack{gap:18px;display:grid}.presence-hero{--presence-color:var(--admin-vital);border:1px solid var(--admin-line);background:linear-gradient(90deg, color-mix(in oklch, var(--admin-ink) 4%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in oklch, var(--admin-ink) 4%, transparent) 1px, transparent 1px), var(--admin-surface);background-size:34px 34px;border-radius:18px;grid-template-columns:minmax(0,1fr) 330px;align-items:stretch;min-height:248px;display:grid;position:relative;overflow:hidden}.presence-hero.resting{--presence-color:var(--admin-amber)}.presence-hero.quiet{--presence-color:#869396}.presence-copy{z-index:1;align-self:center;padding:34px 38px;position:relative}.presence-copy .eyebrow{color:color-mix(in oklch, var(--presence-color) 72%, var(--admin-muted))}.presence-copy h1{font:670 clamp(36px, 4vw, 50px)/1.08 var(--admin-display);letter-spacing:-.05em;flex-wrap:wrap;align-items:center;gap:13px;margin:0;display:flex}.live-badge{border:1px solid color-mix(in oklch, var(--presence-color) 30%, transparent);color:color-mix(in oklch, var(--presence-color) 82%, var(--admin-ink));background:color-mix(in oklch, var(--presence-color) 10%, var(--admin-surface));font:650 10px var(--admin-body);letter-spacing:.03em;border-radius:999px;padding:6px 10px}.presence-readout{flex-wrap:wrap;gap:0;margin-top:32px;display:flex}.presence-readout>div{border-left:1px solid var(--admin-line);min-width:135px;padding:0 24px}.presence-readout>div:first-child{border-left:0;min-width:220px;padding-left:0}.presence-readout span,.presence-readout strong{display:block}.presence-readout span{color:var(--admin-muted);margin-bottom:6px;font-size:10px}.presence-readout strong{max-width:260px;font:610 12px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.pulse-organ{z-index:1;border-left:1px solid var(--admin-line);background:color-mix(in oklch, var(--presence-color) 7%, var(--admin-surface));justify-content:center;align-items:center;gap:8px;min-height:248px;display:flex;position:relative;overflow:hidden}.pulse-organ:before,.pulse-organ:after{content:"";border:1px solid color-mix(in oklch, var(--presence-color) 18%, transparent);border-radius:50%;width:210px;height:210px;position:absolute}.pulse-organ:after{width:150px;height:150px}.organ-line{background:color-mix(in oklch, var(--presence-color) 24%, var(--admin-line));height:1px;position:absolute;left:30px;right:30px}.pulse-organ>i{z-index:1;width:4px;height:var(--h);background:var(--presence-color);max-height:92px;box-shadow:0 0 14px color-mix(in oklch, var(--presence-color) 25%, transparent);animation:vital-beat 1.8s ease-in-out var(--d) infinite;border-radius:999px}.presence-hero.resting .pulse-organ>i{animation-duration:4s}.presence-hero.quiet .pulse-organ>i{opacity:.42;animation:none;transform:scaleY(.35)}.pulse-organ small{color:color-mix(in oklch, var(--presence-color) 55%, var(--admin-muted));font:650 9px var(--admin-data);letter-spacing:.12em;position:absolute;bottom:17px;right:20px}.presence-hero>.icon-btn{z-index:3;position:absolute;top:14px;right:14px}@keyframes vital-beat{0%,to{opacity:.5;transform:scaleY(.46)}42%{opacity:1;transform:scaleY(1)}}.vital-grid{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:16px;grid-template-columns:repeat(5,minmax(0,1fr));gap:1px;display:grid;overflow:hidden}.vital{background:var(--admin-surface);grid-template-columns:1fr auto;align-items:start;min-height:138px;padding:19px 20px;transition:background .16s;display:grid}.vital:hover{background:var(--admin-soft)}.vital>svg{color:var(--admin-vital)}.vital>span{color:var(--admin-muted);text-align:right;font-size:11px}.vital>strong{font:660 30px/1 var(--admin-data);letter-spacing:-.04em;font-variant-numeric:tabular-nums;grid-column:1/-1;margin-top:13px}.vital>small{color:var(--admin-muted);grid-column:1/-1;margin-top:8px;font-size:11px}.two-col{grid-template-columns:1fr 1fr;gap:18px;display:grid}.admin-panel{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:16px;min-width:0;padding:24px}.admin-panel>header{justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:22px;display:flex}.admin-panel>header h2{font:660 17px/1.3 var(--admin-display);letter-spacing:-.02em;margin:0}.admin-panel>header p{max-width:680px;color:var(--admin-muted);margin:6px 0 0;font-size:12px;line-height:1.6}.memory-meter{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:11px;grid-template-columns:repeat(3,1fr);gap:1px;display:grid;overflow:hidden}.memory-meter div{background:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-surface));padding:15px}.memory-meter span,.memory-meter b{display:block}.memory-meter span{color:var(--admin-muted);font-size:11px}.memory-meter b{font:620 13px var(--admin-data);margin-top:8px}.runtime-clock{color:var(--admin-vital);align-items:center;gap:17px;padding:11px;display:flex}.runtime-clock b,.runtime-clock span{display:block}.runtime-clock b{color:var(--admin-ink);font:620 14px var(--admin-data);font-variant-numeric:tabular-nums}.runtime-clock span{color:var(--admin-muted);margin-top:5px;font-size:11px}.notice{border:1px solid #0000;border-radius:10px;align-items:center;gap:9px;min-height:42px;padding:10px 13px;font-size:12px;line-height:1.5;display:flex}.notice.amber{color:color-mix(in oklch, var(--admin-amber) 72%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-amber) 34%, var(--admin-surface));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface))}.notice.success{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-vital) 28%, var(--admin-surface));background:color-mix(in oklch, var(--admin-vital) 8%, var(--admin-surface))}.notice.error{color:color-mix(in oklch, var(--admin-red) 82%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-red) 28%, var(--admin-surface));background:color-mix(in oklch, var(--admin-red) 7%, var(--admin-surface))}.state-box{border:1px dashed var(--admin-line);min-height:180px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 82%, transparent);border-radius:15px;align-content:center;place-items:center;gap:12px;font-size:12px;display:grid}.state-box.error{color:var(--admin-red)}.state-pulse{align-items:center;gap:4px;height:24px;display:flex}.state-pulse i{background:var(--admin-vital);border-radius:99px;width:3px;height:9px;animation:1.1s ease-in-out infinite state-beat}.state-pulse i:nth-child(2){height:21px;animation-delay:.1s}.state-pulse i:nth-child(3){height:13px;animation-delay:.2s}@keyframes state-beat{50%{opacity:.45;transform:scaleY(.45)}}.active-model{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:var(--admin-soft);border-radius:12px;align-items:center;gap:15px;padding:17px;display:flex}.active-model svg{color:var(--admin-vital)}.active-model span,.active-model strong{display:block}.active-model span{color:var(--admin-muted);font-size:11px}.active-model strong{font:650 15px var(--admin-data);margin-top:5px}.inline-form{grid-template-columns:1fr 1.3fr auto;gap:10px;margin-top:15px;display:grid}.inline-form input,.inline-form select,.field input,.field select,.field textarea,.inline-add input,.search-box input,.log-filters input,.log-filters select,.row-actions select,.editor-meta input,.tiny-input{border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);background:#fff;border-radius:9px;outline:0;padding:9px 12px;font-size:13px;transition:border-color .16s,box-shadow .16s,background .16s}.inline-form :where(input,select):focus,.field :where(input,select,textarea):focus,.inline-add input:focus,.search-box input:focus,.log-filters :where(input,select):focus,.editor-meta input:focus,.source-editor:focus,.code-area:focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.field-grid{grid-template-columns:1fr 1fr;align-items:start;gap:15px;display:grid}.field{align-content:start;align-self:start;gap:var(--admin-field-gap);min-width:0;display:grid}.field>span{min-height:var(--admin-field-label-height);color:color-mix(in oklch, var(--admin-ink) 84%, var(--admin-muted));align-items:center;gap:7px;font-size:12px;font-weight:650;line-height:1.5;display:flex}.field>:where(input,select,textarea){box-sizing:border-box;width:100%}.field small{color:var(--admin-muted);font-size:11px;line-height:1.45}.effect-badge{min-height:19px;font:8px var(--admin-data);white-space:nowrap;border-radius:5px;align-items:center;padding:0 6px;font-style:normal;display:inline-flex}.effect-badge.hot{color:var(--admin-vital);background:var(--admin-soft)}.provider-editor-head .effect-badge{vertical-align:2px;margin-left:6px}.field textarea{resize:vertical;min-height:105px}.switch{min-height:42px;color:var(--admin-muted);cursor:pointer;align-self:end;align-items:center;gap:9px;font-size:12px;display:flex;position:relative}.switch input{opacity:0;pointer-events:none;position:absolute}.switch>i{background:color-mix(in oklch, var(--admin-ink) 16%, var(--admin-surface));border-radius:99px;width:38px;height:22px;padding:3px;transition:background .18s}.switch>i:after{content:"";background:#fff;border-radius:50%;width:16px;height:16px;transition:transform .18s;display:block;box-shadow:0 2px 5px #0002}.switch input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 28%, transparent);outline-offset:2px}.switch input:checked+i{background:var(--admin-vital)}.switch input:checked+i:after{transform:translate(16px)}.code-area{border:1px solid var(--admin-line);width:100%;min-height:180px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-paper) 62%, var(--admin-surface));font:12px/1.7 var(--admin-data);resize:vertical;border-radius:10px;outline:0;padding:14px}.code-area.short{min-height:112px}.code-area.compact{min-height:128px}.code-area.invalid{border-color:color-mix(in oklch, var(--admin-red) 55%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 3%, white)}.field .field-error{color:var(--admin-red)}.panel-actions{justify-content:flex-end;align-items:center;gap:9px;margin-top:18px;display:flex}.settings-layout{grid-template-columns:188px minmax(0,1fr);align-items:start;gap:18px;display:grid}.subnav{border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);border-radius:13px;gap:3px;padding:7px;display:grid;position:sticky;top:104px}.subnav button{min-height:40px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:8px;justify-content:space-between;align-items:center;gap:8px;padding:0 10px;font-size:11px;display:flex}.subnav button:hover{color:var(--admin-ink);background:var(--admin-paper)}.subnav button.active{color:var(--admin-vital);background:var(--admin-soft);font-weight:680}.subnav button:disabled{opacity:.58;cursor:wait}.subnav button>span{align-items:center;gap:7px;display:inline-flex}.settings-dirty-dot{background:var(--admin-amber);width:6px;height:6px;box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-amber) 13%, transparent);border-radius:50%}.config-panel{max-width:1020px}.config-override-strip{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:color-mix(in oklch, var(--admin-soft) 58%, var(--admin-surface));border-radius:11px;gap:10px;margin-top:12px;padding:12px;display:grid}.config-override-strip>div:first-child{color:var(--admin-vital);align-items:flex-start;gap:9px;display:flex}.config-override-strip>div:first-child svg{flex:none;margin-top:1px}.config-override-strip b,.config-override-strip small{display:block}.config-override-strip b{font-size:11px}.config-override-strip small{color:var(--admin-muted);margin-top:2px;font-size:10px;line-height:1.45}.config-override-strip>div:last-child{flex-wrap:wrap;gap:6px;display:flex}.config-override-strip button{border:1px solid var(--admin-line);min-height:29px;color:var(--admin-muted);background:var(--admin-surface);cursor:pointer;border-radius:7px;align-items:center;gap:6px;padding:5px 8px;font-size:9px;display:inline-flex}.config-override-strip button:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line))}.config-override-strip button.pending{color:color-mix(in oklch, var(--admin-amber) 74%, var(--admin-ink));border-color:color-mix(in oklch, var(--admin-amber) 38%, var(--admin-line));background:color-mix(in oklch, var(--admin-amber) 7%, var(--admin-surface))}.config-override-strip button:focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:1px}.config-fields{grid-template-columns:1fr 1fr;align-items:start;gap:16px;margin-top:17px;display:grid}.config-switch{margin-top:calc(var(--admin-field-label-height) + var(--admin-field-gap));align-self:start}.config-fields .field:has(.code-area){grid-column:1/-1}.string-list-field,.transport-list-field{grid-column:1/-1}.string-list-editor{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;overflow:hidden}.string-list-items{display:grid}.string-list-item{border-bottom:1px solid var(--admin-line);grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;min-height:42px;padding:7px 8px 7px 12px;display:grid}.string-list-item code{overflow-wrap:anywhere;color:var(--admin-ink);font:11px/1.5 var(--admin-data)}.string-list-item button{width:28px;height:28px;color:var(--admin-muted);cursor:pointer;background:0 0;border:0;border-radius:7px;place-items:center;display:grid}.string-list-item button:hover{color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 7%, var(--admin-surface))}.string-list-item button:focus-visible{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:1px}.string-list-empty{border-bottom:1px dashed var(--admin-line);color:var(--admin-muted);text-align:center;padding:18px 12px;font-size:11px}.string-list-add{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface));grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:9px;display:grid}.string-list-add input{border:1px solid var(--admin-line);min-width:0;min-height:38px;color:var(--admin-ink);font:11px var(--admin-data);background:#fff;border-radius:8px;outline:0;padding:8px 10px}.string-list-add input:focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.string-list-add button{white-space:nowrap}.transport-list-editor{grid-template-columns:1fr 1fr;gap:9px;display:grid}.transport-list-editor>label{border:1px solid var(--admin-line);background:var(--admin-surface);cursor:pointer;border-radius:10px;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:10px;min-height:62px;padding:10px 12px;display:grid}.transport-list-editor>label:has(input:checked){border-color:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));background:var(--admin-soft)}.transport-list-editor input{opacity:0;pointer-events:none;position:absolute}.transport-list-editor i{border:1px solid var(--admin-line);color:#0000;background:#fff;border-radius:6px;place-items:center;width:22px;height:22px;display:grid}.transport-list-editor input:checked+i{color:#fff;border-color:var(--admin-vital);background:var(--admin-vital)}.transport-list-editor input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:2px}.transport-list-editor b,.transport-list-editor small{display:block}.transport-list-editor b{font:650 11px var(--admin-data)}.transport-list-editor small{color:var(--admin-muted);margin-top:3px;font-size:10px}.llm-config-overview{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:13px;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:20px;margin-top:17px;padding:20px;display:grid}.llm-config-copy{align-items:flex-start;gap:13px;display:flex}.llm-config-copy>svg{color:var(--admin-vital);flex:none;margin-top:2px}.llm-config-copy span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.11em}.llm-config-copy h3{font:20px var(--admin-display);margin:5px 0}.llm-config-copy p{max-width:610px;color:var(--admin-muted);margin:0;font-size:11px;line-height:1.65}.llm-config-facts{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:9px;grid-template-columns:repeat(3,minmax(100px,auto));display:grid;overflow:hidden}.llm-config-facts span{border-right:1px solid var(--admin-line);min-width:0;color:var(--admin-muted);padding:11px 12px;font-size:9px}.llm-config-facts span:last-child{border-right:0}.llm-config-facts b{color:var(--admin-ink);font:650 11px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-bottom:5px;display:block;overflow:hidden}.config-section-heading{border-bottom:1px solid var(--admin-line);grid-column:1/-1;align-items:center;min-height:42px;padding-bottom:8px;display:flex}.config-section-heading b,.config-section-heading small{display:block}.config-section-heading b{font-size:13px}.config-section-heading small{color:var(--admin-muted);margin-top:4px;font-size:10px}.weixin-settings{gap:14px;display:grid}.weixin-overview{border:1px solid var(--admin-line);background:linear-gradient(120deg, color-mix(in oklch, #19ad67 7%, var(--admin-soft)), var(--admin-surface) 70%);border-radius:14px;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;padding:18px;display:grid}.weixin-overview.warning{border-color:color-mix(in oklch, var(--admin-amber) 36%, var(--admin-line))}.weixin-overview.disabled{background:var(--admin-paper);border-style:dashed}.weixin-overview>div{align-items:flex-start;gap:12px;min-width:0;display:flex}.weixin-overview>div>svg{color:#19ad67;flex:none;margin-top:2px}.weixin-overview span small,.weixin-overview span b,.weixin-overview span p{display:block}.weixin-overview span small{color:#168954;font:680 9px var(--admin-data);letter-spacing:.12em}.weixin-overview span b{font:18px var(--admin-display);margin-top:5px}.weixin-overview span p{color:var(--admin-muted);margin:4px 0 0;font-size:11px}.weixin-pairing{border:1px solid color-mix(in oklch, #19ad67 28%, var(--admin-line));background:linear-gradient(135deg, var(--admin-surface), color-mix(in oklch, #19ad67 6%, var(--admin-paper)));border-radius:15px;grid-template-columns:190px minmax(0,1fr);align-items:center;gap:24px;padding:20px;display:grid}.weixin-pairing.confirmed{border-color:color-mix(in oklch, #19ad67 42%, var(--admin-line))}.weixin-pairing.failed{border-color:color-mix(in oklch, var(--admin-amber) 42%, var(--admin-line));background:linear-gradient(135deg, var(--admin-surface), color-mix(in oklch, var(--admin-amber) 6%, var(--admin-paper)))}.weixin-qr-stage{aspect-ratio:1;border:1px solid var(--admin-line);background:#fff;border-radius:12px;place-items:center;padding:12px;display:grid;box-shadow:inset 0 0 0 5px #f4f7f5}.weixin-pairing.confirmed .weixin-qr-stage{color:#168954;background:oklch(97.6158% .0108966 155.498)}.weixin-pairing.failed .weixin-qr-stage{color:var(--admin-amber);background:color-mix(in oklch, var(--admin-amber) 7%, white)}.weixin-qr-stage img{object-fit:contain;width:100%;height:100%;display:block}.weixin-pairing-copy>span{color:#168954;font:680 9px var(--admin-data);letter-spacing:.12em}.weixin-pairing-copy h3{font:21px var(--admin-display);margin:7px 0}.weixin-pairing-copy p{max-width:620px;color:var(--admin-muted);margin:0 0 14px;font-size:11px;line-height:1.65}.weixin-verify{gap:8px;margin-bottom:10px;display:flex}.weixin-verify input{max-width:220px}.weixin-account-list{gap:8px;display:grid}.weixin-account-list article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:13px 14px;display:grid}.weixin-account-mark{color:#168954;background:color-mix(in oklch, #19ad67 10%, var(--admin-paper));border-radius:10px;place-items:center;width:38px;height:38px;display:grid}.weixin-account-copy input,.weixin-account-copy code,.weixin-account-copy small{display:block}.weixin-account-copy input{max-width:320px;min-height:32px;padding:4px 8px;font-weight:650}.weixin-account-copy code{color:var(--admin-muted);margin-top:5px;font-size:9px}.weixin-account-copy small{color:var(--admin-muted);margin-top:3px;font-size:9px}.provider-editor{grid-column:1/-1;gap:10px;display:grid}.provider-editor-head{justify-content:space-between;align-items:center;gap:16px;display:flex}.provider-editor-head b,.provider-editor-head small{display:block}.provider-editor-head b{font-size:15px}.provider-editor-head small{color:var(--admin-muted);margin-top:5px;font-size:11px;line-height:1.5}.provider-source-note{border-left:2px solid var(--admin-vital);color:var(--admin-vital);background:var(--admin-soft);align-items:flex-start;gap:10px;padding:12px 13px;display:flex}.provider-source-note p{margin:0}.provider-source-note b,.provider-source-note span{display:block}.provider-source-note b{color:var(--admin-ink);font-size:11px}.provider-source-note span{color:var(--admin-muted);margin-top:4px;font-size:10px;line-height:1.6}.provider-source-note code{color:var(--admin-ink);font:10px var(--admin-data)}.provider-effective{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:9px;flex-wrap:wrap;align-items:center;gap:8px;padding:11px 13px;display:flex}.provider-effective>b{width:100%;color:var(--admin-muted);font-size:10px}.provider-effective>span{background:var(--admin-soft);border-radius:7px;align-items:center;gap:8px;padding:6px 9px;font-size:10px;display:flex}.provider-effective strong{color:var(--admin-ink)}.provider-effective code{color:var(--admin-vital);font:10px var(--admin-data)}.provider-effective small{color:var(--admin-muted)}.provider-row{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:11px;grid-template-columns:1fr 1fr;align-items:start;gap:14px 16px;padding:18px 62px 18px 18px;display:grid;position:relative}.provider-row .field:nth-of-type(3){grid-column:1/-1}.provider-row .field>span{font-size:11px}.provider-row .field small{font-size:10px}.provider-remove{position:absolute;top:14px;right:14px}.provider-empty{border:1px dashed var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);text-align:center;border-radius:11px;padding:22px;font-size:11px}.desktop-update-settings{gap:16px;margin-top:17px;display:grid}.desktop-sync-overview{border:1px solid var(--admin-line);background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:14px;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);align-items:center;gap:18px;padding:18px;display:grid}.desktop-sync-overview.ready{border-color:color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line))}.desktop-sync-overview.warning{border-color:color-mix(in oklch, var(--admin-amber) 36%, var(--admin-line));background:color-mix(in oklch, var(--admin-amber) 6%, var(--admin-surface))}.desktop-sync-overview.disabled{background:var(--admin-paper);border-style:dashed}.desktop-sync-overview-copy{align-items:flex-start;gap:12px;min-width:0;display:flex}.desktop-sync-overview-copy>svg{color:var(--admin-vital);flex:none;margin-top:3px}.desktop-sync-overview.warning .desktop-sync-overview-copy>svg{color:var(--admin-amber)}.desktop-sync-overview-copy span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.12em}.desktop-sync-overview-copy h3{font:19px var(--admin-display);margin:5px 0}.desktop-sync-overview-copy p{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;margin:0;font-size:11px;line-height:1.6;overflow:hidden}.desktop-sync-overview-copy b{color:var(--admin-ink)}.desktop-source-toolbar{justify-content:space-between;align-items:center;gap:14px;display:flex}.desktop-source-toolbar b,.desktop-source-toolbar small{display:block}.desktop-source-toolbar b{font-size:14px}.desktop-source-toolbar small{color:var(--admin-muted);margin-top:4px;font-size:10px}.desktop-source-toolbar>div:last-child{flex-wrap:wrap;justify-content:flex-end;gap:8px;display:flex}.desktop-source-workbench{grid-template-columns:minmax(300px,.42fr) minmax(0,1fr);align-items:start;gap:14px;display:grid}.desktop-source-list{gap:9px;display:grid}.desktop-source-item{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;overflow:hidden}.desktop-source-item.selected{border-color:color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));box-shadow:inset 3px 0 0 var(--admin-vital)}.desktop-source-item.warning:not(.selected){border-color:color-mix(in oklch, var(--admin-amber) 30%, var(--admin-line))}.desktop-source-item>button{width:100%;min-height:66px;color:inherit;text-align:left;background:0 0;border:0;justify-content:space-between;align-items:flex-start;gap:10px;padding:12px;display:flex}.desktop-source-item>button:hover{background:var(--admin-paper)}.desktop-source-item b,.desktop-source-item small{display:block}.desktop-source-item b{color:var(--admin-ink);font-size:12px}.desktop-source-item small{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;margin-top:5px;font-size:10px;line-height:1.45;overflow:hidden}.desktop-source-badge{color:var(--admin-muted);background:var(--admin-paper);font:normal 9px var(--admin-data);border-radius:99px;flex:none;padding:4px 7px}.desktop-source-badge.active{color:var(--admin-vital);background:var(--admin-soft)}.desktop-source-badge.warning{color:color-mix(in oklch, var(--admin-amber) 75%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 11%, var(--admin-surface))}.desktop-source-row-actions{grid-template-columns:repeat(5,minmax(0,1fr));gap:5px;padding:0 10px 10px;display:grid}.desktop-source-row-actions .mini{min-width:0;padding-inline:6px;font-size:9px}.desktop-source-detail{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;min-width:0;padding:16px}.desktop-source-detail>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.desktop-source-detail>header span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.12em}.desktop-source-detail>header h3{font:18px var(--admin-display);margin:5px 0 4px}.desktop-source-detail>header p{overflow-wrap:anywhere;color:var(--admin-muted);font:10px var(--admin-data);margin:0}.desktop-source-detail .config-fields{margin-top:14px}.desktop-source-form{gap:16px;margin-top:16px;display:grid}.desktop-form-row{align-items:start;gap:16px;display:grid}.desktop-form-row.two{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.desktop-option-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.desktop-option-card{border:1px solid var(--admin-line);background:var(--admin-paper);cursor:pointer;border-radius:10px;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:4px 10px;min-height:72px;padding:12px;display:grid;position:relative}.desktop-option-card input{width:18px;height:18px;accent-color:var(--admin-vital);grid-row:1/span 2;margin:1px 0 0}.desktop-option-card span{color:var(--admin-ink);font-size:11px;font-weight:650}.desktop-option-card small{color:var(--admin-muted);font-size:9px;line-height:1.5}.desktop-option-card:has(input:checked){border-color:color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:var(--admin-soft)}.desktop-global-settings{gap:0;display:grid}.unit-field{grid-template-columns:minmax(0,1fr) 92px;gap:8px;display:grid}.field>small+small{margin-top:-3px}.desktop-source-empty{place-items:center;min-height:110px;display:grid}.admin-settings-status{gap:14px;margin-top:17px;display:grid}.admin-security-hero{border:1px solid color-mix(in oklch, var(--admin-vital) 25%, var(--admin-line));background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface) 68%);border-radius:13px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:16px;min-height:132px;padding:22px;display:grid;position:relative;overflow:hidden}.admin-security-hero:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";border-radius:50%;width:190px;height:190px;position:absolute;top:-70px;right:-40px}.admin-security-hero.missing{border-color:color-mix(in oklch, var(--admin-red) 28%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface))}.security-seal{border:1px solid color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));width:62px;height:62px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;place-items:center;display:grid;position:relative}.security-seal i{border:2px solid var(--admin-surface);background:var(--admin-vital);border-radius:50%;width:9px;height:9px;position:absolute;bottom:4px;right:4px}.admin-security-hero>div:nth-child(2)>span{color:var(--admin-vital);font:680 8px var(--admin-data);letter-spacing:.13em}.admin-security-hero h3{font:20px var(--admin-display);margin:6px 0 5px}.admin-security-hero p{max-width:620px;color:var(--admin-muted);margin:0;font-size:10px;line-height:1.6}.admin-security-hero>b{z-index:1;color:var(--admin-vital);background:var(--admin-surface);border-radius:6px;padding:6px 9px;font-size:9px;position:relative}.admin-security-hero.missing>b{color:var(--admin-red)}.admin-setting-cards{grid-template-columns:1fr 1fr;gap:10px;display:grid}.admin-setting-cards article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:11px;grid-template-columns:auto minmax(0,1fr);gap:11px;min-height:110px;padding:16px;display:grid}.admin-setting-cards article>svg{color:var(--admin-vital)}.admin-setting-cards span,.admin-setting-cards b,.admin-setting-cards code,.admin-setting-cards small{display:block}.admin-setting-cards span{color:var(--admin-muted);font-size:9px}.admin-setting-cards b,.admin-setting-cards code{overflow-wrap:anywhere;color:var(--admin-ink);font:650 11px/1.5 var(--admin-data);margin-top:6px}.admin-setting-cards small{color:var(--admin-muted);margin-top:7px;font-size:9px;line-height:1.55}.admin-security-note{border-left:2px solid var(--admin-amber);color:var(--admin-amber);background:color-mix(in oklch, var(--admin-amber) 6%, var(--admin-surface));align-items:flex-start;gap:10px;padding:13px;display:flex}.admin-security-note p{margin:0}.admin-security-note b,.admin-security-note span{display:block}.admin-security-note b{color:var(--admin-ink);font-size:10px}.admin-security-note span{color:var(--admin-muted);margin-top:4px;font-size:9px;line-height:1.55}.admin-security-note code{color:var(--admin-ink);font:9px var(--admin-data)}.config-panel>.panel-actions{z-index:4;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 10%, transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-radius:12px;margin:22px -8px -8px;padding:10px;position:sticky;bottom:20px}.tabbar{border:1px solid var(--admin-line);background:var(--admin-surface);scrollbar-width:none;border-radius:11px;gap:4px;width:max-content;max-width:100%;padding:5px;display:flex;overflow:auto}.tabbar::-webkit-scrollbar{display:none}.tabbar button{min-height:34px;color:var(--admin-muted);white-space:nowrap;background:0 0;border:0;border-radius:7px;padding:0 13px;font-size:11px}.tabbar button:hover{color:var(--admin-ink);background:var(--admin-paper)}.tabbar button.active{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));background:var(--admin-soft);font-weight:680}.tabbar.compact{width:100%}.tabbar.compact button{flex:1;padding:0 7px}.inline-add,.search-box,.log-filters{align-items:center;gap:8px;display:flex}.inline-add button,.search-box button{color:#fff;background:var(--admin-ink);border:0;border-radius:8px;min-height:38px;padding:0 13px;font-size:11px;font-weight:680}.record-list{border:1px solid var(--admin-line);border-radius:12px;gap:0;display:grid;overflow:hidden}.record{border:0;border-bottom:1px solid var(--admin-line);background:var(--admin-surface);justify-content:space-between;align-items:center;gap:17px;min-height:72px;padding:13px 15px;transition:background .16s;display:flex}.record:last-child{border-bottom:0}.record:hover{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface))}.record>div:first-child{min-width:0}.record b,.record small{display:block}.record b{overflow-wrap:anywhere;margin:6px 0 4px;font-size:13px;line-height:1.45}.record small{color:var(--admin-muted);font:10px/1.5 var(--admin-data)}.bubble-list{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;overflow:hidden}.bubble-record{border-bottom:1px solid var(--admin-line)}.bubble-record:last-child{border-bottom:0}.bubble-record-head{justify-content:space-between;align-items:center;gap:17px;min-height:72px;padding:13px 15px;transition:background .16s;display:flex}.bubble-record-head:hover,.bubble-record.open .bubble-record-head{background:color-mix(in oklch, var(--admin-paper) 52%, var(--admin-surface))}.bubble-record .record-main{min-width:0}.bubble-record-tags{align-items:center;gap:5px;display:flex}.bubble-mode{color:var(--admin-vital);background:var(--admin-soft);font:680 9px var(--admin-data);border-radius:99px;padding:4px 7px}.bubble-handoff-tag{border:1px solid color-mix(in oklch, var(--admin-vital) 26%, var(--admin-line));color:var(--admin-vital);background:color-mix(in oklch, var(--admin-vital) 7%, var(--admin-surface));font:680 9px var(--admin-data);border-radius:99px;align-items:center;gap:4px;padding:4px 7px;display:inline-flex}.bubble-record-title{overflow-wrap:anywhere;-webkit-line-clamp:2;-webkit-box-orient:vertical;margin:7px 0 5px;font-size:13px;line-height:1.5;display:-webkit-box;overflow:hidden}.bubble-record-routing{flex-wrap:wrap;gap:5px;margin:0 0 6px;display:flex}.bubble-record-routing>span{border:1px solid var(--admin-line);min-width:0;max-width:min(100%,320px);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-paper) 58%, var(--admin-surface));font:600 9px/1.3 var(--admin-data);border-radius:6px;align-items:center;gap:4px;padding:3px 6px;display:inline-flex}.bubble-record-routing code{color:var(--admin-ink);font:inherit;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.bubble-record-meta{color:var(--admin-muted);font:10px/1.5 var(--admin-data);text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.bubble-history{background:color-mix(in oklch, var(--admin-paper) 48%, var(--admin-surface));padding:12px 15px 16px}.bubble-history .short-message-list{max-height:640px}.bubble-history-loading{color:var(--admin-muted);text-align:center;padding:22px;font-size:11px}.bubble-load-more{min-width:180px;margin:14px auto 0;display:block}.status-pill{color:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 11%, var(--admin-surface));font:680 9px var(--admin-data);text-transform:uppercase;border-radius:99px;padding:4px 7px}.status-pill.running,.status-pill.in_progress{color:color-mix(in oklch, var(--admin-vital) 82%, var(--admin-ink));background:color-mix(in oklch, var(--admin-vital) 10%, var(--admin-surface))}.status-pill.error{color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 9%, var(--admin-surface))}.status-pill.done,.status-pill.completed{color:var(--admin-muted);background:var(--admin-paper)}.row-actions{align-items:center;gap:8px;display:flex}.task-compose{grid-template-columns:1fr 1.2fr auto;align-items:stretch;gap:9px;margin-bottom:14px;display:grid}.task-compose input,.task-compose textarea{border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);font:12px/1.5 var(--admin-body);background:#fff;border-radius:9px;outline:0;padding:9px 12px}.task-compose textarea{resize:vertical;height:42px}.task-compose :where(input,textarea):focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.list-toolbar{justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;display:flex}.task-filters{background:var(--admin-paper);border-radius:9px;align-items:center;gap:4px;padding:4px;display:flex}.task-filters button{min-height:30px;color:var(--admin-muted);background:0 0;border:0;border-radius:6px;padding:0 10px;font-size:10px}.task-filters button.active{color:var(--admin-vital);background:var(--admin-surface);font-weight:680;box-shadow:0 1px 5px oklch(22% .012 260/.05)}.record-main{flex:1;min-width:0}.record-details{max-width:720px;color:var(--admin-muted);-webkit-line-clamp:2;white-space:pre-line;-webkit-box-orient:vertical;margin:2px 0 5px;font-size:11px;line-height:1.55;display:-webkit-box;overflow:hidden}.task-record.completed .record-main>b{color:var(--admin-muted);text-decoration:line-through;-webkit-text-decoration-color:color-mix(in oklch, var(--admin-muted) 45%, transparent);text-decoration-color:color-mix(in oklch, var(--admin-muted) 45%, transparent)}.task-modal{width:min(560px,100vw - 32px)}.task-modal .field{margin-top:13px}.task-modal .field textarea{min-height:150px}.alarm-compose{grid-template-columns:1.15fr .75fr 1.6fr auto;align-items:end;gap:10px;display:grid}.alarm-compose .primary{min-height:42px}.alarm-summary{border-left:2px solid var(--admin-vital);min-height:46px;color:var(--admin-muted);background:linear-gradient(90deg, var(--admin-soft), transparent 55%);align-items:center;gap:8px;margin:14px 0 9px;padding:0 3px 0 11px;font-size:11px;display:flex}.alarm-summary svg{color:var(--admin-vital)}.alarm-summary span{flex:1}.alarm-summary .icon-btn{width:34px;height:34px}.alarm-record{grid-template-columns:76px minmax(0,1fr) auto;display:grid}.alarm-time{border-right:1px solid var(--admin-line);text-align:center;place-content:center;align-self:stretch;padding-right:15px;display:grid}.alarm-time strong,.alarm-time b{margin:0;display:block}.alarm-time strong{color:var(--admin-muted);font:9px var(--admin-data);text-transform:uppercase}.alarm-time b{color:var(--admin-ink);font:680 16px var(--admin-data);margin-top:4px}.alarm-due{color:var(--admin-vital);font:680 9px var(--admin-data);margin-bottom:1px;display:inline-block}.danger-icon,.icon-btn{border:1px solid var(--admin-line);width:38px;height:38px;color:var(--admin-muted);background:var(--admin-surface);border-radius:9px;flex:none;place-items:center;padding:0;transition:color .16s,border-color .16s,background .16s;display:grid}.icon-btn:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));background:var(--admin-soft)}.danger-icon:hover{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 6%, var(--admin-surface))}.empty{min-height:150px;color:color-mix(in oklch, var(--admin-muted) 75%, var(--admin-surface));background:var(--admin-surface);align-content:center;place-items:center;gap:8px;display:grid}.empty p{margin:0;font-size:12px}.memory-tabs{align-items:center}.memory-tabs button{justify-content:center;align-items:center;gap:6px;min-width:126px;display:flex}.short-watermark{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:linear-gradient(118deg, var(--admin-soft), var(--admin-surface) 58%);border-radius:16px;grid-template-columns:minmax(0,1.35fr) minmax(300px,.65fr);display:grid;position:relative;overflow:hidden}.short-watermark:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";pointer-events:none;border-radius:45% 55% 51% 49%;width:300px;height:300px;position:absolute;bottom:-190px;right:-60px;transform:rotate(18deg)}.watermark-reading{grid-template-columns:auto minmax(0,1fr);align-items:center;gap:24px;min-height:210px;padding:28px 30px;display:grid}.watermark-orbit{background:conic-gradient(var(--admin-vital) var(--water), color-mix(in oklch, var(--admin-line) 72%, transparent) 0);width:126px;height:126px;box-shadow:0 14px 40px color-mix(in oklch, var(--admin-vital) 11%, transparent);border-radius:50%;place-items:center;padding:7px;display:grid}.watermark-orbit>span{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, var(--admin-line));background:var(--admin-surface);border-radius:50%;align-content:center;place-items:center;width:100%;height:100%;display:grid}.watermark-orbit b{font:680 27px var(--admin-data);letter-spacing:-.04em}.watermark-orbit small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:5px}.watermark-reading h2{font:680 clamp(24px, 2.4vw, 36px) var(--admin-data);letter-spacing:-.04em;margin:8px 0 13px}.watermark-reading h2 small{color:var(--admin-muted);letter-spacing:0;font-size:.42em;font-weight:500}.watermark-reading>div:last-child>p:last-child{color:var(--admin-muted);font-size:var(--admin-type-caption);margin:9px 0 0}.watermark-track{background:var(--admin-line);border-radius:99px;width:min(480px,100%);height:5px;overflow:hidden}.watermark-track i{border-radius:inherit;background:linear-gradient(90deg, var(--admin-peach), var(--admin-vital));height:100%;display:block}.watermark-facts{z-index:1;border-left:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 84%, transparent);align-content:center;gap:0;padding:25px 56px 25px 24px;display:grid;position:relative}.watermark-facts span{border-bottom:1px solid var(--admin-line);padding:11px 0}.watermark-facts small,.watermark-facts b{display:block}.watermark-facts small{color:var(--admin-muted);font-size:var(--admin-type-micro)}.watermark-facts b{overflow-wrap:anywhere;font:610 var(--admin-type-small)/1.45 var(--admin-data);margin-top:4px}.watermark-facts p{color:var(--admin-muted);font-size:var(--admin-type-micro);align-items:flex-start;gap:6px;margin:12px 0 0;line-height:1.55;display:flex}.watermark-facts p svg{color:var(--admin-vital);flex:none;margin-top:1px}.watermark-refresh{z-index:2;position:absolute;top:14px;right:14px}.short-memory-grid{grid-template-columns:minmax(0,.92fr) minmax(0,1.08fr);align-items:start;gap:18px;display:grid}.short-tree-panel,.short-tail-panel{min-width:0}.short-tree,.short-message-list{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;max-height:720px;display:grid;overflow:auto}.short-tree-node{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);position:relative}.short-tree-node:last-child{border-bottom:0}.short-tree-node>summary{cursor:pointer;grid-template-columns:38px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:68px;padding:10px 12px;list-style:none;display:grid}.short-tree-node>summary::-webkit-details-marker{display:none}.short-message>summary::-webkit-details-marker{display:none}.pinned-context-list summary::-webkit-details-marker{display:none}.short-tree-node>summary:hover{background:var(--admin-paper)}.tree-level{border:1px solid color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line));width:32px;height:32px;color:var(--admin-vital);background:var(--admin-soft);font:680 var(--admin-type-caption) var(--admin-data);border-radius:50%;place-items:center;display:grid}.tree-node-copy{min-width:0}.tree-node-copy b,.tree-node-copy small{display:block}.tree-node-copy b{font:590 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.tree-node-copy small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:5px}.raw-state{color:var(--admin-vital);background:var(--admin-soft);font-size:var(--admin-type-micro);white-space:nowrap;border-radius:5px;padding:4px 6px}.raw-state.summary-only{color:var(--admin-muted);background:var(--admin-paper)}.tree-node-detail{padding:0 13px 13px 60px}.tree-node-detail>p{border-left:2px solid color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));color:var(--admin-ink);background:var(--admin-paper);font-size:var(--admin-type-small);white-space:pre-wrap;margin:0;padding:12px;line-height:1.7}.tree-children{border:1px solid var(--admin-line);border-radius:9px;margin-top:9px;overflow:hidden}.tree-children .short-tree-node>summary{min-height:60px;padding-left:calc(10px + var(--indent))}.short-message{border-bottom:1px solid var(--admin-line)}.short-message:last-child{border-bottom:0}.short-message>summary{cursor:pointer;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:10px;min-height:76px;padding:9px 12px;list-style:none;display:grid}.short-message>summary:hover{background:var(--admin-paper)}.message-index{color:color-mix(in oklch, var(--admin-muted) 64%, var(--admin-surface));font:680 var(--admin-type-micro) var(--admin-data)}.short-message>summary b,.short-message>summary small{display:block}.short-message>summary b{font-size:var(--admin-type-small)}.short-message>summary small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);margin-top:3px}.message-summary-copy{min-width:0}.message-preview{color:color-mix(in oklch, var(--admin-ink) 74%, var(--admin-muted));font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;margin-top:6px;font-style:normal;line-height:1.45;display:block;overflow:hidden}.short-message[open] .message-preview{visibility:hidden}.short-message>summary>i{max-width:130px;color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-micro);text-overflow:ellipsis;white-space:nowrap;border-radius:5px;padding:4px 6px;font-style:normal;overflow:hidden}.role-assistant>summary{border-left:2px solid var(--admin-vital)}.role-user>summary{border-left:2px solid var(--admin-sky)}.role-tool>summary{border-left:2px solid var(--admin-amber)}.role-system>summary{border-left:2px solid var(--admin-muted)}.short-message-body{padding:0 13px 13px 56px}.short-message-body pre,.pinned-context-list pre{color:var(--admin-ink);background:var(--admin-paper);font:var(--admin-type-caption)/1.7 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:8px;margin:0;padding:12px;overflow:auto}.short-message-body>p{color:var(--admin-muted);font:var(--admin-type-micro)/1.5 var(--admin-data);margin:8px 0 0}.message-reasoning,.message-tool-section{margin-top:9px}.message-reasoning>b,.message-tool-section>b{color:var(--admin-muted);font-size:var(--admin-type-micro);align-items:center;gap:5px;margin-bottom:6px;display:flex}.message-reasoning>pre{border-left:2px solid var(--admin-vital);color:var(--admin-muted)}.message-tools{gap:7px;margin-top:8px;display:grid}.tool-exchange{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:8px;overflow:hidden}.tool-exchange>summary{cursor:pointer;justify-content:space-between;align-items:center;gap:8px;min-height:34px;padding:6px 8px;list-style:none;display:flex}.tool-exchange>summary::-webkit-details-marker{display:none}.tool-exchange>summary span{color:var(--admin-vital);font:var(--admin-type-micro) var(--admin-data);align-items:center;gap:5px;display:inline-flex}.tool-exchange>summary small{color:var(--admin-muted);font-size:var(--admin-type-micro)}.tool-exchange>div{grid-template-columns:38px minmax(0,1fr);gap:6px 8px;padding:0 8px 8px;display:grid}.tool-exchange label{color:var(--admin-muted);font-size:var(--admin-type-micro);padding-top:8px}.tool-exchange pre{max-height:240px;font-size:var(--admin-type-micro);margin:0;padding:8px}.pinned-context-list{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.pin-compose{grid-template-columns:minmax(150px,.45fr) minmax(260px,1fr) auto;align-items:stretch;gap:9px;margin-bottom:14px;display:grid}.pin-compose :where(input,textarea){border:1px solid var(--admin-line);min-width:0;min-height:42px;color:var(--admin-ink);font:var(--admin-type-small)/1.5 var(--admin-body);background:#fff;border-radius:9px;outline:0;padding:9px 12px}.pin-compose textarea{resize:vertical;height:42px}.pin-compose :where(input,textarea):focus{border-color:var(--admin-vital);box-shadow:0 0 0 4px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.pinned-context-list details{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;min-width:0;overflow:hidden}.pinned-context-list summary{cursor:pointer;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;min-height:64px;padding:10px 12px;list-style:none;display:grid}.pinned-context-list summary>svg:first-child{color:var(--admin-vital)}.pinned-context-list .pin-delete{width:32px;height:32px;color:var(--admin-muted)}.pinned-context-list .pin-delete:hover{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 42%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 6%, var(--admin-surface))}.pinned-context-list summary b,.pinned-context-list summary small{display:block}.pinned-context-list summary b{font-size:var(--admin-type-small)}.pinned-context-list summary small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;overflow:hidden}.pinned-context-list details>pre{margin:0 12px 12px}.pinned-context-list details>p{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);overflow-wrap:anywhere;align-items:center;gap:5px;margin:0 12px 12px;display:flex}.memory-maintenance .mild{grid-template-columns:auto minmax(0,1fr) 76px auto}.memory-panel{overflow:hidden}.memory-search-stage{border:1px solid color-mix(in oklch, var(--admin-vital) 25%, var(--admin-line));background:linear-gradient(118deg, var(--admin-soft), var(--admin-surface) 62%);border-radius:15px;grid-template-columns:auto minmax(210px,.7fr) minmax(320px,1.3fr);align-items:center;gap:18px;margin:-2px 0 22px;padding:24px;display:grid;position:relative;overflow:hidden}.memory-search-stage:after{border:1px solid color-mix(in oklch, var(--admin-vital) 13%, transparent);content:"";pointer-events:none;border-radius:44% 56% 48% 52%;width:260px;height:260px;position:absolute;bottom:-126px;right:-72px;transform:rotate(22deg)}.memory-search-mark{border:1px solid color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line));width:58px;height:58px;color:var(--admin-vital);background:var(--admin-surface);box-shadow:0 10px 30px color-mix(in oklch, var(--admin-vital) 10%, transparent);border-radius:50%;place-items:center;display:grid;position:relative}.memory-search-mark i{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, transparent);border-radius:44% 56% 53% 47%;position:absolute;inset:-6px}.memory-search-mark i:last-child{opacity:.65;border-style:dashed;inset:-12px;transform:rotate(28deg)}.memory-search-copy{min-width:0}.memory-search-copy>span{color:var(--admin-vital);font:680 9px var(--admin-data);letter-spacing:.13em}.memory-search-copy h3{font:680 20px var(--admin-display);letter-spacing:-.025em;margin:5px 0 4px}.memory-search-copy p{color:var(--admin-muted);margin:0;font-size:10px;line-height:1.6}.memory-query{z-index:1;border:1px solid color-mix(in oklch, var(--admin-ink) 16%, var(--admin-surface));min-height:52px;color:var(--admin-muted);background:var(--admin-surface);box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 7%, transparent);border-radius:12px;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;padding:5px 5px 5px 15px;transition:border-color .16s,box-shadow .16s;display:grid;position:relative}.memory-query:focus-within{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line));box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 7%, transparent)}.memory-query input{min-width:0;height:40px;color:var(--admin-ink);background:0 0;border:0;outline:0;padding:0 11px;font-size:12px}.memory-query input:focus-visible{outline:0}.memory-query-clear{width:30px;height:30px;color:var(--admin-muted);background:0 0;border:0;border-radius:7px;place-items:center;padding:0;display:grid}.memory-query-clear:hover{color:var(--admin-ink);background:var(--admin-paper)}.memory-query-submit{color:#fff;background:var(--admin-ink);white-space:nowrap;border:0;border-radius:8px;align-items:center;gap:5px;min-height:40px;padding:0 13px;font-size:10px;font-weight:680;display:flex}.memory-query-submit:disabled{cursor:not-allowed;opacity:.45}.memory-examples{z-index:1;flex-wrap:wrap;grid-column:3;align-items:center;gap:6px;margin-top:-9px;display:flex}.memory-examples span{color:var(--admin-muted);margin-right:2px;font-size:9px}.memory-examples button{border:1px solid var(--admin-line);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-surface) 78%, transparent);border-radius:99px;padding:4px 8px;font-size:9px}.memory-examples button:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line))}.memory-notice{margin-bottom:14px}.memory-result-head{color:var(--admin-muted);justify-content:space-between;align-items:center;gap:14px;margin:0 2px 10px;display:flex}.memory-result-head>div{align-items:center;gap:7px;min-width:0;font-size:10px;display:flex}.memory-result-head>div span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-result-head b{color:var(--admin-vital);font:680 10px var(--admin-data);flex:none}.memory-results{grid-template-columns:1fr 1fr;gap:11px;display:grid}.memory-results article{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:12px;grid-template-columns:36px minmax(0,1fr);min-height:190px;transition:border-color .16s,transform .16s,box-shadow .16s;display:grid;overflow:hidden}.memory-results article:hover{border-color:color-mix(in oklch, var(--admin-vital) 30%, var(--admin-line));box-shadow:0 10px 28px color-mix(in oklch, var(--admin-ink) 6%, transparent);transform:translateY(-1px)}.memory-results article.editing{border-color:color-mix(in oklch, var(--admin-vital) 56%, var(--admin-line));box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 7%, transparent)}.memory-rank{border-right:1px solid var(--admin-line);color:color-mix(in oklch, var(--admin-muted) 55%, var(--admin-surface));background:color-mix(in oklch, var(--admin-paper) 58%, var(--admin-surface));font:680 9px var(--admin-data);text-align:center;padding-top:18px}.memory-card-body{flex-direction:column;min-width:0;padding:16px;display:flex}.memory-results article header,.memory-results article footer{align-items:center;gap:8px;display:flex}.memory-results article header{color:var(--admin-muted);font:680 9px var(--admin-data);letter-spacing:.05em;text-transform:uppercase;justify-content:space-between}.memory-results article header>span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-results article p{color:color-mix(in oklch, var(--admin-ink) 94%, var(--admin-muted));white-space:pre-line;flex:1;margin:15px 0 18px;font-size:13px;line-height:1.75}.memory-score{flex:none;align-items:center;gap:7px;display:flex}.memory-score>i{background:var(--admin-paper);border-radius:99px;width:42px;height:3px;overflow:hidden}.memory-score>i>b{border-radius:inherit;background:var(--admin-vital);height:100%;display:block}.memory-score small{font:9px var(--admin-data);letter-spacing:0;text-transform:none}.memory-id{max-width:110px;font:9px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.memory-results article footer{border-top:1px solid var(--admin-line);justify-content:space-between;margin-top:auto;padding-top:11px}.memory-tags{flex-wrap:wrap;gap:5px;min-width:0;display:flex}.memory-tags>i{color:var(--admin-muted);background:var(--admin-paper);border-radius:99px;padding:4px 7px;font-size:9px;font-style:normal}.memory-tags>span{color:color-mix(in oklch, var(--admin-muted) 65%, var(--admin-surface));font-size:9px}.memory-actions{flex:none;align-items:center;gap:5px;display:flex}.memory-actions .ghost{align-items:center;gap:5px;display:flex}.memory-actions .danger-icon{width:30px;height:30px}.memory-editor{flex:1;gap:10px;margin:12px 0;display:grid}.memory-editor label{gap:6px;display:grid}.memory-editor label>span{color:var(--admin-vital);font-size:9px;font-weight:680}.memory-edit{border:1px solid var(--admin-vital);width:100%;min-height:112px;color:var(--admin-ink);background:var(--admin-surface);font:12px/1.7 var(--admin-body);resize:vertical;border-radius:9px;outline:0;padding:11px}.memory-edit:focus{border-color:color-mix(in oklch, var(--admin-vital) 68%, var(--admin-line));box-shadow:none}.memory-edit:focus-visible{outline:0}.memory-tag-edit{border:1px solid var(--admin-line);width:100%;min-height:38px;color:var(--admin-ink);background:var(--admin-surface);border-radius:8px;outline:0;padding:8px 10px;font-size:11px}.memory-tag-edit:focus{border-color:color-mix(in oklch, var(--admin-vital) 58%, var(--admin-line))}.memory-tag-edit:focus-visible{outline:0}.memory-recalling,.memory-empty{border:1px dashed var(--admin-line);min-height:210px;color:var(--admin-muted);background:color-mix(in oklch, var(--admin-paper) 38%, var(--admin-surface));text-align:center;border-radius:12px;align-content:center;place-items:center;gap:8px;padding:28px;display:grid}.memory-recalling{grid-template-columns:auto auto}.memory-empty svg{color:color-mix(in oklch, var(--admin-vital) 60%, var(--admin-muted))}.memory-empty b{color:var(--admin-ink);font-size:13px}.memory-empty p{max-width:430px;margin:0;font-size:10px;line-height:1.65}.memory-empty.searched{background:var(--admin-surface)}.memory-footnote{color:var(--admin-muted);justify-content:flex-end;align-items:center;gap:6px;margin:13px 2px 0;font-size:9px;display:flex}.memory-footnote svg{color:var(--admin-vital)}.log-table{border:1px solid var(--admin-line);max-height:600px;font:11px/1.45 var(--admin-data);border-radius:11px;display:grid;overflow:auto}.log-table>div{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:84px 122px minmax(0,1fr);gap:12px;padding:10px 12px;display:grid}.log-table>div:last-child{border-bottom:0}.log-table>div:hover{background:var(--admin-soft)}.log-table>.log-head{z-index:2;color:color-mix(in oklch, var(--admin-paper) 84%, var(--admin-ink));background:var(--admin-ink);letter-spacing:.08em;font-size:9px;position:sticky;top:0}.log-table>.log-head:hover{background:var(--admin-ink)}.log-table time{color:var(--admin-muted);font-variant-numeric:tabular-nums}.event-type{color:var(--admin-vital)}.log-table code{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.history-navigator{border:1px solid color-mix(in oklch, var(--admin-vital) 20%, var(--admin-line));background:linear-gradient(100deg, var(--admin-soft), color-mix(in oklch, var(--admin-surface) 92%, var(--admin-soft)));border-radius:11px;justify-content:space-between;align-items:center;gap:14px;margin-bottom:12px;padding:11px 13px;display:flex}.history-position{align-items:center;gap:10px;min-width:0;display:flex}.history-position b,.history-position small{display:block}.history-position b{color:var(--admin-ink);font-size:11px}.history-detail-line{align-items:center;gap:8px;min-width:0;margin-top:3px;display:flex}.history-position small{color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.history-sequence-total{border-left:1px solid color-mix(in oklch, var(--admin-vital) 26%, var(--admin-line));color:color-mix(in oklch, var(--admin-vital) 76%, var(--admin-ink));font:680 9px var(--admin-data);font-variant-numeric:tabular-nums;white-space:nowrap;flex:none;padding-left:8px}.history-marker{border:1px solid color-mix(in oklch, var(--admin-vital) 34%, var(--admin-line));width:30px;height:30px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;flex:none;place-items:center;display:grid;position:relative}.history-marker:after{background:color-mix(in oklch, var(--admin-vital) 34%, transparent);content:"";width:1px;height:14px;position:absolute;top:100%;left:50%}.history-marker.earlier{color:var(--admin-amber);border-color:color-mix(in oklch, var(--admin-amber) 38%, var(--admin-line))}.history-actions{flex:none;gap:6px;display:flex}.history-actions button{align-items:center;gap:4px;display:inline-flex}.history-log-filters{flex-wrap:wrap;justify-content:flex-end;max-width:100%}.sequence-range{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));background:color-mix(in oklch, var(--admin-soft) 62%, var(--admin-surface));border-radius:10px;align-items:center;gap:5px;min-height:42px;padding:3px 4px 3px 8px;display:flex}.sequence-range label{align-items:center;gap:5px;min-width:0;display:flex}.sequence-range label>span{color:var(--admin-muted);font:680 8px var(--admin-data);letter-spacing:.04em;white-space:nowrap}.sequence-range input{width:86px;min-height:32px;font:11px var(--admin-data);font-variant-numeric:tabular-nums;border-radius:6px;flex:0 86px;padding:5px 8px}.sequence-separator{color:color-mix(in oklch, var(--admin-vital) 66%, var(--admin-muted));font:700 13px var(--admin-data)}.sequence-locate{white-space:nowrap;min-height:32px;padding-inline:9px}.history-sequence-error{margin-bottom:12px}.lifecycle-log-table{max-height:min(680px,100dvh - 310px)}.lifecycle-log-table>.log-head{grid-template-columns:142px 138px minmax(0,1fr) 70px}.lifecycle-log-table>article{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:142px 138px minmax(0,1fr) auto;align-items:start;gap:8px 12px;padding:10px 12px;transition:background .16s;display:grid}.lifecycle-log-table>article:hover,.lifecycle-log-table>article.open{background:color-mix(in oklch, var(--admin-soft) 64%, var(--admin-surface))}.lifecycle-log-table>article:last-child{border-bottom:0}.lifecycle-log-table>article>time{padding-top:2px}.lifecycle-log-table .event-type{text-overflow:ellipsis;white-space:nowrap;padding-top:2px;font-size:10px;overflow:hidden}.interaction-row-copy{min-width:0}.interaction-row-copy code{display:block}.interaction-row-copy small{color:var(--admin-muted);font:9px/1.35 var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:4px;display:block;overflow:hidden}.interaction-detail-toggle{align-self:start;min-width:52px}.interaction-detail{border:1px solid color-mix(in oklch, var(--admin-vital) 18%, var(--admin-line));background:color-mix(in oklch, var(--admin-paper) 50%, var(--admin-surface));border-radius:8px;grid-column:1/-1;margin-top:1px;padding:10px}.interaction-detail pre{max-height:360px;color:color-mix(in oklch, var(--admin-paper) 90%, var(--admin-ink));background:var(--admin-ink);font:10px/1.55 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:6px;margin:0;padding:11px;overflow:auto}.interaction-detail>small{color:var(--admin-amber);margin-top:8px;font-size:9px;display:block}.history-empty{grid-column:1/-1}.history-empty .empty-state{min-height:210px}.danger-list{gap:9px;display:grid}.danger-card{border:1px solid color-mix(in oklch, var(--admin-red) 30%, var(--admin-line));color:var(--admin-red);background:color-mix(in oklch, var(--admin-red) 5%, var(--admin-surface));border-radius:12px;align-items:center;gap:14px;padding:16px;display:flex}.danger-card.mild{color:var(--admin-ink);border-color:var(--admin-line);background:var(--admin-surface)}.danger-card>div{flex:1;min-width:0}.danger-card b{font-size:13px}.danger-card p{color:var(--admin-muted);margin:5px 0 0;font-size:11px;line-height:1.55}.danger-card small{color:var(--admin-vital);margin-top:6px;font-size:10px;display:block}.tiny-input{width:76px}.modal-layer{z-index:100;background:color-mix(in oklch, var(--admin-ink) 58%, transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);place-items:center;padding:20px;display:grid;position:fixed;inset:0}.confirm-modal{border:1px solid color-mix(in oklch, var(--admin-red) 24%, var(--admin-line));width:min(440px,100%);color:var(--admin-ink);background:var(--admin-surface);border-radius:17px;padding:29px;box-shadow:0 30px 90px #0004}.confirm-modal>svg{color:var(--admin-red)}.confirm-modal h3{font:660 20px var(--admin-display);margin:14px 0 7px}.confirm-modal>p{color:var(--admin-muted);margin:0 0 21px;font-size:12px;line-height:1.65}.identity-form{grid-template-columns:1fr 1fr;gap:16px;display:grid}.identity-form .field:nth-child(n+3){grid-column:1/-1}.identity-form textarea{min-height:140px}.system-prompt-facts{align-items:center;gap:9px;margin-bottom:12px;display:flex}.system-prompt-facts span,.system-prompt-facts em{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font:11px var(--admin-data);border-radius:9px;align-items:baseline;gap:5px;padding:6px 9px;font-style:normal;display:inline-flex}.system-prompt-facts b{color:var(--admin-ink);font-size:13px}.system-prompt-facts em{color:var(--admin-vital);margin-left:auto}.system-prompt-preview{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:13px;overflow:hidden}.system-prompt-preview summary{color:var(--admin-ink);cursor:pointer;grid-template-columns:auto 1fr auto;align-items:center;gap:9px;padding:13px 15px;list-style:none;display:grid}.system-prompt-preview summary::-webkit-details-marker{display:none}.system-prompt-preview summary svg{color:var(--admin-vital)}.system-prompt-preview summary span{font-size:13px;font-weight:680}.system-prompt-preview summary small{color:var(--admin-muted);font-size:11px}.system-prompt-preview[open] summary{border-bottom:1px solid var(--admin-line)}.system-prompt-preview pre{max-height:620px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-surface) 70%, var(--admin-paper));font:12px/1.7 var(--admin-data);white-space:pre-wrap;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text;margin:0;padding:18px;overflow:auto}.content-workspace{gap:14px;display:grid}.capability-strip{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:repeat(3,1fr);display:grid;overflow:hidden}.capability-strip button{border:0;border-right:1px solid var(--admin-line);min-height:78px;color:var(--admin-muted);text-align:left;background:0 0;grid-template-columns:1fr auto;gap:4px 12px;padding:15px 17px;display:grid;position:relative}.capability-strip button:last-child{border-right:0}.capability-strip button:after{background:var(--admin-vital);content:"";transform-origin:0;height:3px;transition:transform .2s;position:absolute;bottom:0;left:0;right:0;transform:scaleX(0)}.capability-strip button:hover{background:var(--admin-paper)}.capability-strip button.active{color:var(--admin-ink);background:linear-gradient(115deg, var(--admin-soft), var(--admin-surface) 72%)}.capability-strip button.active:after{transform:scaleX(1)}.capability-strip span{font-size:15px;font-weight:720}.capability-strip b{color:var(--admin-vital);font:680 16px var(--admin-data)}.capability-strip small{grid-column:1/-1;font-size:12px;font-weight:450;line-height:1.45}.content-layout{grid-template-columns:310px minmax(0,1fr);align-items:stretch;gap:14px;display:grid}.content-layout:has(.content-files){grid-template-columns:278px 218px minmax(0,1fr)}.content-index{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:15px;flex-direction:column;min-height:720px;padding:11px;display:flex}.content-index-head{justify-content:space-between;align-items:center;padding:3px 3px 10px;display:flex}.content-index-head span,.content-index-head b{display:block}.content-index-head span{color:var(--admin-muted);font:10px var(--admin-data);letter-spacing:.09em;text-transform:uppercase}.content-index-head b{margin-top:5px;font-size:14px}.content-index-head .icon-btn{width:32px;height:32px}.content-search{border:1px solid var(--admin-line);min-height:38px;color:var(--admin-muted);background:var(--admin-paper);border-radius:9px;align-items:center;gap:8px;padding:0 10px;display:flex}.content-search:focus-within{border-color:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 9%, transparent)}.content-search input{width:100%;min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;font-size:13px}.content-items{scrollbar-width:thin;flex:1;align-content:start;gap:3px;min-height:0;margin:10px -3px 10px 0;padding-right:4px;display:grid;overflow:auto}.content-items button{width:100%;min-height:70px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:9px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;display:grid}.content-items button:hover{color:var(--admin-ink);background:var(--admin-paper)}.content-items button.active{color:var(--admin-vital);background:var(--admin-soft)}.content-health{background:var(--admin-red);width:7px;height:7px;box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-red) 10%, transparent);border-radius:50%}.content-health.valid{background:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 10%, transparent)}.content-item-copy{min-width:0}.content-item-copy b{color:var(--admin-ink);text-overflow:ellipsis;white-space:nowrap;font-size:13px;display:block;overflow:hidden}.content-item-copy small{-webkit-line-clamp:2;-webkit-box-orient:vertical;margin-top:5px;font-size:11px;font-weight:450;line-height:1.45;display:-webkit-box;overflow:hidden}.content-no-result{min-height:130px;color:var(--admin-muted);place-items:center;font-size:12px;display:grid}.new-content{border:1px dashed var(--admin-line);width:100%;min-height:44px;color:var(--admin-muted);background:0 0;border-radius:9px;justify-content:center;align-items:center;gap:7px;margin-top:auto;font-size:12px;display:flex}.new-content:hover{color:var(--admin-vital);border-color:color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));background:var(--admin-soft)}.content-files{border:1px solid var(--admin-line);background:var(--admin-paper);border-radius:15px;flex-direction:column;min-width:0;min-height:720px;display:flex;overflow:hidden}.content-files>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:center;min-height:54px;padding:8px 9px 8px 13px;display:flex}.content-files>header div{min-width:0;color:var(--admin-vital);align-items:center;gap:7px;display:flex}.content-files>header span{color:var(--admin-ink);font:680 12px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.content-files>header .icon-btn{width:32px;height:32px}.file-create{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);gap:5px;padding:8px;display:flex}.file-create input{border:1px solid var(--admin-line);width:100%;min-width:0;min-height:36px;font:11px var(--admin-data);border-radius:7px;outline:0;padding:0 8px}.file-create input:focus{border-color:var(--admin-vital)}.file-create button{color:#fff;background:var(--admin-vital);border:0;border-radius:7px;place-items:center;width:34px;display:grid}.file-create button:disabled{opacity:.35}.file-tree{flex:1;align-content:start;gap:2px;padding:7px;display:grid;overflow:auto}.file-tree button{min-height:54px;color:var(--admin-muted);text-align:left;background:0 0;border:0;border-radius:8px;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 9px;display:grid}.file-tree button:hover:not(:disabled){color:var(--admin-ink);background:var(--admin-surface)}.file-tree button.active{color:var(--admin-vital);background:var(--admin-soft)}.file-tree button:disabled{cursor:not-allowed;opacity:.5}.file-tree button>span{min-width:0}.file-tree b,.file-tree small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.file-tree b{color:var(--admin-ink);font:11px var(--admin-data)}.file-tree small{font:10px var(--admin-data);margin-top:4px}.file-tree i{color:var(--admin-vital);background:var(--admin-surface);font:9px var(--admin-data);border-radius:4px;padding:3px 5px;font-style:normal}.content-files>footer{border-top:1px solid var(--admin-line);color:var(--admin-muted);text-align:center;padding:10px;font-size:10px;line-height:1.45}.content-editor{flex-direction:column;min-width:0;min-height:720px;display:flex}.editor-file{align-items:center;gap:7px;min-width:0;display:flex}.field input.invalid{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 52%, var(--admin-line));background:color-mix(in oklch, var(--admin-red) 3%, white)}.source-create-card{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:linear-gradient(112deg, var(--admin-soft), var(--admin-surface) 62%);border-radius:11px;grid-template-columns:auto minmax(0,1fr) minmax(220px,300px);align-items:center;gap:14px;margin-bottom:12px;padding:14px 16px;display:grid}.source-create-mark{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));width:42px;height:42px;color:var(--admin-vital);background:var(--admin-surface);border-radius:9px;place-items:center;display:grid}.source-create-card>div:nth-child(2)>span{color:var(--admin-vital);font:680 8px var(--admin-data);letter-spacing:.12em}.source-create-card h3{font:16px var(--admin-display);margin:4px 0 2px}.source-create-card p{color:var(--admin-muted);margin:0;font-size:9px;line-height:1.5}.source-create-card label{grid-template-columns:auto minmax(0,1fr);align-items:center;gap:4px 8px;display:grid}.source-create-card label>span{color:var(--admin-muted);font-size:9px;font-weight:680}.source-create-card input{border:1px solid var(--admin-line);min-width:0;min-height:38px;color:var(--admin-ink);font:12px var(--admin-data);background:#fff;border-radius:8px;outline:0;padding:0 10px}.source-create-card input:focus{border-color:var(--admin-vital);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-vital) 9%, transparent)}.source-create-card input.invalid{color:var(--admin-red);border-color:color-mix(in oklch, var(--admin-red) 52%, var(--admin-line))}.source-create-card small{color:var(--admin-muted);grid-column:2;font-size:8px}.source-workbench{border:1px solid color-mix(in oklch, var(--admin-ink) 22%, var(--admin-line));background:var(--admin-surface);border-radius:11px;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden;box-shadow:inset 0 1px #fff}.source-toolbar{border-bottom:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-paper) 70%, var(--admin-surface));justify-content:space-between;align-items:center;gap:14px;min-height:46px;padding:0 13px;display:flex}.editor-file>svg{color:var(--admin-vital);flex:none}.editor-file code{min-width:0;color:var(--admin-muted);font:11px var(--admin-data);white-space:nowrap;display:flex}.editor-file code b{max-width:220px;color:var(--admin-ink);text-overflow:ellipsis;font-weight:650;overflow:hidden}.editor-file code i{color:color-mix(in oklch, var(--admin-muted) 48%, transparent);padding:0 2px;font-style:normal}.source-status{border:1px solid color-mix(in oklch, var(--admin-muted) 55%, transparent);background:var(--admin-surface);border-radius:50%;flex:none;width:7px;height:7px}.source-status.dirty{border-color:var(--admin-amber);background:var(--admin-amber);box-shadow:0 0 0 3px color-mix(in oklch, var(--admin-amber) 10%, transparent)}.source-readout{white-space:nowrap;align-items:center;gap:5px;display:flex}.source-readout span,.source-readout b{font:8px var(--admin-data);border-radius:5px;padding:4px 6px}.source-readout span{color:var(--admin-vital);background:var(--admin-soft)}.source-readout b{color:var(--admin-muted);font-weight:500}.source-schema{border-bottom:1px solid var(--admin-line);min-height:38px;color:var(--admin-muted);background:var(--admin-surface);align-items:center;gap:7px;padding:0 14px;display:flex}.source-schema span{font-size:8px;font-weight:680}.source-schema code{color:var(--admin-vital);font:9px var(--admin-data)}.source-schema>i{background:var(--admin-line);width:1px;height:13px;margin:0 2px}.source-schema p{text-overflow:ellipsis;white-space:nowrap;margin:0;font-size:9px;overflow:hidden}.source-schema kbd{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font:8px var(--admin-data);white-space:nowrap;border-bottom-width:2px;border-radius:4px;margin-left:auto;padding:3px 6px}.source-editor{width:100%;min-height:510px;color:var(--admin-ink);background:color-mix(in oklch, var(--admin-paper) 35%, var(--admin-surface));caret-color:var(--admin-vital);font:13px/1.78 var(--admin-data);resize:vertical;tab-size:2;border:0;outline:0;flex:1;padding:22px 24px}.source-editor:focus{background:var(--admin-surface);box-shadow:inset 3px 0 var(--admin-vital)}.content-editor>.panel-actions{z-index:4;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 88%, transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border-radius:12px;margin:18px -8px -8px;padding:10px;position:sticky;bottom:20px}.save-state{color:var(--admin-muted);margin-right:auto;font-size:11px}.save-state.dirty{color:color-mix(in oklch, var(--admin-amber) 74%, var(--admin-ink))}.content-welcome{min-height:540px;color:var(--admin-muted);text-align:center;flex:1;align-content:center;place-items:center;display:grid}.content-welcome h3{color:var(--admin-ink);font:22px var(--admin-display);margin:18px 0 5px}.content-welcome p{max-width:380px;margin:0 0 20px;font-size:11px;line-height:1.65}.welcome-orbit{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));width:72px;height:72px;color:var(--admin-vital);background:var(--admin-soft);border-radius:50%;place-items:center;display:grid;position:relative}.welcome-orbit i{border:1px solid color-mix(in oklch, var(--admin-vital) 24%, transparent);border-radius:50%;width:88px;height:42px;position:absolute;transform:rotate(24deg)}.welcome-orbit i:last-child{transform:rotate(-24deg)}.audit-workspace{gap:14px;display:grid}.audit-vitals{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:repeat(4,minmax(0,1fr));display:grid;overflow:hidden}.audit-vitals article{border-right:1px solid var(--admin-line);grid-template-columns:auto 1fr auto;align-items:center;gap:6px 9px;min-height:92px;padding:15px 17px;display:grid}.audit-vitals article:last-child{border-right:0}.audit-vitals svg{color:var(--admin-vital)}.audit-vitals article.alert svg,.audit-vitals article.alert b{color:var(--admin-red)}.audit-vitals span{color:var(--admin-muted);font-size:10px}.audit-vitals b{color:var(--admin-ink);font:680 22px var(--admin-data)}.audit-vitals small{color:var(--admin-muted);grid-column:2/-1;font-size:9px}.audit-switcher{justify-content:space-between;align-items:center;gap:12px;display:flex}.audit-switcher>div{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;gap:4px;padding:4px;display:flex}.audit-switcher>div button{min-height:34px;color:var(--admin-muted);background:0 0;border:0;border-radius:7px;align-items:center;gap:7px;padding:0 12px;font-size:10px;display:flex}.audit-switcher>div button.active{color:var(--admin-vital);background:var(--admin-soft);font-weight:680}.audit-filters{grid-template-columns:minmax(220px,1fr) 130px auto;align-items:center;gap:9px;margin-bottom:14px;display:grid}.audit-filters label{border:1px solid var(--admin-line);min-height:40px;color:var(--admin-muted);background:var(--admin-paper);border-radius:9px;align-items:center;gap:7px;padding:0 10px;display:flex}.audit-filters label:focus-within{border-color:var(--admin-vital)}.audit-filters input{width:100%;min-width:0;color:var(--admin-ink);background:0 0;border:0;outline:0;font-size:11px}.audit-filters select{border:1px solid var(--admin-line);min-height:40px;color:var(--admin-ink);background:var(--admin-surface);border-radius:9px;padding:0 9px;font-size:10px}.audit-filters>span{color:var(--admin-muted);font:9px var(--admin-data);white-space:nowrap}.audit-timeline{max-height:650px;padding-right:5px;overflow:auto}.audit-timeline article{grid-template-columns:18px 105px minmax(0,1fr);gap:10px;min-height:82px;display:grid}.audit-rail{justify-content:center;display:flex;position:relative}.audit-rail:after{background:var(--admin-line);content:"";width:1px;position:absolute;top:22px;bottom:-4px}.audit-timeline article:last-child .audit-rail:after{display:none}.audit-rail i{z-index:1;border:2px solid var(--admin-surface);background:var(--admin-vital);width:9px;height:9px;box-shadow:0 0 0 2px color-mix(in oklch, var(--admin-vital) 20%, var(--admin-line));border-radius:50%;margin-top:17px;position:relative}.audit-timeline article.failed .audit-rail i{background:var(--admin-red);box-shadow:0 0 0 2px color-mix(in oklch, var(--admin-red) 20%, var(--admin-line))}.audit-timeline time{padding-top:11px}.audit-timeline time b,.audit-timeline time span{display:block}.audit-timeline time b{color:var(--admin-ink);font:650 12px var(--admin-data)}.audit-timeline time span{color:var(--admin-muted);font:8px var(--admin-data);margin-top:3px}.audit-event{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;margin-bottom:8px;padding:11px 13px}.audit-event header{align-items:center;gap:7px;display:flex}.audit-event header span{color:var(--admin-vital);background:var(--admin-soft);font:8px var(--admin-data);text-transform:uppercase;border-radius:4px;padding:3px 5px}.audit-event header b{font-size:11px}.audit-event header i{color:var(--admin-vital);margin-left:auto;font-size:9px;font-style:normal}.audit-timeline article.failed .audit-event header i{color:var(--admin-red)}.audit-event>code{color:var(--admin-ink);font:9px var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-top:7px;display:block;overflow:hidden}.audit-event p{color:var(--admin-muted);margin:7px 0 0;font-size:10px;line-height:1.5}.audit-event footer{color:var(--admin-muted);font:8px var(--admin-data);margin-top:7px}.runtime-callout{border:1px solid color-mix(in oklch, var(--admin-vital) 22%, var(--admin-line));color:var(--admin-vital);background:var(--admin-soft);border-radius:11px;align-items:center;gap:12px;padding:14px;display:flex}.runtime-callout b,.runtime-callout span{display:block}.runtime-callout b{color:var(--admin-ink);font-size:12px}.runtime-callout span{color:var(--admin-muted);margin-top:4px;font-size:9px}.runtime-task-grid{grid-template-columns:1fr 1fr;gap:8px;margin-top:11px;display:grid}.runtime-task-grid details{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:10px;min-width:0}.runtime-task-grid details.current{border-color:color-mix(in oklch, var(--admin-amber) 35%, var(--admin-line))}.runtime-task-grid summary{cursor:pointer;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:9px;padding:12px;display:grid}.task-signal{background:var(--admin-soft);border-radius:50%;place-items:center;width:18px;height:18px;display:grid}.task-signal i{background:var(--admin-vital);border-radius:50%;width:6px;height:6px}.runtime-task-grid details.done .task-signal i{background:var(--admin-muted)}.runtime-task-grid summary b,.runtime-task-grid summary code{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.runtime-task-grid summary b{font-size:10px}.runtime-task-grid summary code{color:var(--admin-muted);font:8px var(--admin-data);margin-top:3px}.task-state{color:var(--admin-vital);background:var(--admin-soft);white-space:nowrap;border-radius:5px;padding:4px 6px;font-size:8px}.task-waiting{border-top:1px solid var(--admin-line);background:var(--admin-paper);padding:12px}.task-waiting>span{color:var(--admin-muted);font-size:8px;display:block}.task-waiting>code{overflow-wrap:anywhere;color:var(--admin-ink);font:9px/1.55 var(--admin-data);margin-top:5px;display:block}.task-waiting pre{max-height:230px;color:color-mix(in oklch, var(--admin-paper) 86%, var(--admin-ink));background:var(--admin-ink);font:8px/1.55 var(--admin-data);border-radius:7px;margin:10px 0 0;padding:10px;overflow:auto}.log-table,.content-items,.file-tree,.source-editor,.audit-timeline,.task-waiting pre{overscroll-behavior:contain;scrollbar-gutter:stable}.admin-shell small{font-size:var(--admin-type-caption);line-height:1.55}.admin-shell button{font-size:var(--admin-type-small);line-height:1.35}.admin-shell :where(input,select,textarea){font-size:var(--admin-type-body);line-height:1.55}.admin-shell :where(code,pre,kbd,time){font-variant-numeric:tabular-nums}.admin-shell :where(p,small){text-wrap:pretty}.admin-brand span,.sidebar-foot>span,.sidebar-foot button,.topbar-title .eyebrow,.shell-life>span,.live-badge,.presence-readout span,.pulse-organ small,.config-section-heading small,.provider-source-note span,.provider-source-note code,.provider-row .field small,.admin-security-hero p,.admin-setting-cards span,.admin-setting-cards small,.admin-security-note b,.admin-security-note span,.admin-security-note code,.record small,.task-filters button,.memory-results article header,.memory-results article footer>i,.danger-card small,.content-index-head span,.file-tree small,.content-files>footer,.source-create-card p,.source-create-card label>span,.source-schema p,.audit-vitals span,.audit-vitals small,.audit-switcher>div button,.audit-filters select,.audit-filters>span,.audit-event>code,.audit-event p,.runtime-callout span,.runtime-task-grid summary b,.runtime-task-grid summary code,.task-waiting>code,.task-waiting pre{font-size:var(--admin-type-caption)}.effect-badge,.llm-config-copy span,.admin-security-hero>div:nth-child(2)>span,.admin-security-hero>b,.status-pill,.alarm-time strong,.alarm-due,.file-tree i,.source-create-card>div:nth-child(2)>span,.source-create-card small,.source-readout span,.source-readout b,.source-schema span,.source-schema code,.source-schema kbd,.audit-timeline time span,.audit-event header span,.audit-event header i,.audit-event footer,.task-state,.task-waiting>span{font-size:var(--admin-type-micro);line-height:1.45}.admin-panel>header p,.field small,.topbar-actions>a,.vital>span,.memory-meter span,.runtime-clock span,.active-model span,.llm-config-copy p,.provider-source-note b,.provider-source-note p,.provider-row .field>span,.provider-empty,.llm-config-facts span,.record-details,.alarm-summary,.danger-card p,.capability-strip small,.content-item-copy small,.log-table,.log-table>.log-head,.save-state,.content-welcome p,.audit-filters input,.audit-event header b,.audit-event p,.runtime-callout span{font-size:var(--admin-type-small);line-height:1.65}.audit-vitals article{min-height:104px;padding:18px 20px}.audit-vitals span{font-size:var(--admin-type-small);line-height:1.45}.audit-vitals b{font-size:clamp(24px,22px + .22vw,30px);line-height:1}.audit-vitals small{font-size:var(--admin-type-caption);line-height:1.5}.audit-switcher>div button,.audit-filters input,.audit-filters select{font-size:var(--admin-type-small)}.audit-filters>span{font-size:var(--admin-type-caption)}.audit-timeline article{min-height:94px}.audit-timeline time b{font-size:var(--admin-type-body);line-height:1.25}.audit-timeline time span{font-size:var(--admin-type-caption);line-height:1.45}.audit-event{padding:14px 16px}.audit-event header span{font-size:var(--admin-type-micro)}.audit-event header b{font-size:var(--admin-type-body);line-height:1.45}.audit-event header i{font-size:var(--admin-type-caption)}.audit-event>code{font-size:var(--admin-type-small);line-height:1.55}.audit-event p{font-size:var(--admin-type-body);line-height:1.7}.audit-event footer{font-size:var(--admin-type-caption);line-height:1.5}.runtime-callout b{font-size:var(--admin-type-body);line-height:1.45}.runtime-callout span{font-size:var(--admin-type-small);line-height:1.6}.runtime-task-grid summary{padding:15px}.runtime-task-grid summary b{font-size:var(--admin-type-body);line-height:1.45}.runtime-task-grid summary code{font-size:var(--admin-type-caption);line-height:1.5}.task-state{font-size:var(--admin-type-caption);line-height:1.35}.task-waiting{padding:15px}.task-waiting>span{font-size:var(--admin-type-caption)}.task-waiting>code{font-size:var(--admin-type-small);line-height:1.65}.task-waiting pre{font-size:var(--admin-type-caption);line-height:1.65}.release-page{max-width:1440px;margin:0 auto}.release-hero{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, var(--admin-line));background:linear-gradient(110deg, var(--admin-soft), var(--admin-surface) 62%), var(--admin-surface);border-radius:17px;grid-template-columns:auto minmax(0,1fr) minmax(270px,auto);align-items:center;gap:20px;min-height:150px;padding:25px 28px;display:grid;position:relative;overflow:hidden}.release-hero:after{border:1px solid color-mix(in oklch, var(--admin-vital) 14%, transparent);content:"";pointer-events:none;border-radius:50%;width:240px;height:240px;position:absolute;top:-100px;right:19%}.release-hero.empty{border-style:dashed}.release-signal{border:1px solid color-mix(in oklch, var(--admin-vital) 32%, var(--admin-line));width:68px;height:68px;color:var(--admin-vital);background:var(--admin-surface);border-radius:50%;place-items:center;display:grid;position:relative}.release-signal i{border:1px solid color-mix(in oklch, var(--admin-vital) 28%, transparent);border-radius:43% 57% 52% 48%;animation:8s linear infinite admin-orbit;position:absolute;inset:7px}.release-signal i:last-child{animation-duration:5s;animation-direction:reverse;inset:15px}.release-hero h2{font:660 clamp(21px, 2vw, 28px)/1.2 var(--admin-display);letter-spacing:-.03em;margin:0}.release-hero>div:nth-child(2)>p:last-child{max-width:620px;color:var(--admin-muted);font-size:var(--admin-type-small);margin:8px 0 0;line-height:1.65}.release-hero-platforms{z-index:1;border:1px solid var(--admin-line);background:color-mix(in oklch, var(--admin-surface) 90%, transparent);border-radius:11px;min-width:250px;padding:16px;position:relative}.release-hero-platforms>span{color:var(--admin-vital);font:680 var(--admin-type-micro) var(--admin-data);letter-spacing:.1em}.release-hero-platforms>div{flex-wrap:wrap;gap:5px;min-height:25px;margin-top:9px;display:flex}.release-hero-platforms b{color:var(--admin-ink);background:var(--admin-soft);font:610 var(--admin-type-caption) var(--admin-data);border-radius:5px;padding:5px 7px}.release-hero-platforms small{color:var(--admin-muted)}.release-hero-platforms time{color:var(--admin-muted);font:var(--admin-type-caption) var(--admin-data);margin-top:9px;display:block}.release-fleet{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:minmax(190px,.36fr) minmax(0,1fr);display:grid;overflow:hidden}.release-fleet>header{border-right:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:flex-start;gap:12px;padding:19px 18px;display:flex}.release-fleet h3{font:650 var(--admin-type-body) var(--admin-display);margin:3px 0 0}.release-fleet .icon-btn:disabled{opacity:.45}.release-fleet-state{min-height:88px;color:var(--admin-muted);font-size:var(--admin-type-small);align-items:center;gap:10px;padding:18px 22px;display:flex}.release-fleet-state svg{color:var(--admin-vital);flex:none}.release-fleet-state span b,.release-fleet-state span{display:block}.release-fleet-state span b{color:var(--admin-ink);margin-bottom:3px}.release-fleet-state.error svg{color:var(--admin-danger)}.release-fleet-state .spin{animation:1.2s linear infinite admin-spin}.release-fleet-body{grid-template-columns:minmax(250px,.55fr) minmax(340px,1fr);min-width:0;display:grid}.release-fleet-summary{border-right:1px solid var(--admin-line);grid-template-columns:repeat(3,minmax(0,1fr));align-items:stretch;display:grid}.release-fleet-summary span{min-width:0;color:var(--admin-muted);font-size:var(--admin-type-micro);text-align:center;flex-direction:column;justify-content:center;padding:16px 13px;display:flex}.release-fleet-summary span+span{border-left:1px solid var(--admin-line)}.release-fleet-summary b{color:var(--admin-ink);font:670 21px/1.1 var(--admin-data);margin-bottom:2px}.release-fleet-summary .online b{color:var(--admin-vital)}.release-fleet-summary .outdated b{color:var(--admin-amber)}.release-version-tracks{gap:9px;max-height:150px;padding:15px 17px;display:grid;overflow:auto}.release-version-track{grid-template-columns:minmax(82px,auto) minmax(80px,1fr) minmax(86px,auto);align-items:center;gap:11px;min-width:0;display:grid}.release-version-label{align-items:center;gap:6px;min-width:0;display:flex}.release-version-label b{color:var(--admin-ink);font:620 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-version-label span{color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-micro);white-space:nowrap;border-radius:4px;padding:2px 5px}.release-version-track.latest .release-version-label span{color:var(--admin-vital);background:var(--admin-soft)}.release-version-track.outdated .release-version-label span{color:color-mix(in oklch, var(--admin-amber) 78%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface))}.release-version-rail{background:var(--admin-paper);border-radius:999px;height:6px;overflow:hidden}.release-version-rail i{border-radius:inherit;background:color-mix(in oklch, var(--admin-vital) 55%, var(--admin-line));height:100%;display:block}.release-version-track.latest .release-version-rail i{background:var(--admin-vital)}.release-version-track.outdated .release-version-rail i{background:var(--admin-amber)}.release-version-track.unknown .release-version-rail i{background:var(--admin-muted)}.release-version-track small{color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);text-align:right;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-sync-card{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:14px;grid-template-columns:minmax(280px,1.2fr) minmax(340px,1fr);align-items:center;gap:15px 22px;padding:18px 20px;display:grid}.release-sync-card.running{border-color:color-mix(in oklch, var(--admin-vital) 38%, var(--admin-line));box-shadow:inset 3px 0 0 var(--admin-vital)}.release-sync-card.failed,.release-sync-card.conflict{border-color:color-mix(in oklch, var(--admin-danger) 36%, var(--admin-line))}.release-sync-main{align-items:center;gap:13px;min-width:0;display:flex}.release-sync-icon{width:42px;height:42px;color:var(--admin-vital);background:var(--admin-soft);border-radius:10px;flex:none;place-items:center;display:grid}.release-sync-card.running .release-sync-icon svg{animation:1.2s linear infinite admin-spin}.release-sync-main h3{font:650 var(--admin-type-body) var(--admin-display);margin:2px 0 4px}.release-sync-main p:last-child{min-width:0;color:var(--admin-muted);font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;margin:0;overflow:hidden}.release-sync-main code{color:var(--admin-ink);font-family:var(--admin-data)}.release-sync-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.release-sync-facts span{background:var(--admin-soft);border-radius:8px;min-width:0;padding:9px 10px}.release-sync-facts b{color:var(--admin-ink);font:620 var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.release-sync-facts span{color:var(--admin-muted);font-size:var(--admin-type-micro)}.release-sync-progress{grid-column:1/-1}.release-sync-progress>div{color:var(--admin-muted);font-size:var(--admin-type-caption);justify-content:space-between;gap:12px;margin-bottom:6px;display:flex}.release-sync-progress>i{background:var(--admin-soft);border-radius:999px;height:5px;display:block;overflow:hidden}.release-sync-progress>i>em{border-radius:inherit;background:var(--admin-vital);height:100%;transition:width .2s;display:block}.release-sync-card>.notice{grid-column:1/-1;margin:0}.release-sync-footer{border-top:1px solid var(--admin-line);grid-column:1/-1;justify-content:space-between;align-items:center;gap:16px;padding-top:12px;display:flex}.release-sync-footer p{color:var(--admin-muted);font-size:var(--admin-type-caption);align-items:center;gap:7px;margin:0;display:flex}.release-sync-footer p svg{color:var(--admin-vital);flex:none}.release-sync-footer>div{gap:8px;display:flex}@keyframes admin-spin{to{transform:rotate(360deg)}}.release-layout{grid-template-columns:315px minmax(0,1fr);align-items:start;gap:18px;display:grid}.release-index{border:1px solid var(--admin-line);background:var(--admin-surface);border-radius:15px;min-width:0;position:sticky;top:106px;overflow:hidden}.release-index>header{border-bottom:1px solid var(--admin-line);justify-content:space-between;align-items:center;min-height:62px;padding:12px 14px;display:flex}.release-index>header span,.release-index>header b{display:block}.release-index>header span{color:var(--admin-vital);font:680 var(--admin-type-micro) var(--admin-data);letter-spacing:.1em}.release-index>header b{font-size:var(--admin-type-small);margin-top:4px}.release-new{border:1px dashed color-mix(in oklch, var(--admin-vital) 35%, var(--admin-line));width:calc(100% - 20px);min-height:53px;color:var(--admin-vital);background:var(--admin-soft);text-align:left;border-radius:9px;align-items:center;gap:10px;margin:10px;padding:8px 11px;display:flex}.release-new:hover,.release-new.active{background:color-mix(in oklch, var(--admin-vital) 12%, var(--admin-surface));border-style:solid}.release-new span,.release-new b,.release-new small{display:block}.release-new b{color:var(--admin-ink);font-size:var(--admin-type-small)}.release-new small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:3px}.release-trace{max-height:calc(100dvh - 286px);padding:2px 10px 12px;overflow:auto}.release-trace>button{width:100%;min-height:94px;color:var(--admin-muted);text-align:left;background:0 0;border:0;grid-template-columns:18px minmax(0,1fr);padding:0;display:grid}.trace-node{justify-content:center;height:100%;display:flex;position:relative}.trace-node:before{background:var(--admin-line);content:"";width:1px;position:absolute;top:0;bottom:0;left:50%}.release-trace>button:first-child .trace-node:before{top:22px}.release-trace>button:last-child .trace-node:before{bottom:calc(100% - 22px)}.trace-node i{z-index:1;border:2px solid var(--admin-surface);background:color-mix(in oklch, var(--admin-muted) 38%, var(--admin-line));width:9px;height:9px;box-shadow:0 0 0 1px var(--admin-line);border-radius:50%;margin-top:18px;position:relative}.release-trace>button.latest .trace-node i{background:var(--admin-vital);box-shadow:0 0 0 4px var(--admin-soft)}.trace-copy{border:1px solid #0000;border-radius:9px;min-width:0;margin:6px 0;padding:10px 11px;transition:background .15s,border-color .15s}.release-trace>button:hover .trace-copy{background:var(--admin-paper)}.release-trace>button.active .trace-copy{border-color:color-mix(in oklch, var(--admin-vital) 24%, var(--admin-line));background:var(--admin-soft)}.trace-copy>span:first-child{justify-content:space-between;align-items:center;gap:8px;display:flex}.trace-copy b{color:var(--admin-ink);font:650 var(--admin-type-body) var(--admin-data)}.trace-copy em{color:var(--admin-muted);background:var(--admin-paper);font:normal var(--admin-type-micro) var(--admin-data);border-radius:4px;padding:3px 5px}.release-trace>button.latest .trace-copy em{color:var(--admin-vital);background:var(--admin-soft)}.trace-copy>small{color:var(--admin-muted);font-size:var(--admin-type-caption);-webkit-line-clamp:1;-webkit-box-orient:vertical;margin-top:6px;line-height:1.4;display:-webkit-box;overflow:hidden}.trace-platforms{color:var(--admin-ink);font:var(--admin-type-micro) var(--admin-data);margin-top:7px;display:inline-block}.trace-copy time{float:right;color:var(--admin-muted);font:var(--admin-type-micro) var(--admin-data);margin-top:7px}.release-index-empty{color:var(--admin-muted);font-size:var(--admin-type-small);text-align:center;padding:30px 16px}.release-workspace{gap:16px;min-width:0;display:grid}.release-create{max-width:820px}.release-create form{gap:16px;display:grid}.release-create .field textarea{min-height:150px}.release-state{min-height:25px;color:var(--admin-muted);background:var(--admin-paper);font:var(--admin-type-caption) var(--admin-data);border-radius:6px;align-items:center;padding:0 8px;display:inline-flex}.release-state.latest{color:var(--admin-vital);background:var(--admin-soft)}.release-meta{border:1px solid var(--admin-line);background:var(--admin-line);border-radius:9px;grid-template-columns:1fr 1fr .7fr;gap:1px;display:grid;overflow:hidden}.release-meta span{min-width:0;color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-caption);padding:12px 13px}.release-meta b{color:var(--admin-ink);font:610 var(--admin-type-small) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;margin-bottom:5px;display:block;overflow:hidden}.release-provenance{border:1px solid var(--admin-line);color:var(--admin-muted);background:var(--admin-paper);font-size:var(--admin-type-caption);border-radius:8px;flex-wrap:wrap;align-items:center;gap:7px 10px;margin-top:8px;padding:8px 11px;display:flex}.release-provenance strong{color:var(--admin-ink);font-weight:620}.release-provenance code{min-width:0;color:var(--admin-muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.release-notes{max-width:760px;color:var(--admin-muted);font-size:12px;line-height:1.65}.release-notes.preview{max-height:148px;position:relative;overflow:hidden}.release-notes.preview:after{background:linear-gradient(transparent, var(--admin-paper));content:"";pointer-events:none;height:38px;position:absolute;bottom:0;left:0;right:0}.release-notes-toggle{color:var(--admin-vital);cursor:pointer;background:0 0;border:0;margin-top:7px;padding:0;font-size:11px}.release-notes-toggle:hover{text-underline-offset:3px;text-decoration:underline}.release-notes :first-child{margin-top:6px}.release-notes :last-child{margin-bottom:0}.release-notes p{margin:6px 0}.release-notes h1,.release-notes h2,.release-notes h3,.release-notes h4{color:var(--admin-ink);font:650 13px/1.35 var(--admin-display);margin:10px 0 5px}.release-notes ul,.release-notes ol{margin:6px 0;padding-left:19px}.release-notes li{margin:2px 0}.release-notes a{color:var(--admin-vital);text-underline-offset:2px;text-decoration:underline}.release-notes code{color:var(--admin-ink);background:var(--admin-soft);font-family:var(--admin-data);border-radius:4px;padding:1px 4px}.release-notes pre{background:var(--admin-soft);border-radius:6px;margin:8px 0;padding:8px 10px;overflow-x:auto}.release-notes pre code{background:0 0;padding:0}.release-notes blockquote{border-left:2px solid var(--admin-vital);margin:8px 0;padding-left:10px}.release-asset-grid{grid-template-columns:1fr 1fr;gap:12px;margin-top:15px;display:grid}.release-asset-lane{border:1px solid var(--admin-line);border-radius:10px;min-width:0;overflow:hidden}.release-asset-lane>header{border-bottom:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:center;gap:10px;padding:12px 13px;display:flex}.release-asset-lane>header b,.release-asset-lane>header small{display:block}.release-asset-lane>header b{font-size:var(--admin-type-small)}.release-asset-lane>header small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:3px}.release-asset-lane>header>span{min-width:23px;height:23px;color:var(--admin-vital);background:var(--admin-soft);font:var(--admin-type-caption) var(--admin-data);border-radius:6px;place-items:center;display:grid}.release-asset-lane article{border-bottom:1px solid var(--admin-line);grid-template-columns:minmax(125px,.7fr) minmax(0,1fr) auto;align-items:center;gap:10px;min-height:59px;padding:9px 11px;display:grid}.release-asset-lane article:last-child{border-bottom:0}.asset-platform{align-items:center;gap:6px;min-width:0;display:flex}.asset-platform i{background:var(--admin-vital);border-radius:50%;flex:none;width:6px;height:6px}.asset-platform span{font:var(--admin-type-caption) var(--admin-data);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.asset-file{min-width:0}.asset-file b,.asset-file small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.asset-file b{font:560 var(--admin-type-caption) var(--admin-data)}.asset-file small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:4px}.release-asset-lane article>button{width:29px;height:29px;color:var(--admin-vital);background:var(--admin-soft);cursor:pointer;border:0;border-radius:7px;place-items:center;display:grid}.release-asset-lane article>button:hover{background:color-mix(in oklch, var(--admin-vital) 15%, var(--admin-surface))}.release-lane-empty{color:var(--admin-muted);font-size:var(--admin-type-caption);text-align:center;margin:0;padding:25px 12px}.release-dropzone{border:1px dashed color-mix(in oklch, var(--admin-vital) 42%, var(--admin-line));min-height:118px;color:var(--admin-vital);background:linear-gradient(120deg, var(--admin-soft), var(--admin-surface));cursor:pointer;border-radius:12px;justify-content:center;align-items:center;gap:13px;padding:20px;transition:border-color .16s,background .16s,transform .16s;display:flex}.release-dropzone:hover,.release-dropzone.dragging{border-style:solid;border-color:var(--admin-vital);background:color-mix(in oklch, var(--admin-vital) 11%, var(--admin-surface));transform:translateY(-1px)}.release-dropzone.busy{opacity:.68;pointer-events:none}.release-dropzone span,.release-dropzone b,.release-dropzone small{display:block}.release-dropzone b{color:var(--admin-ink);font-size:var(--admin-type-body)}.release-dropzone small{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-top:5px}.release-dropzone input{opacity:0;width:1px;height:1px;position:absolute}.release-upload-hint{color:var(--admin-muted);font-size:var(--admin-type-caption);flex-wrap:wrap;align-items:center;gap:6px;margin-top:12px;display:flex}.release-upload-hint code{color:var(--admin-ink);background:var(--admin-paper);font-size:var(--admin-type-micro);border-radius:5px;padding:4px 6px}.release-queue{border:1px solid var(--admin-line);border-radius:11px;margin-top:14px;overflow:hidden}.release-queue>header{border-bottom:1px solid var(--admin-line);background:var(--admin-paper);justify-content:space-between;align-items:center;gap:10px;min-height:48px;padding:8px 11px;display:flex}.release-queue>header>div{color:var(--admin-vital);align-items:center;gap:7px;display:flex}.release-queue>header span{color:var(--admin-ink);font-size:var(--admin-type-small)}.release-queue>.notice{white-space:pre-line;margin:10px}.release-queue-rows{display:grid}.release-queue-rows article{border-bottom:1px solid var(--admin-line);background:var(--admin-surface);grid-template-columns:minmax(180px,1fr) minmax(145px,.6fr) 100px minmax(130px,.6fr) auto;align-items:center;gap:8px;min-height:64px;padding:8px 10px;display:grid}.release-queue-rows article.success{background:color-mix(in oklch, var(--admin-vital) 5%, var(--admin-surface))}.release-queue-rows article.invalid,.release-queue-rows article.error{background:color-mix(in oklch, var(--admin-red) 4%, var(--admin-surface))}.queue-file{min-width:0}.queue-file b,.queue-file small{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.queue-file b{font:570 var(--admin-type-small) var(--admin-data)}.queue-file small{color:var(--admin-muted);font-size:var(--admin-type-micro);margin-top:4px}.release-queue-rows select{border:1px solid var(--admin-line);min-width:0;height:36px;color:var(--admin-ink);background:var(--admin-surface);font-size:var(--admin-type-caption);border-radius:7px;outline:0;padding:0 8px}.queue-state{color:var(--admin-muted);font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.invalid .queue-state,.error .queue-state{color:var(--admin-red)}.success .queue-state{color:var(--admin-vital)}.queue-actions{justify-content:flex-end;align-items:center;gap:5px;display:flex}.orphan-signatures{border-bottom:1px solid var(--admin-line);color:var(--admin-muted);font-size:var(--admin-type-caption);flex-wrap:wrap;align-items:center;gap:6px;padding:9px 11px;display:flex}.orphan-signatures button{color:color-mix(in oklch, var(--admin-amber) 70%, var(--admin-ink));background:color-mix(in oklch, var(--admin-amber) 10%, var(--admin-surface));font:var(--admin-type-micro) var(--admin-data);border:0;border-radius:5px;align-items:center;gap:5px;padding:4px 6px;display:inline-flex}.release-queue>.panel-actions{background:var(--admin-paper);margin:0;padding:11px}.queue-summary{color:var(--admin-muted);font-size:var(--admin-type-caption);margin-right:auto}.publish-platforms{flex-wrap:wrap;gap:8px;display:flex}.publish-platforms label{border:1px solid var(--admin-line);background:var(--admin-paper);cursor:pointer;border-radius:8px;align-items:center;gap:7px;min-height:38px;padding:0 10px 0 8px;display:flex;position:relative}.publish-platforms input{opacity:0;position:absolute}.publish-platforms i{border:1px solid var(--admin-line);color:#0000;background:var(--admin-surface);border-radius:5px;place-items:center;width:18px;height:18px;display:grid}.publish-platforms input:checked+i{border-color:var(--admin-vital);color:#fff;background:var(--admin-vital)}.publish-platforms input:focus-visible+i{outline:3px solid color-mix(in oklch, var(--admin-vital) 25%, transparent);outline-offset:2px}.publish-platforms span{font:var(--admin-type-caption) var(--admin-data)}.publish-note{border-left:2px solid var(--admin-amber);color:var(--admin-muted);background:color-mix(in oklch, var(--admin-amber) 7%, var(--admin-surface));font-size:var(--admin-type-caption);align-items:flex-start;gap:8px;margin-top:14px;padding:10px 11px;line-height:1.55;display:flex}.publish-note svg{color:var(--admin-amber);flex:none}.release-publish-empty{min-height:90px;color:var(--admin-muted);font-size:var(--admin-type-small);justify-content:center;align-items:center;gap:10px;display:flex}.release-publish-empty svg{color:var(--admin-vital)}.login-presence h1,.login-access h2,.topbar-title h1,.presence-copy h1,.admin-panel>header h2{letter-spacing:-.015em}@media (prefers-reduced-motion:reduce){.login-sigil i,.login-life-trace i,.life-trace:after,.pulse-organ>i,.state-pulse i,.restart-orbit i,.release-signal i{animation:none!important}.primary:hover{transform:none}}@media (width<=1120px){.release-fleet{grid-template-columns:180px minmax(0,1fr)}.release-fleet-body{grid-template-columns:minmax(220px,.55fr) minmax(300px,1fr)}.release-layout{grid-template-columns:275px minmax(0,1fr)}.release-asset-grid{grid-template-columns:1fr}.release-queue-rows article{grid-template-columns:minmax(170px,1fr) minmax(135px,.7fr) 92px auto}.queue-state{grid-area:2/1/auto/4}.queue-actions{grid-area:1/4/3}}@media (width<=820px){.weixin-overview{grid-template-columns:1fr}.weixin-overview>button{justify-content:center;width:100%}.weixin-pairing{grid-template-columns:1fr}.weixin-qr-stage{width:min(100%,240px)}.release-hero{grid-template-columns:auto minmax(0,1fr);padding:20px}.release-fleet{grid-template-columns:1fr}.release-fleet>header{border-right:0;border-bottom:1px solid var(--admin-line);align-items:center}.release-sync-card{grid-template-columns:1fr}.release-sync-facts{grid-template-columns:repeat(3,minmax(0,1fr))}.release-sync-footer{flex-direction:column;align-items:flex-start}.release-sync-footer>div{justify-content:flex-end;width:100%}.release-hero-platforms{grid-column:1/-1;min-width:0}.release-layout{grid-template-columns:1fr}.release-index{position:static}.release-trace{max-height:none;padding-bottom:10px;display:flex;overflow-x:auto}.release-trace>button{flex:0 0 250px}.release-meta{grid-template-columns:1fr}.release-meta span{border-bottom:1px solid var(--admin-line)}.release-meta span:last-child{border-bottom:0}.release-queue-rows article{grid-template-columns:minmax(0,1fr) 145px 92px auto}}@media (width<=560px){.release-hero,.release-fleet-body{grid-template-columns:1fr}.release-fleet-summary{border-right:0;border-bottom:1px solid var(--admin-line)}.release-version-track{grid-template-columns:minmax(80px,auto) 1fr}.release-version-track small{text-align:left;grid-column:1/-1}.release-sync-facts{grid-template-columns:1fr}.release-sync-main p:last-child{white-space:normal}.release-sync-footer>div{flex-direction:column}.release-sync-footer button{justify-content:center;width:100%}.release-signal{width:56px;height:56px}.release-hero-platforms{grid-column:auto}.release-asset-lane article{grid-template-columns:1fr auto}.asset-file{grid-area:2/1}.release-asset-lane article>button{grid-area:1/2/3}.release-dropzone{justify-content:flex-start;align-items:flex-start}.release-queue-rows article{grid-template-columns:1fr 1fr auto;padding:11px}.queue-file{grid-column:1/-1}.release-queue-rows select:first-of-type{grid-column:1/3}.queue-state{white-space:normal;grid-area:auto/1/auto/3}.queue-actions{grid-area:2/3/4}.release-queue>.panel-actions{align-items:stretch}.release-queue>.panel-actions .primary{justify-content:center;width:100%}.queue-summary{width:100%}.publish-platforms{grid-template-columns:1fr;display:grid}.release-publish-panel .panel-actions>button{justify-content:center;width:100%}}@media (width>=1680px){.admin-shell{grid-template-columns:240px minmax(0,1fr)}.admin-sidebar{padding:24px 16px 18px}.admin-brand b{max-width:150px}.nav-group button.active:before{left:-16px}.admin-topbar{min-height:88px;padding-inline:clamp(40px,2.4vw,64px)}.topbar-title h1{font-size:24px}.topbar-title>span{font-size:13px}.page-stack{gap:22px}.presence-hero{grid-template-columns:minmax(0,1fr) clamp(380px,22vw,520px);min-height:280px}.presence-copy{padding:40px 46px}.pulse-organ{min-height:280px}.vital{min-height:152px;padding:23px 24px}.admin-panel{padding:28px}.content-layout{grid-template-columns:clamp(310px,16vw,390px) minmax(0,1fr);gap:18px}.content-layout:has(.content-files){grid-template-columns:clamp(278px,14vw,350px) clamp(218px,11vw,280px) minmax(0,1fr)}}@media (width>=821px) and (height>=900px){.content-workspace{--content-workbench-height:clamp(720px, calc(100dvh - 282px), 1320px)}.content-layout{height:var(--content-workbench-height)}.content-index,.content-files,.content-editor{height:100%;min-height:0}.content-editor{overflow:hidden}.source-editor{resize:none;min-height:240px}.content-editor>.panel-actions{flex:none;position:static}.log-table{max-height:max(600px,100dvh - 300px)}.audit-timeline{max-height:max(650px,100dvh - 360px)}}@media (width>=1680px) and (height>=1200px){.presence-hero,.pulse-organ{min-height:clamp(300px,18dvh,380px)}.vital{min-height:clamp(152px,9dvh,190px)}}@media (width<=1120px){.short-memory-grid{grid-template-columns:1fr}.short-tree,.short-message-list{max-height:560px}.admin-shell{grid-template-columns:78px minmax(0,1fr)}.admin-sidebar{padding-inline:10px}.admin-brand{justify-content:center;padding-inline:0}.admin-brand>div:last-child,.nav-group>p,.nav-group button span,.nav-chevron,.sidebar-foot>span,.sidebar-foot button span{display:none}.nav-group button{justify-content:center;padding:0}.nav-group button.active:before{left:-10px}.sidebar-foot button{justify-content:center}.topbar-title>span{display:none}.presence-hero{grid-template-columns:minmax(0,1fr) 280px}.presence-readout>div{min-width:120px;padding-inline:16px}.presence-readout>div:first-child{min-width:200px}.vital-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.content-layout{grid-template-columns:280px minmax(0,1fr)}.content-layout:has(.content-files){grid-template-columns:240px 200px minmax(0,1fr)}.llm-config-overview{grid-template-columns:1fr}.llm-config-facts{width:100%}}@media (width<=820px){.short-watermark{grid-template-columns:1fr}.watermark-facts{border-top:1px solid var(--admin-line);border-left:0;padding:20px 54px 20px 24px}.pin-compose,.pinned-context-list{grid-template-columns:1fr}.admin-bootstrap{align-items:start;padding:18px;overflow:auto}.bootstrap-card{grid-template-columns:1fr}.bootstrap-rail{padding:32px}.bootstrap-rail .login-sigil{margin-bottom:28px}.bootstrap-rail h1 br{display:none}.awakening-circuit{grid-template-columns:repeat(3,1fr);margin-top:34px}.awakening-circuit:before{width:auto;height:1px;inset:20px 15% auto}.awakening-circuit li{text-align:center;grid-template-columns:1fr;justify-items:center;gap:7px}.bootstrap-form-stage{padding:36px 32px}.admin-shell{display:block}.admin-sidebar{z-index:60;width:100%;height:76px;padding:7px 8px calc(7px + env(safe-area-inset-bottom));border-top:1px solid #ffffff16;display:block;position:fixed;inset:auto 0 0}.admin-sidebar:after,.admin-brand,.sidebar-foot{display:none}.admin-sidebar nav{scrollbar-width:none;scrollbar-gutter:auto;gap:0;height:100%;padding:0;display:flex;overflow:auto hidden}.admin-sidebar nav::-webkit-scrollbar{display:none}.nav-group{display:contents}.nav-group>p{display:none}.nav-group button{text-align:center;border-radius:9px;flex-direction:column;flex:0 0 84px;justify-content:center;gap:4px;width:auto;min-height:60px;padding:0 6px}.nav-group button span{width:100%;color:inherit;font-size:var(--admin-type-caption);text-overflow:ellipsis;white-space:nowrap;flex:none;display:block;overflow:hidden}.nav-group button.active:before{border-radius:0 0 99px 99px;width:24px;height:3px;top:-7px;left:50%;transform:translate(-50%)}.admin-topbar{min-height:78px;padding:12px 18px}.topbar-title .eyebrow{margin-bottom:4px}.topbar-title h1{font-size:20px}.topbar-actions{gap:10px}.topbar-actions>a,.shell-life>span{display:none}.life-trace{width:96px}.admin-content{padding:20px 16px 108px}.presence-hero{grid-template-columns:1fr}.pulse-organ{border-top:1px solid var(--admin-line);border-left:0;min-height:130px}.pulse-organ:before{width:180px;height:180px}.pulse-organ:after{width:120px;height:120px}.presence-copy{padding:28px}.two-col,.settings-layout,.content-layout{grid-template-columns:1fr}.content-layout:has(.content-files){grid-template-columns:1fr}.settings-layout{gap:12px}.subnav{z-index:8;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);scrollbar-width:none;max-width:calc(100vw - 32px);padding:5px;display:flex;position:sticky;top:88px;overflow:auto}.subnav::-webkit-scrollbar{display:none}.subnav button{white-space:nowrap;flex:none}.content-index{grid-template-columns:minmax(0,1fr) minmax(180px,.8fr) auto;align-items:center;gap:9px;min-height:auto;display:grid}.content-items{grid-column:1/-1;max-height:none;margin:2px 0;padding:0;display:flex;overflow:auto}.content-items button{flex:0 0 220px}.content-no-result{width:100%;min-height:70px}.new-content{width:max-content;min-width:132px}.content-editor{min-height:620px}.content-files{min-height:auto}.content-files>header{min-height:48px}.file-tree{max-height:none;display:flex;overflow:auto}.file-tree button{flex:0 0 220px}.content-files>footer{display:none}.provider-row{grid-template-columns:1fr 1fr}.desktop-sync-overview,.desktop-source-workbench,.desktop-form-row.two,.desktop-option-grid{grid-template-columns:1fr}.tabbar:not(.compact){grid-template-columns:repeat(3,1fr);width:100%;display:grid;overflow:visible}.tabbar:not(.compact) button{width:100%}.audit-table>div{grid-template-columns:130px minmax(120px,1fr) 54px}.audit-table>.audit-head,.audit-table code,.audit-table small{display:none}.audit-vitals{grid-template-columns:1fr 1fr}.audit-vitals article:nth-child(2){border-right:0}.audit-vitals article:nth-child(-n+2){border-bottom:1px solid var(--admin-line)}.runtime-task-grid{grid-template-columns:1fr}}@media (width<=560px){.admin-language-toggle-floating{top:12px;right:12px}.admin-login{background:var(--admin-surface);min-height:100vh;padding:0;display:block;overflow:auto}.admin-login:after{display:none}.login-card{width:100%;min-height:100vh;box-shadow:none;border:0;border-radius:0;display:block}.login-presence{min-height:320px;padding:30px 24px}.login-sigil{width:62px;height:62px;margin-bottom:28px}.login-presence h1{font-size:36px}.login-copy{margin-top:11px}.login-life-trace{opacity:.7;justify-content:flex-end;height:58px;margin:0;position:absolute;bottom:62px;left:24px;right:24px}.login-assurance{margin-top:34px}.login-assurance span:last-child{display:none}.login-access{min-height:calc(100vh - 320px);padding:34px 24px 42px}.admin-bootstrap{padding:0}.bootstrap-card{min-height:100vh;box-shadow:none;border:0;border-radius:0}.bootstrap-rail{padding:28px 22px}.bootstrap-rail .login-sigil{width:58px;height:58px}.bootstrap-rail h1{font-size:34px}.bootstrap-rail>p:not(.eyebrow){font-size:12px}.awakening-circuit li{min-height:68px}.awakening-circuit li>span{width:38px;height:38px}.awakening-circuit li small{display:none}.bootstrap-form-stage{padding:32px 22px 40px}.bootstrap-grid{grid-template-columns:1fr}.bootstrap-grid label.wide{grid-column:auto}.bootstrap-footnote{align-items:flex-start;line-height:1.5}.access-step{margin-bottom:30px}.admin-topbar{min-height:72px;padding:10px 14px}.topbar-title .eyebrow{font-size:var(--admin-type-micro)}.topbar-title h1{font-size:19px}.life-trace{width:74px}.admin-content{padding:15px 12px 104px}.page-stack{gap:13px}.memory-tabs{grid-template-columns:1fr 1fr!important}.memory-tabs button{min-width:0}.watermark-reading{grid-template-columns:1fr;justify-items:start;gap:18px;min-height:0;padding:22px 20px}.watermark-orbit{width:104px;height:104px}.watermark-orbit b{font-size:23px}.watermark-reading h2{margin-top:6px;font-size:25px}.watermark-facts{padding:18px 20px}.watermark-refresh{top:10px;right:10px}.short-tree-node>summary{grid-template-columns:34px minmax(0,1fr)}.short-tree-node .raw-state{grid-column:2;width:max-content}.tree-node-detail{padding:0 10px 10px 46px}.short-message>summary{grid-template-columns:26px minmax(0,1fr)}.short-message>summary>i{grid-column:2;width:max-content;max-width:100%}.short-message-body{padding:0 10px 10px 46px}.memory-maintenance .mild{grid-template-columns:auto minmax(0,1fr)}.memory-maintenance .mild input,.memory-maintenance .mild button{grid-column:2}.memory-maintenance .mild button{width:max-content}.presence-copy{padding:24px 20px}.presence-copy h1{gap:9px;font-size:34px}.presence-readout{grid-template-columns:1fr 1fr;gap:0;margin-top:25px;display:grid}.presence-readout>div,.presence-readout>div:first-child{border-top:1px solid var(--admin-line);border-left:0;min-width:0;padding:12px 10px}.presence-readout>div:first-child{grid-column:1/-1;padding-inline:0}.presence-readout>div:nth-child(2){padding-left:0}.pulse-organ{min-height:112px}.pulse-organ>i{max-height:68px}.vital-grid{grid-template-columns:1fr 1fr}.vital{min-height:124px;padding:16px}.vital>strong{font-size:26px}.two-col{gap:13px}.admin-panel{border-radius:13px;padding:18px}.admin-panel>header{flex-direction:column;gap:12px;margin-bottom:18px}.admin-panel>header h2{font-size:16px}.admin-panel>header p{font-size:var(--admin-type-small)}.memory-meter{grid-template-columns:1fr}.memory-search-stage{grid-template-columns:auto minmax(0,1fr);gap:15px;padding:20px}.memory-search-mark{width:48px;height:48px}.memory-query{grid-column:1/-1;grid-template-columns:auto minmax(0,1fr) auto;padding:5px 5px 5px 12px}.memory-query-clear{grid-column:3}.memory-query-submit{grid-column:1/-1;justify-content:center;min-height:42px;margin-top:4px}.memory-examples{grid-column:1/-1;margin-top:-2px}.memory-results article{grid-template-columns:30px minmax(0,1fr)}.memory-card-body{padding:14px}.memory-results article footer{flex-direction:column;align-items:flex-start}.memory-actions{align-self:flex-end}.memory-footnote{justify-content:flex-start;align-items:flex-start;line-height:1.5}.inline-form,.task-compose,.alarm-compose,.field-grid,.config-fields,.memory-results,.identity-form{grid-template-columns:1fr}.config-fields .field,.identity-form .field{grid-column:auto!important}.string-list-field,.transport-list-field{grid-column:1/-1!important}.transport-list-editor{grid-template-columns:1fr}.system-prompt-facts{flex-wrap:wrap}.system-prompt-facts em{margin-left:0}.system-prompt-preview summary{grid-template-columns:auto 1fr}.system-prompt-preview summary small{grid-column:2}.config-switch{margin-top:0}.inline-add,.search-box,.log-filters{flex-wrap:wrap;width:100%}.inline-add input,.search-box input,.log-filters input{flex:150px}.record,.bubble-record-head{flex-direction:column;align-items:flex-start}.bubble-history{padding-left:14px}.alarm-record{grid-template-columns:64px minmax(0,1fr) auto;align-items:center;display:grid}.alarm-record .danger-icon{align-self:center}.provider-row{grid-template-columns:1fr;padding-top:52px}.provider-row .field:nth-of-type(n){grid-column:auto}.llm-config-facts{grid-template-columns:1fr}.llm-config-facts span{border-right:0;border-bottom:1px solid var(--admin-line)}.llm-config-facts span:last-child{border-bottom:0}.admin-security-hero{grid-template-columns:auto minmax(0,1fr);padding:18px}.admin-security-hero>b{grid-column:2;width:max-content}.admin-setting-cards{grid-template-columns:1fr}.row-actions{justify-content:flex-end;width:100%}.row-actions select{flex:1}.danger-card{grid-template-columns:auto 1fr;align-items:start;display:grid}.danger-card>button,.danger-card>.tiny-input{grid-column:2;width:100%}.log-table>div{grid-template-columns:70px 98px minmax(130px,1fr);min-width:390px}.history-navigator{flex-direction:column;align-items:flex-start}.history-detail-line{flex-wrap:wrap;align-items:flex-start;gap:3px 8px}.history-position small{flex:100%}.history-sequence-total{border-left:0;padding-left:0}.history-log-filters{justify-content:flex-start}.sequence-range{width:100%}.sequence-range label{flex:1}.sequence-range input{flex:1 1 0;width:100%;min-width:0}.history-actions{width:100%}.history-actions button{flex:1;justify-content:center}.lifecycle-log-table>.log-head{display:none}.lifecycle-log-table>article{grid-template-columns:68px minmax(0,1fr) auto;gap:5px 8px;min-width:0}.lifecycle-log-table>article>time{grid-row:1/span 2;font-size:9px;line-height:1.45}.lifecycle-log-table .event-type{grid-area:1/2}.interaction-row-copy{grid-area:2/2}.interaction-detail-toggle{grid-area:1/3/span 2;align-self:center}.interaction-detail{grid-column:1/-1}.audit-table>div{grid-template-columns:1fr auto;gap:6px 12px;padding:13px}.audit-table time{grid-column:1/-1}.audit-table b{font-size:12px}.audit-vitals article{min-height:78px;padding:12px}.audit-vitals b{font-size:18px}.audit-vitals small{display:none}.audit-filters{grid-template-columns:1fr auto}.audit-filters label{grid-column:1/-1}.audit-timeline article{grid-template-columns:14px 64px minmax(0,1fr);gap:6px}.audit-event header{flex-wrap:wrap}.audit-event header i{margin-left:0}.audit-switcher>div{flex:1}.audit-switcher>div button{flex:1;justify-content:center;padding-inline:8px}.diagnostic-list{overflow-x:hidden}.diagnostic-list details{min-width:0}.diagnostic-list summary{grid-template-columns:auto minmax(0,1fr);align-items:start}.diagnostic-list summary code{overflow-wrap:anywhere;white-space:normal;grid-column:2;line-height:1.55}.panel-actions{flex-wrap:wrap}.config-panel>.panel-actions,.content-editor>.panel-actions{bottom:84px}.capability-strip button{min-height:64px;padding:12px 10px}.capability-strip small{display:none}.content-index{grid-template-columns:1fr auto}.content-search{grid-area:2/1/auto/-1}.content-items{grid-row:3}.new-content{grid-row:4}.source-create-card{grid-template-columns:auto minmax(0,1fr)}.source-create-card label{grid-column:1/-1;margin-top:4px}.source-toolbar{flex-direction:column;align-items:flex-start;padding-block:10px}.source-readout{max-width:100%;overflow:auto}.source-schema p,.source-schema>i{display:none}.source-editor{min-height:460px;padding:18px 16px;font-size:12px}.confirm-modal{padding:24px 20px}}.relay-access .panel{overflow:visible}.relay-hero{border:1px solid var(--line);background:var(--surface-soft);border-radius:16px;grid-template-columns:auto 1fr auto;align-items:center;gap:16px;padding:20px;display:grid}.relay-hero h3,.relay-hero p{margin:3px 0}.relay-hero>b{text-transform:uppercase;font-size:12px}.relay-signal{background:var(--surface);border-radius:14px;place-items:center;width:48px;height:48px;display:grid;position:relative}.relay-signal i{background:#9299a5;border-radius:50%;width:10px;height:10px;position:absolute;bottom:4px;right:4px}.relay-hero.connected .relay-signal i{background:#36b37e;box-shadow:0 0 0 4px #36b37e2e}.relay-enroll{gap:14px;max-width:680px;margin-top:20px;display:grid}.relay-enroll label{gap:7px;display:grid}.relay-enroll input{width:100%}.relay-enroll button{justify-self:start}.relay-config-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;display:grid}.relay-config-grid article{border:1px solid var(--line);background:var(--surface-soft);border-radius:13px;gap:8px;padding:15px;display:grid}.relay-config-grid code{overflow-wrap:anywhere}.relay-config-grid article>div{gap:8px;display:flex}@media (width<=760px){.relay-hero{grid-template-columns:auto 1fr}.relay-hero>b{grid-column:2}.relay-config-grid{grid-template-columns:1fr}} diff --git a/src/coworker/web/assets/AdminApp-CHWrWirI.js b/src/coworker/web/assets/AdminApp-Dkmb1p-H.js similarity index 81% rename from src/coworker/web/assets/AdminApp-CHWrWirI.js rename to src/coworker/web/assets/AdminApp-Dkmb1p-H.js index 2de9b39..2a4c51d 100644 --- a/src/coworker/web/assets/AdminApp-CHWrWirI.js +++ b/src/coworker/web/assets/AdminApp-Dkmb1p-H.js @@ -1,4 +1,4 @@ -import{_ as e,a as t,c as n,d as r,f as i,g as a,h as o,i as s,l as c,m as l,n as u,o as d,p as f,r as p,s as m,t as h,u as g,v as _,y as v}from"./index-Di_aognP.js";var y=e(`alarm-clock`,[[`circle`,{cx:`12`,cy:`13`,r:`8`,key:`3y4lt7`}],[`path`,{d:`M12 9v4l2 2`,key:`1c63tq`}],[`path`,{d:`M5 3 2 6`,key:`18tl5t`}],[`path`,{d:`m22 6-3-3`,key:`1opdir`}],[`path`,{d:`M6.38 18.7 4 21`,key:`17xu3x`}],[`path`,{d:`M17.64 18.67 20 21`,key:`kv2oe2`}]]),b=e(`archive-restore`,[[`rect`,{width:`20`,height:`5`,x:`2`,y:`3`,rx:`1`,key:`1wp1u1`}],[`path`,{d:`M4 8v11a2 2 0 0 0 2 2h2`,key:`tvwodi`}],[`path`,{d:`M20 8v11a2 2 0 0 1-2 2h-2`,key:`1gkqxj`}],[`path`,{d:`m9 15 3-3 3 3`,key:`1pd0qc`}],[`path`,{d:`M12 12v9`,key:`192myk`}]]),x=e(`brain`,[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]),S=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),C=e(`circle-check-big`,[[`path`,{d:`M21.801 10A10 10 0 1 1 17 3.335`,key:`yps3ct`}],[`path`,{d:`m9 11 3 3L22 4`,key:`1pflzl`}]]),w=e(`circle-gauge`,[[`path`,{d:`M15.6 2.7a10 10 0 1 0 5.7 5.7`,key:`1e0p6d`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`path`,{d:`M13.4 10.6 19 5`,key:`1kr7tw`}]]),T=e(`clock-3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 6v6h4`,key:`135r8i`}]]),E=e(`cloud-upload`,[[`path`,{d:`M12 13v8`,key:`1l5pq0`}],[`path`,{d:`M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242`,key:`1pljnt`}],[`path`,{d:`m8 17 4-4 4 4`,key:`1quai1`}]]),D=e(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),O=e(`file-archive`,[[`path`,{d:`M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5`,key:`4pqfef`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 12v-1`,key:`1ej8lb`}],[`path`,{d:`M8 18v-2`,key:`qcmpov`}],[`path`,{d:`M8 7V6`,key:`1nbb54`}],[`circle`,{cx:`8`,cy:`20`,r:`2`,key:`ckkr5m`}]]),k=e(`file-code-corner`,[[`path`,{d:`M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35`,key:`1wthlu`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`m5 16-3 3 3 3`,key:`331omg`}],[`path`,{d:`m9 22 3-3-3-3`,key:`lsp7cz`}]]),ee=e(`file-cog`,[[`path`,{d:`M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z`,key:`1ckgky`}],[`path`,{d:`M20 8v12a2 2 0 0 1-2 2h-4.182`,key:`1726p0`}],[`path`,{d:`m3.305 19.53.923-.382`,key:`ao1pio`}],[`path`,{d:`M4 10.592V4a2 2 0 0 1 2-2h8`,key:`1foop0`}],[`path`,{d:`m4.228 16.852-.924-.383`,key:`1fv9zy`}],[`path`,{d:`m5.852 15.228-.383-.923`,key:`1a9hc2`}],[`path`,{d:`m5.852 20.772-.383.924`,key:`1sh9ke`}],[`path`,{d:`m8.148 15.228.383-.923`,key:`4yu6lf`}],[`path`,{d:`m8.53 21.696-.382-.924`,key:`18b0s9`}],[`path`,{d:`m9.773 16.852.922-.383`,key:`ti6xop`}],[`path`,{d:`m9.773 19.148.922.383`,key:`rws47d`}],[`circle`,{cx:`7`,cy:`18`,r:`3`,key:`lvkj7j`}]]),A=e(`file-text`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),j=e(`fingerprint-pattern`,[[`path`,{d:`M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4`,key:`1nerag`}],[`path`,{d:`M14 13.12c0 2.38 0 6.38-1 8.88`,key:`o46ks0`}],[`path`,{d:`M17.29 21.02c.12-.6.43-2.3.5-3.02`,key:`ptglia`}],[`path`,{d:`M2 12a10 10 0 0 1 18-6`,key:`ydlgp0`}],[`path`,{d:`M2 16h.01`,key:`1gqxmh`}],[`path`,{d:`M21.8 16c.2-2 .131-5.354 0-6`,key:`drycrb`}],[`path`,{d:`M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2`,key:`1tidbn`}],[`path`,{d:`M8.65 22c.21-.66.45-1.32.57-2`,key:`13wd9y`}],[`path`,{d:`M9 6.8a6 6 0 0 1 9 5.2v2`,key:`1fr1j5`}]]),te=e(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),M=e(`heart-pulse`,[[`path`,{d:`M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5`,key:`mvr1a0`}],[`path`,{d:`M3.22 13H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27`,key:`auskq0`}]]),N=e(`key-round`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),P=e(`list-todo`,[[`path`,{d:`M13 5h8`,key:`a7qcls`}],[`path`,{d:`M13 12h8`,key:`h98zly`}],[`path`,{d:`M13 19h8`,key:`c3s6r1`}],[`path`,{d:`m3 17 2 2 4-4`,key:`1jhpwq`}],[`rect`,{x:`3`,y:`4`,width:`6`,height:`6`,rx:`1`,key:`cif1o7`}]]),F=e(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),I=e(`messages-square`,[[`path`,{d:`M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z`,key:`1n2ejm`}],[`path`,{d:`M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1`,key:`1qfcsi`}]]),L=e(`orbit`,[[`path`,{d:`M20.341 6.484A10 10 0 0 1 10.266 21.85`,key:`1enhxb`}],[`path`,{d:`M3.659 17.516A10 10 0 0 1 13.74 2.152`,key:`1crzgf`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}],[`circle`,{cx:`19`,cy:`5`,r:`2`,key:`mhkx31`}],[`circle`,{cx:`5`,cy:`19`,r:`2`,key:`v8kfzx`}]]),ne=e(`package-open`,[[`path`,{d:`M12 22v-9`,key:`x3hkom`}],[`path`,{d:`M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z`,key:`2ntwy6`}],[`path`,{d:`M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13`,key:`1pmm1c`}],[`path`,{d:`M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z`,key:`12ttoo`}]]),re=e(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),ie=e(`qr-code`,[[`rect`,{width:`5`,height:`5`,x:`3`,y:`3`,rx:`1`,key:`1tu5fj`}],[`rect`,{width:`5`,height:`5`,x:`16`,y:`3`,rx:`1`,key:`1v8r4q`}],[`rect`,{width:`5`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`1x03jg`}],[`path`,{d:`M21 16h-3a2 2 0 0 0-2 2v3`,key:`177gqh`}],[`path`,{d:`M21 21v.01`,key:`ents32`}],[`path`,{d:`M12 7v3a2 2 0 0 1-2 2H7`,key:`8crl2c`}],[`path`,{d:`M3 12h.01`,key:`nlz23k`}],[`path`,{d:`M12 3h.01`,key:`n36tog`}],[`path`,{d:`M12 16v.01`,key:`133mhm`}],[`path`,{d:`M16 12h1`,key:`1slzba`}],[`path`,{d:`M21 12v.01`,key:`1lwtk9`}],[`path`,{d:`M12 21v-1`,key:`1880an`}]]),R=e(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),ae=e(`rocket`,[[`path`,{d:`M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5`,key:`qeys4`}],[`path`,{d:`M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09`,key:`u4xsad`}],[`path`,{d:`M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z`,key:`676m9`}],[`path`,{d:`M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05`,key:`92ym6u`}]]),z=e(`rotate-ccw`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}]]),oe=e(`save`,[[`path`,{d:`M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z`,key:`1c8476`}],[`path`,{d:`M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7`,key:`1ydtos`}],[`path`,{d:`M7 3v4a1 1 0 0 0 1 1h7`,key:`t51u73`}]]),se=e(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),B=e(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ce=e(`sliders-horizontal`,[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]),V=e(`sparkles`,[[`path`,{d:`M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z`,key:`1s2grr`}],[`path`,{d:`M20 2v4`,key:`1rf3ol`}],[`path`,{d:`M22 4h-4`,key:`gwowj6`}],[`circle`,{cx:`4`,cy:`20`,r:`2`,key:`6kqj1y`}]]),le=e(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),H=e(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),U=e(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]),W=v(_(),1),G=d(),K=`/api/admin/channels/weixin/management`,ue=900,de=200,fe={title:`使用微信扫描二维码`,detail:`扫码会新增一个独立 ClawBot 实例;二维码由谁查看不会改变连接归属。`},pe={scaned:{title:`已扫码,请在手机上确认`,detail:`二维码已被识别,连接会在手机确认后自动建立。`},need_verifycode:{title:`输入手机显示的数字`,detail:`完成验证后会继续建立 ClawBot 连接。`},confirmed:{title:`微信连接已建立`,detail:`新的 ClawBot 实例已经加入连接列表。`,terminal:!0},expired:{title:`二维码已过期`,detail:`重新生成二维码后再使用微信扫描。`,terminal:!0,failed:!0},verify_code_blocked:{title:`验证次数暂时受限`,detail:`请稍后重新生成二维码并再次连接。`,terminal:!0,failed:!0},binded_redirect:{title:`这个微信账号已经绑定其他 Bot`,detail:`iLink 只允许一个微信账号绑定一个 Bot 实例。`,terminal:!0,failed:!0}};function me(e){let t=pe[e]||fe;return{...t,title:u(t.title),detail:u(t.detail)}}function he(e,t){return e instanceof Error?e.message:u(t)}function ge({value:e,change:t,apply:r,dirty:i,saving:a,request:o}){let[s,l]=(0,W.useState)([]),[d,f]=(0,W.useState)(``),[p,m]=(0,W.useState)(``),[h,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(``),[y,b]=(0,W.useState)(``),[x,S]=(0,W.useState)(``),[w,T]=(0,W.useState)(!1),E=(0,W.useRef)(null),D=(0,W.useRef)(!0),O=(0,W.useCallback)(async()=>{try{let e=await o(K);D.current&&(l(Array.isArray(e.connections)?e.connections:[]),b(``))}catch(e){D.current&&b(he(e,`微信连接状态读取失败`))}},[o]),k=(0,W.useCallback)(async()=>{try{let e=await o(K);if(!D.current)return;let t=e.pairing;if(!t)return;m(t.session_id||``),g(t.status||`wait`);let n=me(t.status);if(n.failed?f(``):t.qrcode_data_url&&f(t.qrcode_data_url),t.status===`confirmed`){f(``),S(``),await O();return}n.terminal||(E.current=window.setTimeout(()=>void k(),ue))}catch(e){D.current&&v(he(e,`微信连接状态读取失败`))}},[O,o]);(0,W.useEffect)(()=>(D.current=!0,O(),k(),()=>{D.current=!1,E.current!==null&&window.clearTimeout(E.current)}),[O,k]);let ee=async()=>{if(e.enabled){T(!0),v(``),g(`wait`),S(``);try{if(i&&!await r())return;let e=await o(`${K}/start_pairing`,{method:`POST`,body:`{}`});f(e.qrcode_data_url||``),m(e.session_id||``),g(e.status||`wait`),E.current!==null&&window.clearTimeout(E.current),E.current=window.setTimeout(()=>void k(),de)}catch(e){v(he(e,`无法生成微信连接二维码`))}finally{T(!1)}}},A=async()=>{if(x.trim())try{await o(`${K}/verify_pairing`,{method:`POST`,body:JSON.stringify({session_id:p,verify_code:x.trim()})}),k()}catch(e){v(he(e,`微信连接状态读取失败`))}},j=async(e,t)=>{try{b(``),await o(`${K}/update_connection`,{method:`POST`,body:JSON.stringify({bot_instance_id:e,...t})}),await O()}catch(e){b(he(e,`微信连接更新失败`))}},te=async e=>{let t=e.display_name||e.bot_instance_id;if(confirm(u(`移除 ClawBot 连接“{{name}}”?该连接将停止收发,但微信侧授权不会被远程注销。`,{name:t})))try{b(``),await o(`${K}/remove_connection`,{method:`POST`,body:JSON.stringify({bot_instance_id:e.bot_instance_id,confirm:!0})}),await O()}catch(e){b(he(e,`微信连接移除失败`))}},M=me(h),N=!!(d||_||h===`confirmed`||M.failed);return(0,G.jsxs)(`div`,{className:`weixin-settings`,children:[(0,G.jsxs)(`section`,{className:`weixin-overview `+(e.enabled?s.length?`ready`:`warning`:`disabled`),children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(I,{size:23}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`small`,{children:u(`个人微信 ClawBot`)}),(0,G.jsx)(`b`,{children:e.enabled?s.length?u(`{{count}} 个连接已接入`,{count:s.length}):u(`等待添加首个连接`):u(`微信 Claw 已停用`)}),(0,G.jsx)(`p`,{children:u(`一个 ClawBot 实例绑定一个微信账号,并对应一个独立 participant。`)})]})]}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:!!e.enabled,onChange:e=>t(`enabled`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用微信 Claw`)})]}),(0,G.jsxs)(`button`,{className:`primary`,disabled:w||a||!e.enabled,onClick:()=>void ee(),children:[(0,G.jsx)(ie,{size:15}),u(w||a?`正在准备连接…`:e.enabled?s.length?`添加 ClawBot 连接`:`扫码连接微信`:`启用后扫码连接`)]})]}),N&&(0,G.jsxs)(`section`,{className:`weixin-pairing `+(h===`confirmed`?`confirmed`:M.failed||_?`failed`:``),children:[(0,G.jsx)(`div`,{className:`weixin-qr-stage`,children:d?(0,G.jsx)(`img`,{src:d,alt:u(`微信 Claw 连接二维码`)}):h===`confirmed`?(0,G.jsx)(C,{size:31}):(0,G.jsx)(H,{size:28})}),(0,G.jsxs)(`div`,{className:`weixin-pairing-copy`,children:[(0,G.jsx)(`span`,{children:u(`连接状态`)}),(0,G.jsx)(`h3`,{children:M.title}),(0,G.jsx)(`p`,{children:_||M.detail}),h===`need_verifycode`&&(0,G.jsxs)(`div`,{className:`weixin-verify`,children:[(0,G.jsx)(`input`,{autoFocus:!0,value:x,onChange:e=>S(e.target.value),onKeyDown:e=>{e.key===`Enter`&&A()},placeholder:u(`手机上显示的数字`)}),(0,G.jsx)(`button`,{className:`primary mini`,onClick:()=>void A(),children:u(`继续连接`)})]}),h!==`confirmed`&&(0,G.jsxs)(`button`,{className:`ghost mini`,disabled:!e.enabled||w||a,onClick:()=>void ee(),children:[(0,G.jsx)(R,{size:13}),u(d?`刷新二维码`:`重新生成二维码`)]})]})]}),y&&(0,G.jsx)(`div`,{className:`notice error`,role:`alert`,children:y}),(0,G.jsx)(`div`,{className:`weixin-account-list`,children:s.length?s.map(e=>(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`div`,{className:`weixin-account-mark`,children:(0,G.jsx)(n,{size:18})}),(0,G.jsxs)(`div`,{className:`weixin-account-copy`,children:[(0,G.jsx)(`input`,{"aria-label":u(`连接名称`),defaultValue:e.display_name||``,onBlur:t=>{t.target.value!==(e.display_name||``)&&j(e.bot_instance_id,{display_name:t.target.value})},placeholder:u(`给这个 ClawBot 连接命名`)}),(0,G.jsx)(`code`,{children:e.bot_instance_id}),(0,G.jsx)(`small`,{children:e.weixin_user_id?u(`登录微信 ID:{{id}}`,{id:e.weixin_user_id}):u(`微信账号标识未返回`)})]}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:e.enabled!==!1,onChange:t=>void j(e.bot_instance_id,{enabled:t.target.checked})}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(e.enabled===!1?`停用`:`在线`)})]}),(0,G.jsx)(`button`,{className:`danger-icon`,onClick:()=>void te(e),title:u(`移除连接`),children:(0,G.jsx)(c,{size:15})})]},e.bot_instance_id)):(0,G.jsx)(`div`,{className:`provider-empty`,children:u(`还没有 ClawBot 连接。点击“扫码连接微信”添加第一个连接。`)})})]})}var _e={weixin:{label:`微信 Claw`,component:ge}};function ve(e){return _e[e]}function ye(){return Object.fromEntries(Object.entries(_e).map(([e,t])=>[e,t.label]))}function be({serverData:e,updateServerData:t,request:n,describeDesktopSave:r}){let[i,a]=(0,W.useState)(null),[o,s]=(0,W.useState)(()=>new URLSearchParams(window.location.search).get(`group`)||`llm`),[c,l]=(0,W.useState)({}),[d,f]=(0,W.useState)(new Set),[p,m]=(0,W.useState)(null),[h,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(new Set),[y,b]=(0,W.useState)(!1);(0,W.useEffect)(()=>{e&&a(t=>t||structuredClone(e.config))},[e]);let x=(0,W.useMemo)(()=>xe(e,i,c,d),[d,i,c,e]);(0,W.useEffect)(()=>{let e=e=>{x.size>0&&e.preventDefault()};return window.addEventListener(`beforeunload`,e),()=>window.removeEventListener(`beforeunload`,e)},[x]);let S=(0,W.useCallback)((e,t)=>{v(n=>{let r=new Set(n);return t?r.delete(e):r.add(e),ke(n,r)?n:r})},[]),C=(0,W.useCallback)((e,t)=>{f(t=>Oe(t,`${o}.${e}`)),a(n=>n&&{...n,[o]:{...n[o],[e]:t}})},[o]),w=(0,W.useCallback)(e=>{s(e),v(new Set),m(null);let t=new URLSearchParams(window.location.search);t.set(`section`,`settings`),t.set(`group`,e),e!==`desktop_updates`&&t.delete(`source`),window.history.pushState(null,``,`?${t.toString()}`)},[]),T=(0,W.useCallback)((e,t,n)=>{if(!i)return;let r=[...i.llm?.managed_providers||[]];r[e]={...r[e],[t]:n},C(`managed_providers`,r)},[C,i]),E=(0,W.useCallback)(e=>{f(t=>{let n=new Set(t);return n.has(e)?n.delete(e):n.add(e),n})},[]),D=(0,W.useCallback)(()=>{e&&(a(t=>t&&{...t,[o]:structuredClone(e.config[o]||{})}),l(e=>Ee(e,o)),f(e=>De(e,o)),v(new Set),m(null))},[o,e]),O=(0,W.useCallback)(async()=>{if(!e||!i)return!1;let s=Se(o,i,h,_);if(s)return m({kind:`error`,text:s}),!1;if(!x.has(o))return!0;let p=structuredClone(e.config?.[o]||{}),g=structuredClone(i[o]||{});b(!0),m(null);try{let e=we(await n(`/api/admin/config`,{method:`PATCH`,body:JSON.stringify(Ce(o,p,g,c,d))}));l(e=>Ee(e,o)),f(e=>De(e,o)),m({kind:`success`,text:o===`desktop_updates`?r(p,g,e):e});let i=await n(`/api/admin/config`);return t(i),a(e=>e&&{...e,[o]:structuredClone(i.config[o]||{})}),!0}catch(e){return m({kind:`error`,text:e instanceof Error?e.message:u(`保存失败`)}),!1}finally{b(!1)}},[d,h,r,x,i,o,_,n,c,e,t]);return{change:C,changeProvider:T,clearOverridePaths:d,desktopValidationError:h,dirtyGroups:x,draft:i,group:o,invalidJsonPaths:_,isHot:(0,W.useCallback)(t=>(e?.hot_reloadable||[]).some(e=>t===e||t.startsWith(`${e}.`)),[e]),message:p,resetGroup:D,save:O,saving:y,secretInputs:c,selectGroup:w,setDesktopValidationError:g,setJsonValidity:S,setSecretInputs:l,toggleClearOverride:E}}function xe(e,t,n,r){let i=new Set;if(!e||!t)return i;for(let n of Object.keys(t))Object.keys(Te(e.config?.[n]||{},t[n]||{})).length>0&&i.add(n);for(let[e,t]of Object.entries(n))t&&i.add(e.split(`.`)[0]);for(let e of r)i.add(e.split(`.`)[0]);return i}function Se(e,t,n,r){if(e===`desktop_updates`&&n)return n;if(r.size>0)return u(`请先修正标出的 JSON 格式。`);let i=Number(t.llm?.max_tokens);return e===`llm`&&(!Number.isInteger(i)||i<=0)?u(`单次输出上限必须是正整数。`):``}function Ce(e,t,n,r,i){let a=Te(t,n);return{changes:Object.keys(a).length>0?{[e]:a}:{},secrets:Object.fromEntries(Object.entries(r).filter(([t,n])=>t.startsWith(`${e}.`)&&n!==``)),clear_overrides:[...i].filter(t=>t.startsWith(`${e}.`))}}function we(e){let t=e.applied_now?.length||0,n=e.requires_restart?.length||0;return t&&n?u(`已保存:{{hot}} 项立即生效,{{restart}} 项等待重启。`,{hot:t,restart:n}):t?u(`已保存,{{count}} 项修改已立即生效。`,{count:t}):n?u(`已保存,{{count}} 项修改将在安全重启后生效。`,{count:n}):u(`配置没有变化。`)}function Te(e,t){return Object.fromEntries(Object.entries(t).filter(([t,n])=>JSON.stringify(n)!==JSON.stringify(e[t])))}function Ee(e,t){return Object.fromEntries(Object.entries(e).filter(([e])=>!e.startsWith(`${t}.`)))}function De(e,t){return new Set([...e].filter(e=>!e.startsWith(`${t}.`)))}function Oe(e,t){if(!e.has(t))return e;let n=new Set(e);return n.delete(t),n}function ke(e,t){return e.size===t.size&&[...e].every(e=>t.has(e))}async function Ae({cursor:e,filtersActive:t,fetchPage:n}){let r=e,i=new Set;for(r&&i.add(r);;){let e=await n(r),a=Array.isArray(e.events)?e.events:[],o=typeof e.next_cursor==`string`?e.next_cursor:null;if(!t||a.length>0||!o)return e;if(i.has(o))return{...e,next_cursor:null,has_more:!1};i.add(o),r=o}}var je=[{id:`overview`,label:`生命总览`,description:`状态、上下文和当前驻留情况`,group:`观察`,icon:M},{id:`memory`,label:`记忆中心`,description:`短期上下文、长期召回与并行思考记录`,group:`观察`,icon:i},{id:`runtime`,label:`运行中心`,description:`任务、闹钟、运行账本与维护`,group:`观察`,icon:a},{id:`models`,label:`模型编排`,description:`主线模型、摘要与失败降级链`,group:`塑形`,icon:x},{id:`settings`,label:`运行设置`,description:`连接、记忆与循环参数`,group:`塑形`,icon:g},{id:`identity`,label:`身份档案`,description:`姓名、现居地和人格`,group:`塑形`,icon:j},{id:`content`,label:`能力内容`,description:`Skill、Palace 与潜意识模式`,group:`扩展`,icon:ee},{id:`releases`,label:`桌面发布`,description:`版本、签名产物与更新投放`,group:`扩展`,icon:ne},{id:`audit`,label:`诊断与审计`,description:`事件循环健康与管理员操作记录`,group:`追溯`,icon:B}],Me=[`观察`,`塑形`,`扩展`,`追溯`];function Ne(){let e=new URLSearchParams(window.location.search).get(`section`);return je.some(t=>t.id===e)?e:`overview`}function Pe(){return sessionStorage.getItem(`coworker-admin-token`)||``}var Fe=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function q(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{...t,headers:{...t.body&&!n?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${Pe()}`,...t.headers}});if(!r.ok){let e=await r.json().catch(()=>({}));throw new Fe(typeof e.detail==`string`?e.detail:JSON.stringify(e.detail||u(`请求失败 {{status}}`,{status:r.status})),r.status)}return r.status===204?{}:r.json()}async function J(e,t){let n=await fetch(e,{headers:{Authorization:`Bearer ${Pe()}`}});if(!n.ok){let e=await n.json().catch(()=>({}));throw new Fe(typeof e.detail==`string`?e.detail:u(`下载失败`),n.status)}let r=URL.createObjectURL(await n.blob()),i=document.createElement(`a`);i.href=r,i.download=t,document.body.appendChild(i),i.click(),i.remove(),window.setTimeout(()=>URL.revokeObjectURL(r),0)}function Y(e,t=[]){let[n,r]=(0,W.useState)(null),[i,a]=(0,W.useState)(``),[o,s]=(0,W.useState)(!0),c=(0,W.useRef)(0),l=(0,W.useCallback)(async()=>{let t=++c.current;s(!0),a(``);try{let n=await e();c.current===t&&r(n)}catch(e){c.current===t&&a(e instanceof Error?e.message:u(`加载失败`))}finally{c.current===t&&s(!1)}},t);return(0,W.useEffect)(()=>(l(),()=>{c.current+=1}),[l]),{data:n,error:i,loading:o,reload:l,setData:r}}function Ie({onReady:e}){let[t,n]=(0,W.useState)(``),[r,i]=(0,W.useState)(``),[a,o]=(0,W.useState)(!1);return(0,G.jsxs)(`main`,{className:`admin-login`,children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsxs)(`section`,{className:`login-card`,children:[(0,G.jsxs)(`div`,{className:`login-presence`,children:[(0,G.jsxs)(`div`,{className:`login-sigil`,children:[(0,G.jsx)(L,{size:34}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`本地控制台`)}),(0,G.jsx)(`h1`,{children:u(`进入照看室`)}),(0,G.jsx)(`p`,{className:`login-copy`,children:u(`查看生命迹象,调整她的运行方式,并谨慎触碰记忆。`)})]}),(0,G.jsxs)(`div`,{className:`login-life-trace`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{className:`login-assurance`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`i`,{}),u(`本地值守`)]}),(0,G.jsx)(`span`,{children:u(`令牌仅保留在当前会话`)})]})]}),(0,G.jsxs)(`div`,{className:`login-access`,children:[(0,G.jsx)(`p`,{className:`access-step`,children:u(`访问步骤 01`)}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`h2`,{children:u(`确认照看权限`)}),(0,G.jsx)(`p`,{children:u(`使用管理员令牌开启这次值守会话。`)})]}),(0,G.jsxs)(`form`,{onSubmit:async n=>{n.preventDefault(),o(!0),i(``),sessionStorage.setItem(`coworker-admin-token`,t);try{e(await q(`/api/admin/session/verify`,{method:`POST`}))}catch(e){sessionStorage.removeItem(`coworker-admin-token`),i(e instanceof Error?e.message:u(`验证失败`))}finally{o(!1)}},children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`管理员令牌`)}),(0,G.jsxs)(`div`,{className:`token-input`,children:[(0,G.jsx)(N,{size:17}),(0,G.jsx)(`input`,{autoFocus:!0,type:`password`,value:t,onChange:e=>n(e.target.value),placeholder:u(`输入 ADMIN__TOKEN`),autoComplete:`current-password`})]})]}),r&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:r}),(0,G.jsxs)(`button`,{className:`primary`,disabled:!t||a,children:[u(a?`正在确认…`:`进入值守台`),(0,G.jsx)(f,{size:16})]})]}),(0,G.jsxs)(`a`,{href:`/`,children:[u(`返回生命体主页`),` `,(0,G.jsx)(f,{size:14})]})]})]})]})}var Le={anthropic:`Anthropic`,openai:`OpenAI / 兼容服务`,deepseek:`DeepSeek`,qwen:`通义千问`,zhipu:`智谱 GLM`,minimax:`MiniMax`},Re={anthropic:`claude-sonnet-4-8`,openai:`gpt-5.5`,deepseek:`deepseek-v4-pro`,qwen:`qwen3.7-plus`,zhipu:`glm-5.2`,minimax:`MiniMax-M3`};function ze(e,t){let n=Re[e];return n&&t.includes(n)?n:t[0]||``}function Be({data:e,onComplete:t}){let n=e.providers||[],[r,i]=(0,W.useState)(n.some(e=>e.type===`deepseek`)?`deepseek`:n[0]?.type||`openai`),a=n.find(e=>e.type===r)?.models||[],[o,s]=(0,W.useState)(ze(r,a)),[c,l]=(0,W.useState)(``),[d,p]=(0,W.useState)(``),[m,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(e.defaults?.locale===`en`?`en`:`zh-CN`),[y,b]=(0,W.useState)(String(e.defaults?.max_tokens||8192)),[C,w]=(0,W.useState)(!!e.defaults?.passive_mode),[T,E]=(0,W.useState)(!1),[D,O]=(0,W.useState)(!1),[k,ee]=(0,W.useState)(null),[A,j]=(0,W.useState)(-1),te=(0,W.useRef)(null),M=(0,W.useRef)([]),P=(0,W.useRef)(!1),[F,I]=(0,W.useState)(!1),[ne,re]=(0,W.useState)(``),[ie,ae]=(0,W.useState)(`form`),z=o.trim(),oe=z!==``&&!a.includes(z),se=Number(y),ce=Number.isInteger(se)&&se>0,V=`bootstrap-model-listbox-${r}`,le=A>=0?`bootstrap-model-option-${r}-${A}`:void 0,U=(0,W.useMemo)(()=>{if(k===null)return a;let e=k.toLowerCase();return a.filter(t=>t.toLowerCase().includes(e))},[k,a]),K=()=>{O(!1),ee(null),j(-1)},ue=()=>{let e=a.findIndex(e=>e===z);ee(null),j(e),O(!0)},de=e=>{s(e),E(!1),K()},fe=e=>{let t=n.find(t=>t.type===e)?.models||[];i(e),s(ze(e,t)),E(!1),K()},pe=e=>{if(!U.length){j(-1);return}j(t=>t<0?e>0?0:U.length-1:(t+e+U.length)%U.length)};return(0,W.useEffect)(()=>{if(!D)return;let e=e=>{let t=e.target;t instanceof Node&&!te.current?.contains(t)&&K()};return document.addEventListener(`pointerdown`,e),()=>document.removeEventListener(`pointerdown`,e)},[D]),(0,W.useEffect)(()=>{A>=0&&M.current[A]?.scrollIntoView({block:`nearest`})},[A,U]),(0,G.jsxs)(`main`,{className:`admin-login admin-bootstrap`,children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsxs)(`section`,{className:`bootstrap-card`,children:[(0,G.jsxs)(`aside`,{className:`bootstrap-rail`,children:[(0,G.jsxs)(`div`,{className:`login-sigil`,children:[(0,G.jsx)(L,{size:32}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`初始设置`)}),(0,G.jsxs)(`h1`,{children:[u(`接通她的`),(0,G.jsx)(`br`,{}),u(`第一束信号`)]}),(0,G.jsx)(`p`,{children:u(`管理员入口已经准备好。再连接一个模型服务,Coworker 就能开始工作。`)}),(0,G.jsxs)(`ol`,{className:`awakening-circuit`,children:[(0,G.jsxs)(`li`,{className:`done`,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(N,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`访问凭证`)}),(0,G.jsx)(`small`,{children:u(`已安全生成并保存`)})]})]}),(0,G.jsxs)(`li`,{className:ie===`form`?`active`:`done`,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(x,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`模型连接`)}),(0,G.jsx)(`small`,{children:u(ie===`form`?`等待填写`:`配置已写入`)})]})]}),(0,G.jsxs)(`li`,{className:ie===`restarting`?`active`:``,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(R,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`唤醒运行`)}),(0,G.jsx)(`small`,{children:u(ie===`restarting`?`正在安全重启`:`完成后自动进行`)})]})]})]})]}),(0,G.jsx)(`section`,{className:`bootstrap-form-stage`,children:ie===`restarting`?(0,G.jsxs)(`div`,{className:`bootstrap-restarting`,role:`status`,children:[(0,G.jsxs)(`div`,{className:`restart-orbit`,children:[(0,G.jsx)(L,{size:34}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`p`,{className:`access-step`,children:u(`设置步骤 03`)}),(0,G.jsx)(`h2`,{children:u(`正在带着新配置醒来`)}),(0,G.jsx)(`p`,{children:u(`页面会在服务恢复后自动进入照看室,不需要重复填写。`)}),ne&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:ne})]}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`div`,{className:`bootstrap-heading`,children:[(0,G.jsx)(`p`,{className:`access-step`,children:u(`设置步骤 02`)}),(0,G.jsx)(`h2`,{children:u(`配置第一个模型连接`)}),(0,G.jsxs)(`p`,{children:[u(`这些值会写入本地管理配置,不需要创建`),` `,(0,G.jsx)(`code`,{children:`.env`}),u(`。`)]})]}),(0,G.jsxs)(`form`,{className:`bootstrap-form`,onSubmit:async e=>{if(e.preventDefault(),!P.current){P.current=!0,I(!0),re(``);try{await q(`/api/admin/bootstrap`,{method:`POST`,body:JSON.stringify({provider_type:r,model:z,api_key:c,base_url:d,coworker_name:m,locale:_,max_tokens:se,passive_mode:C,allow_unverified_model:oe&&T})}),ae(`restarting`);let e=Date.now()+9e4;(async()=>{for(;Date.now()window.setTimeout(e,1500));try{if(!(await q(`/api/admin/bootstrap`)).required){t();return}}catch{}}re(u(`配置已经保存,但服务仍在重启。请稍后刷新页面。`))})()}catch(e){P.current=!1,I(!1),re(e instanceof Error?e.message:u(`初始化失败`))}}},children:[(0,G.jsxs)(`div`,{className:`bootstrap-grid`,children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`服务类型`)}),(0,G.jsx)(`select`,{value:r,onChange:e=>fe(e.target.value),children:n.map(e=>(0,G.jsx)(`option`,{value:e.type,children:u(Le[e.type]||e.type)},e.type))})]}),(0,G.jsxs)(`div`,{className:`bootstrap-model-field${D?` open`:``}`,children:[(0,G.jsx)(`label`,{id:`bootstrap-model-label`,htmlFor:`bootstrap-model-input`,children:u(`启动模型`)}),(0,G.jsxs)(`div`,{className:`bootstrap-model-combobox`,ref:te,children:[(0,G.jsx)(`input`,{id:`bootstrap-model-input`,role:`combobox`,"aria-autocomplete":`list`,"aria-haspopup":`listbox`,"aria-expanded":D,"aria-controls":V,"aria-activedescendant":le,autoComplete:`off`,spellCheck:!1,value:o,onFocus:()=>{D||ue()},onClick:()=>{D||ue()},onKeyDown:e=>{e.key===`ArrowDown`?(e.preventDefault(),D?pe(1):ue()):e.key===`ArrowUp`?(e.preventDefault(),D?pe(-1):(ue(),j(Math.max(a.length-1,-1)))):e.key===`Enter`&&D&&A>=0&&U[A]?(e.preventDefault(),de(U[A])):e.key===`Escape`&&D?(e.preventDefault(),K()):e.key===`Tab`&&K()},onChange:e=>{s(e.target.value),ee(e.target.value),O(!0),j(-1),E(!1)},placeholder:u(`选择推荐模型或输入模型 ID`)}),D&&(0,G.jsx)(`ul`,{className:`bootstrap-model-listbox`,id:V,role:`listbox`,"aria-labelledby":`bootstrap-model-label`,children:U.length?U.map((e,t)=>(0,G.jsxs)(`li`,{id:`bootstrap-model-option-${r}-${t}`,ref:e=>{M.current[t]=e},className:`${t===A?`active`:``}${e===z?` selected`:``}`,role:`option`,"aria-selected":e===z,onMouseEnter:()=>j(t),onPointerDown:e=>{e.pointerType===`mouse`&&e.preventDefault()},onClick:()=>de(e),children:[(0,G.jsx)(`span`,{children:e}),e===z&&(0,G.jsx)(S,{size:13})]},e)):(0,G.jsx)(`li`,{className:`bootstrap-model-empty`,role:`status`,children:u(`没有匹配的推荐模型;仍可直接使用当前模型 ID。`)})})]})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`运行时语言`)}),(0,G.jsxs)(`select`,{value:_,onChange:e=>v(e.target.value),children:[(0,G.jsx)(`option`,{value:`zh-CN`,children:`简体中文 (zh-CN)`}),(0,G.jsx)(`option`,{value:`en`,children:`English (en)`})]})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`单次输出 Token 上限`)}),(0,G.jsx)(`input`,{required:!0,type:`number`,min:`1`,step:`1`,value:y,onChange:e=>b(e.target.value)})]}),(0,G.jsxs)(`div`,{className:`bootstrap-mode wide`,role:`radiogroup`,"aria-label":u(`启动模式`),children:[(0,G.jsx)(`span`,{children:u(`启动模式`)}),(0,G.jsxs)(`button`,{type:`button`,className:C?``:`active`,role:`radio`,"aria-checked":!C,onClick:()=>w(!1),children:[(0,G.jsx)(`b`,{children:u(`主动模式`)}),(0,G.jsx)(`small`,{children:u(`空闲后会周期性自我唤醒并继续观察。`)})]}),(0,G.jsxs)(`button`,{type:`button`,className:C?`active`:``,role:`radio`,"aria-checked":C,onClick:()=>w(!0),children:[(0,G.jsx)(`b`,{children:u(`Passive 模式`)}),(0,G.jsx)(`small`,{children:u(`空闲后只等待外部消息、闹钟或任务事件唤醒。`)})]})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsx)(`span`,{children:`API Key`}),(0,G.jsx)(`input`,{autoFocus:!0,required:!0,type:`password`,value:c,onChange:e=>l(e.target.value),placeholder:u(`只会保存到本机配置`),autoComplete:`new-password`})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsxs)(`span`,{children:[u(`自定义 Base URL`),` `,(0,G.jsx)(`em`,{children:u(`可选`)})]}),(0,G.jsx)(`input`,{type:`url`,value:d,onChange:e=>p(e.target.value),placeholder:u(`使用官方地址时留空`)})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsxs)(`span`,{children:[u(`给 Coworker 起个名字`),` `,(0,G.jsx)(`em`,{children:u(`可选`)})]}),(0,G.jsx)(`input`,{value:m,onChange:e=>g(e.target.value),placeholder:u(`之后也可以在身份档案中修改`)})]})]}),oe&&(0,G.jsxs)(`div`,{className:`bootstrap-model-warning`,children:[(0,G.jsx)(H,{size:17}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`这是推荐目录外的模型`)}),(0,G.jsx)(`p`,{children:u(`Coworker 主模型必须支持 tool/function calling;初始化不会发起在线能力探测。`)}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:T,onChange:e=>E(e.target.checked)}),(0,G.jsx)(`span`,{children:u(`我确认该模型及当前 API 服务支持工具调用`)})]})]})]}),ne&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:ne}),(0,G.jsxs)(`button`,{className:`primary`,disabled:F||!c.trim()||!z||!ce||oe&&!T,children:[u(F?`正在保存…`:`保存并唤醒`),` `,(0,G.jsx)(f,{size:16})]})]}),(0,G.jsxs)(`p`,{className:`bootstrap-footnote`,children:[(0,G.jsx)(B,{size:13}),u(`配置保存在`),` `,(0,G.jsx)(`code`,{children:`data/admin_config.json`}),u(`,API Key 不会回显到页面。`)]})]})})]})]})}function Ve({text:e}){let n=e.length>900,[r,i]=(0,W.useState)(!1);return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`release-notes`+(n&&!r?` preview`:``),lang:/[\u3400-\u9fff]/.test(e)?`zh-CN`:`en`,children:(0,G.jsx)(t,{allowedElements:[`p`,`h1`,`h2`,`h3`,`h4`,`strong`,`em`,`del`,`ul`,`ol`,`li`,`a`,`code`,`pre`,`blockquote`,`br`,`table`,`thead`,`tbody`,`tr`,`th`,`td`],remarkPlugins:[s],components:{a:({href:e,children:t})=>(0,G.jsx)(`a`,{href:e,target:`_blank`,rel:`noreferrer`,children:t})},children:e})}),n&&(0,G.jsx)(`button`,{type:`button`,className:`release-notes-toggle`,onClick:()=>i(e=>!e),children:u(r?`收起发布说明`:`展开完整发布说明`)})]})}function X({title:e,note:t,action:n,children:r,className:i=``}){return(0,G.jsxs)(`section`,{className:`admin-panel ${i}`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`h2`,{children:u(e)}),typeof t==`string`?(0,G.jsx)(`p`,{children:u(t)}):t]}),n]}),r]})}function Z({error:e}){return(0,G.jsxs)(`div`,{className:e?`state-box error`:`state-box`,role:e?`alert`:`status`,children:[!e&&(0,G.jsxs)(`span`,{className:`state-pulse`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(e||`正在读取生命迹象…`)})]})}function He(e){return e.is_running?e.is_sleeping?u(e.passive_mode?`等待事件`:`休息中`):u(e.passive_mode?`处理事件`:`正在运行`):u(`未运行`)}function Ue(e){return e.passive_mode?u(`仅由外部事件唤醒`):Number(e.idle_sleep_seconds)===0?u(`无间隔持续运行`):u(`每 {{seconds}} 秒自唤醒`,{seconds:e.idle_sleep_seconds})}function We({name:e}){let{data:t,error:n,loading:r,reload:a}=Y(()=>q(`/api/admin/overview`),[]);if(r||!t)return(0,G.jsx)(Z,{error:n});let o=t.status,s=t.counts,c=o.is_running,l=c&&!!o.is_sleeping,d=c?l?`resting`:`running`:`quiet`,f=He(o),p=Ue(o),m=new Date().toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`});return(0,G.jsxs)(`div`,{className:`page-stack`,children:[(0,G.jsxs)(`section`,{className:`presence-hero ${d}`,children:[(0,G.jsxs)(`div`,{className:`presence-copy`,children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`状态采样`)}),(0,G.jsxs)(`h1`,{children:[e||`Coworker`,(0,G.jsx)(`span`,{className:`live-badge ${d}`,children:f})]}),(0,G.jsxs)(`div`,{className:`presence-readout`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`主线模型`)}),(0,G.jsxs)(`strong`,{children:[o.provider,`/`,o.model]})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`生命循环`)}),(0,G.jsx)(`strong`,{children:u(`第 {{count}} 次`,{count:o.cycle_count||0})})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`唤醒方式`)}),(0,G.jsx)(`strong`,{children:p})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`本次采样`)}),(0,G.jsx)(`strong`,{children:m})]})]})]}),(0,G.jsxs)(`div`,{className:`pulse-organ`,"aria-label":f,children:[(0,G.jsx)(`span`,{className:`organ-line`,"aria-hidden":`true`}),[22,42,66,92,56,38,74,48,26].map((e,t)=>(0,G.jsx)(`i`,{style:{"--h":`${e}%`,"--d":`${t*.08}s`}},t)),(0,G.jsxs)(`small`,{children:[u(`生命信号`),` · `,f]})]}),(0,G.jsx)(`button`,{className:`icon-btn`,onClick:()=>void a(),title:u(`刷新生命迹象`),"aria-label":u(`刷新生命迹象`),children:(0,G.jsx)(R,{size:16})})]}),t.pending_restart&&(0,G.jsxs)(`div`,{className:`notice amber`,children:[(0,G.jsx)(H,{size:17}),(0,G.jsx)(`span`,{children:u(`有配置等待重启后生效。`)})]}),(0,G.jsx)(`div`,{className:`vital-grid`,children:[[u(`活跃任务`),s.active_tasks,u(`{{count}} 项总计`,{count:s.tasks}),P],[u(`运行 Bubble`),s.active_bubbles,u(`并行思考分支`),L],[u(`长期记忆`),s.long_term_memories,u(`可语义检索`),i],[u(`短期上下文`),s.short_term_messages,u(`{{count}} 个树节点`,{count:t.memory.tree_nodes}),I],[u(`待触发闹钟`),s.alarms,u(`后台守候中`),y]].map(([e,t,n,r])=>(0,G.jsxs)(`article`,{className:`vital`,children:[(0,G.jsx)(r,{size:18}),(0,G.jsx)(`span`,{children:u(e)}),(0,G.jsx)(`strong`,{children:Number(t).toLocaleString()}),(0,G.jsx)(`small`,{children:u(n)})]},e))}),(0,G.jsxs)(`div`,{className:`two-col`,children:[(0,G.jsx)(X,{title:`上下文水位`,note:`短期消息与记忆树的当前结构`,children:(0,G.jsxs)(`div`,{className:`memory-meter`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`消息`)}),(0,G.jsx)(`b`,{children:t.memory.messages})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`容量`)}),(0,G.jsxs)(`b`,{children:[Number(t.memory.max_tokens).toLocaleString(),` token`]})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`回溯`)}),(0,G.jsx)(`b`,{children:t.memory.backfill?.running?`${t.memory.backfill.done}/${t.memory.backfill.total}`:u(`空闲`)})]})]})}),(0,G.jsx)(X,{title:`进程驻留`,note:`当前实例的连续运行时间`,children:(0,G.jsxs)(`div`,{className:`runtime-clock`,children:[(0,G.jsx)(w,{size:34}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:new Date(o.started_at).toLocaleString()}),(0,G.jsx)(`span`,{children:u(`本轮启动时间`)})]})]})})]})]})}function Ge(){let{data:e,error:t,loading:n,reload:r,setData:i}=Y(()=>q(`/api/admin/model`),[]),[a,s]=(0,W.useState)({provider:``,model_id:``}),[c,l]=(0,W.useState)(``),[d,f]=(0,W.useState)(!1),[p,m]=(0,W.useState)(null);(0,W.useEffect)(()=>{e&&(m(JSON.parse(JSON.stringify(e))),s({provider:e.active.provider||``,model_id:e.active.model||``}))},[e]);let h=async()=>{if(!p)return;let e=await q(`/api/admin/model`,{method:`PATCH`,body:JSON.stringify({summary:p.summary,fallbacks:p.fallbacks,vision:p.vision})});i(e),m(e)},g=async()=>{l(``),f(!0);try{let e=await q(`/api/admin/model/switch`,{method:`POST`,body:JSON.stringify(a)});i(e),m(e),s({provider:``,model_id:``})}catch(e){l(e instanceof Error?e.message:u(`切换模型失败`))}finally{f(!1)}};if(n||!p)return(0,G.jsx)(Z,{error:t});let _=(e,t)=>m(n=>{let r=structuredClone(n),[i,a]=e.split(`.`);return r[i][a]=t,r});return(0,G.jsxs)(`div`,{className:`page-stack`,children:[(0,G.jsxs)(X,{title:`主线模型`,note:`切换立即生效,正在执行的单次调用不会被中断。`,children:[(0,G.jsxs)(`div`,{className:`active-model`,children:[(0,G.jsx)(o,{size:28}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`当前接棒者`)}),(0,G.jsxs)(`strong`,{children:[p.active.provider,`/`,p.active.model]})]})]}),(0,G.jsxs)(`div`,{className:`inline-form`,children:[(0,G.jsxs)(`select`,{value:a.provider,onChange:e=>s({...a,provider:e.target.value}),children:[(0,G.jsx)(`option`,{value:``,children:u(`选择 Provider`)}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]}),(0,G.jsx)(`input`,{value:a.model_id,onChange:e=>s({...a,model_id:e.target.value}),placeholder:u(`模型 ID(留空使用默认)`)}),(0,G.jsx)(`button`,{className:`primary`,disabled:!a.provider||d,onClick:()=>void g(),children:u(d?`正在切换…`:`切换模型`)})]}),c&&(0,G.jsxs)(`div`,{className:`notice error`,role:`alert`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:c})]})]}),(0,G.jsxs)(`div`,{className:`two-col`,children:[(0,G.jsx)(X,{title:`摘要与压缩`,note:`控制上下文压缩时使用的模型。`,children:(0,G.jsxs)(`div`,{className:`field-grid`,children:[(0,G.jsx)(Q,{label:`Provider`,hint:`留空时跟随主线模型`,children:(0,G.jsxs)(`select`,{value:p.summary.provider,onChange:e=>_(`summary.provider`,e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`跟随主线({{provider}})`,{provider:p.active.provider})}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]})}),(0,G.jsx)(Q,{label:`模型`,hint:`留空时跟随主线模型`,children:(0,G.jsx)(`input`,{value:p.summary.model,onChange:e=>_(`summary.model`,e.target.value),placeholder:p.active.model})}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:p.summary.thinking,onChange:e=>_(`summary.thinking`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用 Thinking`)})]})]})}),(0,G.jsx)(X,{title:`视觉理解`,note:`为纯文本主模型提供图片分析能力。`,children:(0,G.jsxs)(`div`,{className:`field-grid`,children:[(0,G.jsx)(Q,{label:`Provider`,children:(0,G.jsxs)(`select`,{value:p.vision.provider,onChange:e=>_(`vision.provider`,e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`关闭`)}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]})}),(0,G.jsx)(Q,{label:`模型`,children:(0,G.jsx)(`input`,{value:p.vision.model,onChange:e=>_(`vision.model`,e.target.value)})}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:p.vision.thinking,onChange:e=>_(`vision.thinking`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用 Thinking`)})]})]})})]}),(0,G.jsxs)(X,{title:`失败降级链`,note:`每行填写 provider 或 provider/model,按从上到下的顺序接棒。`,children:[(0,G.jsx)(`textarea`,{className:`code-area short`,value:(p.fallbacks||[]).join(` +import{_ as e,a as t,c as n,d as r,f as i,g as a,h as o,i as s,l as c,m as l,n as u,o as d,p as f,r as p,s as m,t as h,u as g,v as _,y as v}from"./index-3W-zuFpp.js";var y=e(`alarm-clock`,[[`circle`,{cx:`12`,cy:`13`,r:`8`,key:`3y4lt7`}],[`path`,{d:`M12 9v4l2 2`,key:`1c63tq`}],[`path`,{d:`M5 3 2 6`,key:`18tl5t`}],[`path`,{d:`m22 6-3-3`,key:`1opdir`}],[`path`,{d:`M6.38 18.7 4 21`,key:`17xu3x`}],[`path`,{d:`M17.64 18.67 20 21`,key:`kv2oe2`}]]),b=e(`archive-restore`,[[`rect`,{width:`20`,height:`5`,x:`2`,y:`3`,rx:`1`,key:`1wp1u1`}],[`path`,{d:`M4 8v11a2 2 0 0 0 2 2h2`,key:`tvwodi`}],[`path`,{d:`M20 8v11a2 2 0 0 1-2 2h-2`,key:`1gkqxj`}],[`path`,{d:`m9 15 3-3 3 3`,key:`1pd0qc`}],[`path`,{d:`M12 12v9`,key:`192myk`}]]),x=e(`brain`,[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]),S=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),C=e(`circle-check-big`,[[`path`,{d:`M21.801 10A10 10 0 1 1 17 3.335`,key:`yps3ct`}],[`path`,{d:`m9 11 3 3L22 4`,key:`1pflzl`}]]),w=e(`circle-gauge`,[[`path`,{d:`M15.6 2.7a10 10 0 1 0 5.7 5.7`,key:`1e0p6d`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`path`,{d:`M13.4 10.6 19 5`,key:`1kr7tw`}]]),T=e(`clock-3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 6v6h4`,key:`135r8i`}]]),E=e(`cloud-upload`,[[`path`,{d:`M12 13v8`,key:`1l5pq0`}],[`path`,{d:`M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242`,key:`1pljnt`}],[`path`,{d:`m8 17 4-4 4 4`,key:`1quai1`}]]),D=e(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),O=e(`file-archive`,[[`path`,{d:`M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5`,key:`4pqfef`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 12v-1`,key:`1ej8lb`}],[`path`,{d:`M8 18v-2`,key:`qcmpov`}],[`path`,{d:`M8 7V6`,key:`1nbb54`}],[`circle`,{cx:`8`,cy:`20`,r:`2`,key:`ckkr5m`}]]),k=e(`file-code-corner`,[[`path`,{d:`M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35`,key:`1wthlu`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`m5 16-3 3 3 3`,key:`331omg`}],[`path`,{d:`m9 22 3-3-3-3`,key:`lsp7cz`}]]),ee=e(`file-cog`,[[`path`,{d:`M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z`,key:`1ckgky`}],[`path`,{d:`M20 8v12a2 2 0 0 1-2 2h-4.182`,key:`1726p0`}],[`path`,{d:`m3.305 19.53.923-.382`,key:`ao1pio`}],[`path`,{d:`M4 10.592V4a2 2 0 0 1 2-2h8`,key:`1foop0`}],[`path`,{d:`m4.228 16.852-.924-.383`,key:`1fv9zy`}],[`path`,{d:`m5.852 15.228-.383-.923`,key:`1a9hc2`}],[`path`,{d:`m5.852 20.772-.383.924`,key:`1sh9ke`}],[`path`,{d:`m8.148 15.228.383-.923`,key:`4yu6lf`}],[`path`,{d:`m8.53 21.696-.382-.924`,key:`18b0s9`}],[`path`,{d:`m9.773 16.852.922-.383`,key:`ti6xop`}],[`path`,{d:`m9.773 19.148.922.383`,key:`rws47d`}],[`circle`,{cx:`7`,cy:`18`,r:`3`,key:`lvkj7j`}]]),A=e(`file-text`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),j=e(`fingerprint-pattern`,[[`path`,{d:`M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4`,key:`1nerag`}],[`path`,{d:`M14 13.12c0 2.38 0 6.38-1 8.88`,key:`o46ks0`}],[`path`,{d:`M17.29 21.02c.12-.6.43-2.3.5-3.02`,key:`ptglia`}],[`path`,{d:`M2 12a10 10 0 0 1 18-6`,key:`ydlgp0`}],[`path`,{d:`M2 16h.01`,key:`1gqxmh`}],[`path`,{d:`M21.8 16c.2-2 .131-5.354 0-6`,key:`drycrb`}],[`path`,{d:`M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2`,key:`1tidbn`}],[`path`,{d:`M8.65 22c.21-.66.45-1.32.57-2`,key:`13wd9y`}],[`path`,{d:`M9 6.8a6 6 0 0 1 9 5.2v2`,key:`1fr1j5`}]]),te=e(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),M=e(`heart-pulse`,[[`path`,{d:`M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5`,key:`mvr1a0`}],[`path`,{d:`M3.22 13H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27`,key:`auskq0`}]]),N=e(`key-round`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),P=e(`list-todo`,[[`path`,{d:`M13 5h8`,key:`a7qcls`}],[`path`,{d:`M13 12h8`,key:`h98zly`}],[`path`,{d:`M13 19h8`,key:`c3s6r1`}],[`path`,{d:`m3 17 2 2 4-4`,key:`1jhpwq`}],[`rect`,{x:`3`,y:`4`,width:`6`,height:`6`,rx:`1`,key:`cif1o7`}]]),F=e(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),I=e(`messages-square`,[[`path`,{d:`M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z`,key:`1n2ejm`}],[`path`,{d:`M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1`,key:`1qfcsi`}]]),L=e(`orbit`,[[`path`,{d:`M20.341 6.484A10 10 0 0 1 10.266 21.85`,key:`1enhxb`}],[`path`,{d:`M3.659 17.516A10 10 0 0 1 13.74 2.152`,key:`1crzgf`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}],[`circle`,{cx:`19`,cy:`5`,r:`2`,key:`mhkx31`}],[`circle`,{cx:`5`,cy:`19`,r:`2`,key:`v8kfzx`}]]),ne=e(`package-open`,[[`path`,{d:`M12 22v-9`,key:`x3hkom`}],[`path`,{d:`M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z`,key:`2ntwy6`}],[`path`,{d:`M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13`,key:`1pmm1c`}],[`path`,{d:`M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z`,key:`12ttoo`}]]),re=e(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),ie=e(`qr-code`,[[`rect`,{width:`5`,height:`5`,x:`3`,y:`3`,rx:`1`,key:`1tu5fj`}],[`rect`,{width:`5`,height:`5`,x:`16`,y:`3`,rx:`1`,key:`1v8r4q`}],[`rect`,{width:`5`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`1x03jg`}],[`path`,{d:`M21 16h-3a2 2 0 0 0-2 2v3`,key:`177gqh`}],[`path`,{d:`M21 21v.01`,key:`ents32`}],[`path`,{d:`M12 7v3a2 2 0 0 1-2 2H7`,key:`8crl2c`}],[`path`,{d:`M3 12h.01`,key:`nlz23k`}],[`path`,{d:`M12 3h.01`,key:`n36tog`}],[`path`,{d:`M12 16v.01`,key:`133mhm`}],[`path`,{d:`M16 12h1`,key:`1slzba`}],[`path`,{d:`M21 12v.01`,key:`1lwtk9`}],[`path`,{d:`M12 21v-1`,key:`1880an`}]]),R=e(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),ae=e(`rocket`,[[`path`,{d:`M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5`,key:`qeys4`}],[`path`,{d:`M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09`,key:`u4xsad`}],[`path`,{d:`M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z`,key:`676m9`}],[`path`,{d:`M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05`,key:`92ym6u`}]]),z=e(`rotate-ccw`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}]]),oe=e(`save`,[[`path`,{d:`M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z`,key:`1c8476`}],[`path`,{d:`M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7`,key:`1ydtos`}],[`path`,{d:`M7 3v4a1 1 0 0 0 1 1h7`,key:`t51u73`}]]),se=e(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),B=e(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ce=e(`sliders-horizontal`,[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]),V=e(`sparkles`,[[`path`,{d:`M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z`,key:`1s2grr`}],[`path`,{d:`M20 2v4`,key:`1rf3ol`}],[`path`,{d:`M22 4h-4`,key:`gwowj6`}],[`circle`,{cx:`4`,cy:`20`,r:`2`,key:`6kqj1y`}]]),le=e(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),H=e(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),U=e(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]),W=v(_(),1),G=d(),K=`/api/admin/channels/weixin/management`,ue=900,de=200,fe={title:`使用微信扫描二维码`,detail:`扫码会新增一个独立 ClawBot 实例;二维码由谁查看不会改变连接归属。`},pe={scaned:{title:`已扫码,请在手机上确认`,detail:`二维码已被识别,连接会在手机确认后自动建立。`},need_verifycode:{title:`输入手机显示的数字`,detail:`完成验证后会继续建立 ClawBot 连接。`},confirmed:{title:`微信连接已建立`,detail:`新的 ClawBot 实例已经加入连接列表。`,terminal:!0},expired:{title:`二维码已过期`,detail:`重新生成二维码后再使用微信扫描。`,terminal:!0,failed:!0},verify_code_blocked:{title:`验证次数暂时受限`,detail:`请稍后重新生成二维码并再次连接。`,terminal:!0,failed:!0},binded_redirect:{title:`这个微信账号已经绑定其他 Bot`,detail:`iLink 只允许一个微信账号绑定一个 Bot 实例。`,terminal:!0,failed:!0}};function me(e){let t=pe[e]||fe;return{...t,title:u(t.title),detail:u(t.detail)}}function he(e,t){return e instanceof Error?e.message:u(t)}function ge({value:e,change:t,apply:r,dirty:i,saving:a,request:o}){let[s,l]=(0,W.useState)([]),[d,f]=(0,W.useState)(``),[p,m]=(0,W.useState)(``),[h,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(``),[y,b]=(0,W.useState)(``),[x,S]=(0,W.useState)(``),[w,T]=(0,W.useState)(!1),E=(0,W.useRef)(null),D=(0,W.useRef)(!0),O=(0,W.useCallback)(async()=>{try{let e=await o(K);D.current&&(l(Array.isArray(e.connections)?e.connections:[]),b(``))}catch(e){D.current&&b(he(e,`微信连接状态读取失败`))}},[o]),k=(0,W.useCallback)(async()=>{try{let e=await o(K);if(!D.current)return;let t=e.pairing;if(!t)return;m(t.session_id||``),g(t.status||`wait`);let n=me(t.status);if(n.failed?f(``):t.qrcode_data_url&&f(t.qrcode_data_url),t.status===`confirmed`){f(``),S(``),await O();return}n.terminal||(E.current=window.setTimeout(()=>void k(),ue))}catch(e){D.current&&v(he(e,`微信连接状态读取失败`))}},[O,o]);(0,W.useEffect)(()=>(D.current=!0,O(),k(),()=>{D.current=!1,E.current!==null&&window.clearTimeout(E.current)}),[O,k]);let ee=async()=>{if(e.enabled){T(!0),v(``),g(`wait`),S(``);try{if(i&&!await r())return;let e=await o(`${K}/start_pairing`,{method:`POST`,body:`{}`});f(e.qrcode_data_url||``),m(e.session_id||``),g(e.status||`wait`),E.current!==null&&window.clearTimeout(E.current),E.current=window.setTimeout(()=>void k(),de)}catch(e){v(he(e,`无法生成微信连接二维码`))}finally{T(!1)}}},A=async()=>{if(x.trim())try{await o(`${K}/verify_pairing`,{method:`POST`,body:JSON.stringify({session_id:p,verify_code:x.trim()})}),k()}catch(e){v(he(e,`微信连接状态读取失败`))}},j=async(e,t)=>{try{b(``),await o(`${K}/update_connection`,{method:`POST`,body:JSON.stringify({bot_instance_id:e,...t})}),await O()}catch(e){b(he(e,`微信连接更新失败`))}},te=async e=>{let t=e.display_name||e.bot_instance_id;if(confirm(u(`移除 ClawBot 连接“{{name}}”?该连接将停止收发,但微信侧授权不会被远程注销。`,{name:t})))try{b(``),await o(`${K}/remove_connection`,{method:`POST`,body:JSON.stringify({bot_instance_id:e.bot_instance_id,confirm:!0})}),await O()}catch(e){b(he(e,`微信连接移除失败`))}},M=me(h),N=!!(d||_||h===`confirmed`||M.failed);return(0,G.jsxs)(`div`,{className:`weixin-settings`,children:[(0,G.jsxs)(`section`,{className:`weixin-overview `+(e.enabled?s.length?`ready`:`warning`:`disabled`),children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(I,{size:23}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`small`,{children:u(`个人微信 ClawBot`)}),(0,G.jsx)(`b`,{children:e.enabled?s.length?u(`{{count}} 个连接已接入`,{count:s.length}):u(`等待添加首个连接`):u(`微信 Claw 已停用`)}),(0,G.jsx)(`p`,{children:u(`一个 ClawBot 实例绑定一个微信账号,并对应一个独立 participant。`)})]})]}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:!!e.enabled,onChange:e=>t(`enabled`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用微信 Claw`)})]}),(0,G.jsxs)(`button`,{className:`primary`,disabled:w||a||!e.enabled,onClick:()=>void ee(),children:[(0,G.jsx)(ie,{size:15}),u(w||a?`正在准备连接…`:e.enabled?s.length?`添加 ClawBot 连接`:`扫码连接微信`:`启用后扫码连接`)]})]}),N&&(0,G.jsxs)(`section`,{className:`weixin-pairing `+(h===`confirmed`?`confirmed`:M.failed||_?`failed`:``),children:[(0,G.jsx)(`div`,{className:`weixin-qr-stage`,children:d?(0,G.jsx)(`img`,{src:d,alt:u(`微信 Claw 连接二维码`)}):h===`confirmed`?(0,G.jsx)(C,{size:31}):(0,G.jsx)(H,{size:28})}),(0,G.jsxs)(`div`,{className:`weixin-pairing-copy`,children:[(0,G.jsx)(`span`,{children:u(`连接状态`)}),(0,G.jsx)(`h3`,{children:M.title}),(0,G.jsx)(`p`,{children:_||M.detail}),h===`need_verifycode`&&(0,G.jsxs)(`div`,{className:`weixin-verify`,children:[(0,G.jsx)(`input`,{autoFocus:!0,value:x,onChange:e=>S(e.target.value),onKeyDown:e=>{e.key===`Enter`&&A()},placeholder:u(`手机上显示的数字`)}),(0,G.jsx)(`button`,{className:`primary mini`,onClick:()=>void A(),children:u(`继续连接`)})]}),h!==`confirmed`&&(0,G.jsxs)(`button`,{className:`ghost mini`,disabled:!e.enabled||w||a,onClick:()=>void ee(),children:[(0,G.jsx)(R,{size:13}),u(d?`刷新二维码`:`重新生成二维码`)]})]})]}),y&&(0,G.jsx)(`div`,{className:`notice error`,role:`alert`,children:y}),(0,G.jsx)(`div`,{className:`weixin-account-list`,children:s.length?s.map(e=>(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`div`,{className:`weixin-account-mark`,children:(0,G.jsx)(n,{size:18})}),(0,G.jsxs)(`div`,{className:`weixin-account-copy`,children:[(0,G.jsx)(`input`,{"aria-label":u(`连接名称`),defaultValue:e.display_name||``,onBlur:t=>{t.target.value!==(e.display_name||``)&&j(e.bot_instance_id,{display_name:t.target.value})},placeholder:u(`给这个 ClawBot 连接命名`)}),(0,G.jsx)(`code`,{children:e.bot_instance_id}),(0,G.jsx)(`small`,{children:e.weixin_user_id?u(`登录微信 ID:{{id}}`,{id:e.weixin_user_id}):u(`微信账号标识未返回`)})]}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:e.enabled!==!1,onChange:t=>void j(e.bot_instance_id,{enabled:t.target.checked})}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(e.enabled===!1?`停用`:`在线`)})]}),(0,G.jsx)(`button`,{className:`danger-icon`,onClick:()=>void te(e),title:u(`移除连接`),children:(0,G.jsx)(c,{size:15})})]},e.bot_instance_id)):(0,G.jsx)(`div`,{className:`provider-empty`,children:u(`还没有 ClawBot 连接。点击“扫码连接微信”添加第一个连接。`)})})]})}var _e={weixin:{label:`微信 Claw`,component:ge}};function ve(e){return _e[e]}function ye(){return Object.fromEntries(Object.entries(_e).map(([e,t])=>[e,t.label]))}function be({serverData:e,updateServerData:t,request:n,describeDesktopSave:r}){let[i,a]=(0,W.useState)(null),[o,s]=(0,W.useState)(()=>new URLSearchParams(window.location.search).get(`group`)||`llm`),[c,l]=(0,W.useState)({}),[d,f]=(0,W.useState)(new Set),[p,m]=(0,W.useState)(null),[h,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(new Set),[y,b]=(0,W.useState)(!1);(0,W.useEffect)(()=>{e&&a(t=>t||structuredClone(e.config))},[e]);let x=(0,W.useMemo)(()=>xe(e,i,c,d),[d,i,c,e]);(0,W.useEffect)(()=>{let e=e=>{x.size>0&&e.preventDefault()};return window.addEventListener(`beforeunload`,e),()=>window.removeEventListener(`beforeunload`,e)},[x]);let S=(0,W.useCallback)((e,t)=>{v(n=>{let r=new Set(n);return t?r.delete(e):r.add(e),ke(n,r)?n:r})},[]),C=(0,W.useCallback)((e,t)=>{f(t=>Oe(t,`${o}.${e}`)),a(n=>n&&{...n,[o]:{...n[o],[e]:t}})},[o]),w=(0,W.useCallback)(e=>{s(e),v(new Set),m(null);let t=new URLSearchParams(window.location.search);t.set(`section`,`settings`),t.set(`group`,e),e!==`desktop_updates`&&t.delete(`source`),window.history.pushState(null,``,`?${t.toString()}`)},[]),T=(0,W.useCallback)((e,t,n)=>{if(!i)return;let r=[...i.llm?.managed_providers||[]];r[e]={...r[e],[t]:n},C(`managed_providers`,r)},[C,i]),E=(0,W.useCallback)(e=>{f(t=>{let n=new Set(t);return n.has(e)?n.delete(e):n.add(e),n})},[]),D=(0,W.useCallback)(()=>{e&&(a(t=>t&&{...t,[o]:structuredClone(e.config[o]||{})}),l(e=>Ee(e,o)),f(e=>De(e,o)),v(new Set),m(null))},[o,e]),O=(0,W.useCallback)(async()=>{if(!e||!i)return!1;let s=Se(o,i,h,_);if(s)return m({kind:`error`,text:s}),!1;if(!x.has(o))return!0;let p=structuredClone(e.config?.[o]||{}),g=structuredClone(i[o]||{});b(!0),m(null);try{let e=we(await n(`/api/admin/config`,{method:`PATCH`,body:JSON.stringify(Ce(o,p,g,c,d))}));l(e=>Ee(e,o)),f(e=>De(e,o)),m({kind:`success`,text:o===`desktop_updates`?r(p,g,e):e});let i=await n(`/api/admin/config`);return t(i),a(e=>e&&{...e,[o]:structuredClone(i.config[o]||{})}),!0}catch(e){return m({kind:`error`,text:e instanceof Error?e.message:u(`保存失败`)}),!1}finally{b(!1)}},[d,h,r,x,i,o,_,n,c,e,t]);return{change:C,changeProvider:T,clearOverridePaths:d,desktopValidationError:h,dirtyGroups:x,draft:i,group:o,invalidJsonPaths:_,isHot:(0,W.useCallback)(t=>(e?.hot_reloadable||[]).some(e=>t===e||t.startsWith(`${e}.`)),[e]),message:p,resetGroup:D,save:O,saving:y,secretInputs:c,selectGroup:w,setDesktopValidationError:g,setJsonValidity:S,setSecretInputs:l,toggleClearOverride:E}}function xe(e,t,n,r){let i=new Set;if(!e||!t)return i;for(let n of Object.keys(t))Object.keys(Te(e.config?.[n]||{},t[n]||{})).length>0&&i.add(n);for(let[e,t]of Object.entries(n))t&&i.add(e.split(`.`)[0]);for(let e of r)i.add(e.split(`.`)[0]);return i}function Se(e,t,n,r){if(e===`desktop_updates`&&n)return n;if(r.size>0)return u(`请先修正标出的 JSON 格式。`);let i=Number(t.llm?.max_tokens);return e===`llm`&&(!Number.isInteger(i)||i<=0)?u(`单次输出上限必须是正整数。`):``}function Ce(e,t,n,r,i){let a=Te(t,n);return{changes:Object.keys(a).length>0?{[e]:a}:{},secrets:Object.fromEntries(Object.entries(r).filter(([t,n])=>t.startsWith(`${e}.`)&&n!==``)),clear_overrides:[...i].filter(t=>t.startsWith(`${e}.`))}}function we(e){let t=e.applied_now?.length||0,n=e.requires_restart?.length||0;return t&&n?u(`已保存:{{hot}} 项立即生效,{{restart}} 项等待重启。`,{hot:t,restart:n}):t?u(`已保存,{{count}} 项修改已立即生效。`,{count:t}):n?u(`已保存,{{count}} 项修改将在安全重启后生效。`,{count:n}):u(`配置没有变化。`)}function Te(e,t){return Object.fromEntries(Object.entries(t).filter(([t,n])=>JSON.stringify(n)!==JSON.stringify(e[t])))}function Ee(e,t){return Object.fromEntries(Object.entries(e).filter(([e])=>!e.startsWith(`${t}.`)))}function De(e,t){return new Set([...e].filter(e=>!e.startsWith(`${t}.`)))}function Oe(e,t){if(!e.has(t))return e;let n=new Set(e);return n.delete(t),n}function ke(e,t){return e.size===t.size&&[...e].every(e=>t.has(e))}async function Ae({cursor:e,filtersActive:t,fetchPage:n}){let r=e,i=new Set;for(r&&i.add(r);;){let e=await n(r),a=Array.isArray(e.events)?e.events:[],o=typeof e.next_cursor==`string`?e.next_cursor:null;if(!t||a.length>0||!o)return e;if(i.has(o))return{...e,next_cursor:null,has_more:!1};i.add(o),r=o}}var je=[{id:`overview`,label:`生命总览`,description:`状态、上下文和当前驻留情况`,group:`观察`,icon:M},{id:`memory`,label:`记忆中心`,description:`短期上下文、长期召回与并行思考记录`,group:`观察`,icon:i},{id:`runtime`,label:`运行中心`,description:`任务、闹钟、运行账本与维护`,group:`观察`,icon:a},{id:`models`,label:`模型编排`,description:`主线模型、摘要与失败降级链`,group:`塑形`,icon:x},{id:`settings`,label:`运行设置`,description:`连接、记忆与循环参数`,group:`塑形`,icon:g},{id:`identity`,label:`身份档案`,description:`姓名、现居地和人格`,group:`塑形`,icon:j},{id:`content`,label:`能力内容`,description:`Skill、Palace 与潜意识模式`,group:`扩展`,icon:ee},{id:`relay`,label:`远程访问`,description:`安全连接自托管 Relay`,group:`扩展`,icon:E},{id:`releases`,label:`桌面发布`,description:`版本、签名产物与更新投放`,group:`扩展`,icon:ne},{id:`audit`,label:`诊断与审计`,description:`事件循环健康与管理员操作记录`,group:`追溯`,icon:B}],Me=[`观察`,`塑形`,`扩展`,`追溯`];function Ne(){let e=new URLSearchParams(window.location.search).get(`section`);return je.some(t=>t.id===e)?e:`overview`}function Pe(){return sessionStorage.getItem(`coworker-admin-token`)||``}var Fe=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function q(e,t={}){let n=typeof FormData<`u`&&t.body instanceof FormData,r=await fetch(e,{...t,headers:{...t.body&&!n?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${Pe()}`,...t.headers}});if(!r.ok){let e=await r.json().catch(()=>({}));throw new Fe(typeof e.detail==`string`?e.detail:JSON.stringify(e.detail||u(`请求失败 {{status}}`,{status:r.status})),r.status)}return r.status===204?{}:r.json()}async function J(e,t){let n=await fetch(e,{headers:{Authorization:`Bearer ${Pe()}`}});if(!n.ok){let e=await n.json().catch(()=>({}));throw new Fe(typeof e.detail==`string`?e.detail:u(`下载失败`),n.status)}let r=URL.createObjectURL(await n.blob()),i=document.createElement(`a`);i.href=r,i.download=t,document.body.appendChild(i),i.click(),i.remove(),window.setTimeout(()=>URL.revokeObjectURL(r),0)}function Y(e,t=[]){let[n,r]=(0,W.useState)(null),[i,a]=(0,W.useState)(``),[o,s]=(0,W.useState)(!0),c=(0,W.useRef)(0),l=(0,W.useCallback)(async()=>{let t=++c.current;s(!0),a(``);try{let n=await e();c.current===t&&r(n)}catch(e){c.current===t&&a(e instanceof Error?e.message:u(`加载失败`))}finally{c.current===t&&s(!1)}},t);return(0,W.useEffect)(()=>(l(),()=>{c.current+=1}),[l]),{data:n,error:i,loading:o,reload:l,setData:r}}function Ie({onReady:e}){let[t,n]=(0,W.useState)(``),[r,i]=(0,W.useState)(``),[a,o]=(0,W.useState)(!1);return(0,G.jsxs)(`main`,{className:`admin-login`,children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsxs)(`section`,{className:`login-card`,children:[(0,G.jsxs)(`div`,{className:`login-presence`,children:[(0,G.jsxs)(`div`,{className:`login-sigil`,children:[(0,G.jsx)(L,{size:34}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`本地控制台`)}),(0,G.jsx)(`h1`,{children:u(`进入照看室`)}),(0,G.jsx)(`p`,{className:`login-copy`,children:u(`查看生命迹象,调整她的运行方式,并谨慎触碰记忆。`)})]}),(0,G.jsxs)(`div`,{className:`login-life-trace`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{className:`login-assurance`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`i`,{}),u(`本地值守`)]}),(0,G.jsx)(`span`,{children:u(`令牌仅保留在当前会话`)})]})]}),(0,G.jsxs)(`div`,{className:`login-access`,children:[(0,G.jsx)(`p`,{className:`access-step`,children:u(`访问步骤 01`)}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`h2`,{children:u(`确认照看权限`)}),(0,G.jsx)(`p`,{children:u(`使用管理员令牌开启这次值守会话。`)})]}),(0,G.jsxs)(`form`,{onSubmit:async n=>{n.preventDefault(),o(!0),i(``),sessionStorage.setItem(`coworker-admin-token`,t);try{e(await q(`/api/admin/session/verify`,{method:`POST`}))}catch(e){sessionStorage.removeItem(`coworker-admin-token`),i(e instanceof Error?e.message:u(`验证失败`))}finally{o(!1)}},children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`管理员令牌`)}),(0,G.jsxs)(`div`,{className:`token-input`,children:[(0,G.jsx)(N,{size:17}),(0,G.jsx)(`input`,{autoFocus:!0,type:`password`,value:t,onChange:e=>n(e.target.value),placeholder:u(`输入 ADMIN__TOKEN`),autoComplete:`current-password`})]})]}),r&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:r}),(0,G.jsxs)(`button`,{className:`primary`,disabled:!t||a,children:[u(a?`正在确认…`:`进入值守台`),(0,G.jsx)(f,{size:16})]})]}),(0,G.jsxs)(`a`,{href:`/`,children:[u(`返回生命体主页`),` `,(0,G.jsx)(f,{size:14})]})]})]})]})}var Le={anthropic:`Anthropic`,openai:`OpenAI / 兼容服务`,deepseek:`DeepSeek`,qwen:`通义千问`,zhipu:`智谱 GLM`,minimax:`MiniMax`},Re={anthropic:`claude-sonnet-4-8`,openai:`gpt-5.5`,deepseek:`deepseek-v4-pro`,qwen:`qwen3.7-plus`,zhipu:`glm-5.2`,minimax:`MiniMax-M3`};function ze(e,t){let n=Re[e];return n&&t.includes(n)?n:t[0]||``}function Be({data:e,onComplete:t}){let n=e.providers||[],[r,i]=(0,W.useState)(n.some(e=>e.type===`deepseek`)?`deepseek`:n[0]?.type||`openai`),a=n.find(e=>e.type===r)?.models||[],[o,s]=(0,W.useState)(ze(r,a)),[c,l]=(0,W.useState)(``),[d,p]=(0,W.useState)(``),[m,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(e.defaults?.locale===`en`?`en`:`zh-CN`),[y,b]=(0,W.useState)(String(e.defaults?.max_tokens||8192)),[C,w]=(0,W.useState)(!!e.defaults?.passive_mode),[T,E]=(0,W.useState)(!1),[D,O]=(0,W.useState)(!1),[k,ee]=(0,W.useState)(null),[A,j]=(0,W.useState)(-1),te=(0,W.useRef)(null),M=(0,W.useRef)([]),P=(0,W.useRef)(!1),[F,I]=(0,W.useState)(!1),[ne,re]=(0,W.useState)(``),[ie,ae]=(0,W.useState)(`form`),z=o.trim(),oe=z!==``&&!a.includes(z),se=Number(y),ce=Number.isInteger(se)&&se>0,V=`bootstrap-model-listbox-${r}`,le=A>=0?`bootstrap-model-option-${r}-${A}`:void 0,U=(0,W.useMemo)(()=>{if(k===null)return a;let e=k.toLowerCase();return a.filter(t=>t.toLowerCase().includes(e))},[k,a]),K=()=>{O(!1),ee(null),j(-1)},ue=()=>{let e=a.findIndex(e=>e===z);ee(null),j(e),O(!0)},de=e=>{s(e),E(!1),K()},fe=e=>{let t=n.find(t=>t.type===e)?.models||[];i(e),s(ze(e,t)),E(!1),K()},pe=e=>{if(!U.length){j(-1);return}j(t=>t<0?e>0?0:U.length-1:(t+e+U.length)%U.length)};return(0,W.useEffect)(()=>{if(!D)return;let e=e=>{let t=e.target;t instanceof Node&&!te.current?.contains(t)&&K()};return document.addEventListener(`pointerdown`,e),()=>document.removeEventListener(`pointerdown`,e)},[D]),(0,W.useEffect)(()=>{A>=0&&M.current[A]?.scrollIntoView({block:`nearest`})},[A,U]),(0,G.jsxs)(`main`,{className:`admin-login admin-bootstrap`,children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsxs)(`section`,{className:`bootstrap-card`,children:[(0,G.jsxs)(`aside`,{className:`bootstrap-rail`,children:[(0,G.jsxs)(`div`,{className:`login-sigil`,children:[(0,G.jsx)(L,{size:32}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`初始设置`)}),(0,G.jsxs)(`h1`,{children:[u(`接通她的`),(0,G.jsx)(`br`,{}),u(`第一束信号`)]}),(0,G.jsx)(`p`,{children:u(`管理员入口已经准备好。再连接一个模型服务,Coworker 就能开始工作。`)}),(0,G.jsxs)(`ol`,{className:`awakening-circuit`,children:[(0,G.jsxs)(`li`,{className:`done`,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(N,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`访问凭证`)}),(0,G.jsx)(`small`,{children:u(`已安全生成并保存`)})]})]}),(0,G.jsxs)(`li`,{className:ie===`form`?`active`:`done`,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(x,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`模型连接`)}),(0,G.jsx)(`small`,{children:u(ie===`form`?`等待填写`:`配置已写入`)})]})]}),(0,G.jsxs)(`li`,{className:ie===`restarting`?`active`:``,children:[(0,G.jsx)(`span`,{children:(0,G.jsx)(R,{size:16})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`唤醒运行`)}),(0,G.jsx)(`small`,{children:u(ie===`restarting`?`正在安全重启`:`完成后自动进行`)})]})]})]})]}),(0,G.jsx)(`section`,{className:`bootstrap-form-stage`,children:ie===`restarting`?(0,G.jsxs)(`div`,{className:`bootstrap-restarting`,role:`status`,children:[(0,G.jsxs)(`div`,{className:`restart-orbit`,children:[(0,G.jsx)(L,{size:34}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`p`,{className:`access-step`,children:u(`设置步骤 03`)}),(0,G.jsx)(`h2`,{children:u(`正在带着新配置醒来`)}),(0,G.jsx)(`p`,{children:u(`页面会在服务恢复后自动进入照看室,不需要重复填写。`)}),ne&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:ne})]}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`div`,{className:`bootstrap-heading`,children:[(0,G.jsx)(`p`,{className:`access-step`,children:u(`设置步骤 02`)}),(0,G.jsx)(`h2`,{children:u(`配置第一个模型连接`)}),(0,G.jsxs)(`p`,{children:[u(`这些值会写入本地管理配置,不需要创建`),` `,(0,G.jsx)(`code`,{children:`.env`}),u(`。`)]})]}),(0,G.jsxs)(`form`,{className:`bootstrap-form`,onSubmit:async e=>{if(e.preventDefault(),!P.current){P.current=!0,I(!0),re(``);try{await q(`/api/admin/bootstrap`,{method:`POST`,body:JSON.stringify({provider_type:r,model:z,api_key:c,base_url:d,coworker_name:m,locale:_,max_tokens:se,passive_mode:C,allow_unverified_model:oe&&T})}),ae(`restarting`);let e=Date.now()+9e4;(async()=>{for(;Date.now()window.setTimeout(e,1500));try{if(!(await q(`/api/admin/bootstrap`)).required){t();return}}catch{}}re(u(`配置已经保存,但服务仍在重启。请稍后刷新页面。`))})()}catch(e){P.current=!1,I(!1),re(e instanceof Error?e.message:u(`初始化失败`))}}},children:[(0,G.jsxs)(`div`,{className:`bootstrap-grid`,children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`服务类型`)}),(0,G.jsx)(`select`,{value:r,onChange:e=>fe(e.target.value),children:n.map(e=>(0,G.jsx)(`option`,{value:e.type,children:u(Le[e.type]||e.type)},e.type))})]}),(0,G.jsxs)(`div`,{className:`bootstrap-model-field${D?` open`:``}`,children:[(0,G.jsx)(`label`,{id:`bootstrap-model-label`,htmlFor:`bootstrap-model-input`,children:u(`启动模型`)}),(0,G.jsxs)(`div`,{className:`bootstrap-model-combobox`,ref:te,children:[(0,G.jsx)(`input`,{id:`bootstrap-model-input`,role:`combobox`,"aria-autocomplete":`list`,"aria-haspopup":`listbox`,"aria-expanded":D,"aria-controls":V,"aria-activedescendant":le,autoComplete:`off`,spellCheck:!1,value:o,onFocus:()=>{D||ue()},onClick:()=>{D||ue()},onKeyDown:e=>{e.key===`ArrowDown`?(e.preventDefault(),D?pe(1):ue()):e.key===`ArrowUp`?(e.preventDefault(),D?pe(-1):(ue(),j(Math.max(a.length-1,-1)))):e.key===`Enter`&&D&&A>=0&&U[A]?(e.preventDefault(),de(U[A])):e.key===`Escape`&&D?(e.preventDefault(),K()):e.key===`Tab`&&K()},onChange:e=>{s(e.target.value),ee(e.target.value),O(!0),j(-1),E(!1)},placeholder:u(`选择推荐模型或输入模型 ID`)}),D&&(0,G.jsx)(`ul`,{className:`bootstrap-model-listbox`,id:V,role:`listbox`,"aria-labelledby":`bootstrap-model-label`,children:U.length?U.map((e,t)=>(0,G.jsxs)(`li`,{id:`bootstrap-model-option-${r}-${t}`,ref:e=>{M.current[t]=e},className:`${t===A?`active`:``}${e===z?` selected`:``}`,role:`option`,"aria-selected":e===z,onMouseEnter:()=>j(t),onPointerDown:e=>{e.pointerType===`mouse`&&e.preventDefault()},onClick:()=>de(e),children:[(0,G.jsx)(`span`,{children:e}),e===z&&(0,G.jsx)(S,{size:13})]},e)):(0,G.jsx)(`li`,{className:`bootstrap-model-empty`,role:`status`,children:u(`没有匹配的推荐模型;仍可直接使用当前模型 ID。`)})})]})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`运行时语言`)}),(0,G.jsxs)(`select`,{value:_,onChange:e=>v(e.target.value),children:[(0,G.jsx)(`option`,{value:`zh-CN`,children:`简体中文 (zh-CN)`}),(0,G.jsx)(`option`,{value:`en`,children:`English (en)`})]})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`单次输出 Token 上限`)}),(0,G.jsx)(`input`,{required:!0,type:`number`,min:`1`,step:`1`,value:y,onChange:e=>b(e.target.value)})]}),(0,G.jsxs)(`div`,{className:`bootstrap-mode wide`,role:`radiogroup`,"aria-label":u(`启动模式`),children:[(0,G.jsx)(`span`,{children:u(`启动模式`)}),(0,G.jsxs)(`button`,{type:`button`,className:C?``:`active`,role:`radio`,"aria-checked":!C,onClick:()=>w(!1),children:[(0,G.jsx)(`b`,{children:u(`主动模式`)}),(0,G.jsx)(`small`,{children:u(`空闲后会周期性自我唤醒并继续观察。`)})]}),(0,G.jsxs)(`button`,{type:`button`,className:C?`active`:``,role:`radio`,"aria-checked":C,onClick:()=>w(!0),children:[(0,G.jsx)(`b`,{children:u(`Passive 模式`)}),(0,G.jsx)(`small`,{children:u(`空闲后只等待外部消息、闹钟或任务事件唤醒。`)})]})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsx)(`span`,{children:`API Key`}),(0,G.jsx)(`input`,{autoFocus:!0,required:!0,type:`password`,value:c,onChange:e=>l(e.target.value),placeholder:u(`只会保存到本机配置`),autoComplete:`new-password`})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsxs)(`span`,{children:[u(`自定义 Base URL`),` `,(0,G.jsx)(`em`,{children:u(`可选`)})]}),(0,G.jsx)(`input`,{type:`url`,value:d,onChange:e=>p(e.target.value),placeholder:u(`使用官方地址时留空`)})]}),(0,G.jsxs)(`label`,{className:`wide`,children:[(0,G.jsxs)(`span`,{children:[u(`给 Coworker 起个名字`),` `,(0,G.jsx)(`em`,{children:u(`可选`)})]}),(0,G.jsx)(`input`,{value:m,onChange:e=>g(e.target.value),placeholder:u(`之后也可以在身份档案中修改`)})]})]}),oe&&(0,G.jsxs)(`div`,{className:`bootstrap-model-warning`,children:[(0,G.jsx)(H,{size:17}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`这是推荐目录外的模型`)}),(0,G.jsx)(`p`,{children:u(`Coworker 主模型必须支持 tool/function calling;初始化不会发起在线能力探测。`)}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:T,onChange:e=>E(e.target.checked)}),(0,G.jsx)(`span`,{children:u(`我确认该模型及当前 API 服务支持工具调用`)})]})]})]}),ne&&(0,G.jsx)(`p`,{className:`form-error`,role:`alert`,children:ne}),(0,G.jsxs)(`button`,{className:`primary`,disabled:F||!c.trim()||!z||!ce||oe&&!T,children:[u(F?`正在保存…`:`保存并唤醒`),` `,(0,G.jsx)(f,{size:16})]})]}),(0,G.jsxs)(`p`,{className:`bootstrap-footnote`,children:[(0,G.jsx)(B,{size:13}),u(`配置保存在`),` `,(0,G.jsx)(`code`,{children:`data/admin_config.json`}),u(`,API Key 不会回显到页面。`)]})]})})]})]})}function Ve({text:e}){let n=e.length>900,[r,i]=(0,W.useState)(!1);return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`release-notes`+(n&&!r?` preview`:``),lang:/[\u3400-\u9fff]/.test(e)?`zh-CN`:`en`,children:(0,G.jsx)(t,{allowedElements:[`p`,`h1`,`h2`,`h3`,`h4`,`strong`,`em`,`del`,`ul`,`ol`,`li`,`a`,`code`,`pre`,`blockquote`,`br`,`table`,`thead`,`tbody`,`tr`,`th`,`td`],remarkPlugins:[s],components:{a:({href:e,children:t})=>(0,G.jsx)(`a`,{href:e,target:`_blank`,rel:`noreferrer`,children:t})},children:e})}),n&&(0,G.jsx)(`button`,{type:`button`,className:`release-notes-toggle`,onClick:()=>i(e=>!e),children:u(r?`收起发布说明`:`展开完整发布说明`)})]})}function X({title:e,note:t,action:n,children:r,className:i=``}){return(0,G.jsxs)(`section`,{className:`admin-panel ${i}`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`h2`,{children:u(e)}),typeof t==`string`?(0,G.jsx)(`p`,{children:u(t)}):t]}),n]}),r]})}function Z({error:e}){return(0,G.jsxs)(`div`,{className:e?`state-box error`:`state-box`,role:e?`alert`:`status`,children:[!e&&(0,G.jsxs)(`span`,{className:`state-pulse`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(e||`正在读取生命迹象…`)})]})}function He(e){return e.is_running?e.is_sleeping?u(e.passive_mode?`等待事件`:`休息中`):u(e.passive_mode?`处理事件`:`正在运行`):u(`未运行`)}function Ue(e){return e.passive_mode?u(`仅由外部事件唤醒`):Number(e.idle_sleep_seconds)===0?u(`无间隔持续运行`):u(`每 {{seconds}} 秒自唤醒`,{seconds:e.idle_sleep_seconds})}function We({name:e}){let{data:t,error:n,loading:r,reload:a}=Y(()=>q(`/api/admin/overview`),[]);if(r||!t)return(0,G.jsx)(Z,{error:n});let o=t.status,s=t.counts,c=o.is_running,l=c&&!!o.is_sleeping,d=c?l?`resting`:`running`:`quiet`,f=He(o),p=Ue(o),m=new Date().toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`});return(0,G.jsxs)(`div`,{className:`page-stack`,children:[(0,G.jsxs)(`section`,{className:`presence-hero ${d}`,children:[(0,G.jsxs)(`div`,{className:`presence-copy`,children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`状态采样`)}),(0,G.jsxs)(`h1`,{children:[e||`Coworker`,(0,G.jsx)(`span`,{className:`live-badge ${d}`,children:f})]}),(0,G.jsxs)(`div`,{className:`presence-readout`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`主线模型`)}),(0,G.jsxs)(`strong`,{children:[o.provider,`/`,o.model]})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`生命循环`)}),(0,G.jsx)(`strong`,{children:u(`第 {{count}} 次`,{count:o.cycle_count||0})})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`唤醒方式`)}),(0,G.jsx)(`strong`,{children:p})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`本次采样`)}),(0,G.jsx)(`strong`,{children:m})]})]})]}),(0,G.jsxs)(`div`,{className:`pulse-organ`,"aria-label":f,children:[(0,G.jsx)(`span`,{className:`organ-line`,"aria-hidden":`true`}),[22,42,66,92,56,38,74,48,26].map((e,t)=>(0,G.jsx)(`i`,{style:{"--h":`${e}%`,"--d":`${t*.08}s`}},t)),(0,G.jsxs)(`small`,{children:[u(`生命信号`),` · `,f]})]}),(0,G.jsx)(`button`,{className:`icon-btn`,onClick:()=>void a(),title:u(`刷新生命迹象`),"aria-label":u(`刷新生命迹象`),children:(0,G.jsx)(R,{size:16})})]}),t.pending_restart&&(0,G.jsxs)(`div`,{className:`notice amber`,children:[(0,G.jsx)(H,{size:17}),(0,G.jsx)(`span`,{children:u(`有配置等待重启后生效。`)})]}),(0,G.jsx)(`div`,{className:`vital-grid`,children:[[u(`活跃任务`),s.active_tasks,u(`{{count}} 项总计`,{count:s.tasks}),P],[u(`运行 Bubble`),s.active_bubbles,u(`并行思考分支`),L],[u(`长期记忆`),s.long_term_memories,u(`可语义检索`),i],[u(`短期上下文`),s.short_term_messages,u(`{{count}} 个树节点`,{count:t.memory.tree_nodes}),I],[u(`待触发闹钟`),s.alarms,u(`后台守候中`),y]].map(([e,t,n,r])=>(0,G.jsxs)(`article`,{className:`vital`,children:[(0,G.jsx)(r,{size:18}),(0,G.jsx)(`span`,{children:u(e)}),(0,G.jsx)(`strong`,{children:Number(t).toLocaleString()}),(0,G.jsx)(`small`,{children:u(n)})]},e))}),(0,G.jsxs)(`div`,{className:`two-col`,children:[(0,G.jsx)(X,{title:`上下文水位`,note:`短期消息与记忆树的当前结构`,children:(0,G.jsxs)(`div`,{className:`memory-meter`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`消息`)}),(0,G.jsx)(`b`,{children:t.memory.messages})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`容量`)}),(0,G.jsxs)(`b`,{children:[Number(t.memory.max_tokens).toLocaleString(),` token`]})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`回溯`)}),(0,G.jsx)(`b`,{children:t.memory.backfill?.running?`${t.memory.backfill.done}/${t.memory.backfill.total}`:u(`空闲`)})]})]})}),(0,G.jsx)(X,{title:`进程驻留`,note:`当前实例的连续运行时间`,children:(0,G.jsxs)(`div`,{className:`runtime-clock`,children:[(0,G.jsx)(w,{size:34}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:new Date(o.started_at).toLocaleString()}),(0,G.jsx)(`span`,{children:u(`本轮启动时间`)})]})]})})]})]})}function Ge(){let{data:e,error:t,loading:n,reload:r,setData:i}=Y(()=>q(`/api/admin/model`),[]),[a,s]=(0,W.useState)({provider:``,model_id:``}),[c,l]=(0,W.useState)(``),[d,f]=(0,W.useState)(!1),[p,m]=(0,W.useState)(null);(0,W.useEffect)(()=>{e&&(m(JSON.parse(JSON.stringify(e))),s({provider:e.active.provider||``,model_id:e.active.model||``}))},[e]);let h=async()=>{if(!p)return;let e=await q(`/api/admin/model`,{method:`PATCH`,body:JSON.stringify({summary:p.summary,fallbacks:p.fallbacks,vision:p.vision})});i(e),m(e)},g=async()=>{l(``),f(!0);try{let e=await q(`/api/admin/model/switch`,{method:`POST`,body:JSON.stringify(a)});i(e),m(e),s({provider:``,model_id:``})}catch(e){l(e instanceof Error?e.message:u(`切换模型失败`))}finally{f(!1)}};if(n||!p)return(0,G.jsx)(Z,{error:t});let _=(e,t)=>m(n=>{let r=structuredClone(n),[i,a]=e.split(`.`);return r[i][a]=t,r});return(0,G.jsxs)(`div`,{className:`page-stack`,children:[(0,G.jsxs)(X,{title:`主线模型`,note:`切换立即生效,正在执行的单次调用不会被中断。`,children:[(0,G.jsxs)(`div`,{className:`active-model`,children:[(0,G.jsx)(o,{size:28}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`当前接棒者`)}),(0,G.jsxs)(`strong`,{children:[p.active.provider,`/`,p.active.model]})]})]}),(0,G.jsxs)(`div`,{className:`inline-form`,children:[(0,G.jsxs)(`select`,{value:a.provider,onChange:e=>s({...a,provider:e.target.value}),children:[(0,G.jsx)(`option`,{value:``,children:u(`选择 Provider`)}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]}),(0,G.jsx)(`input`,{value:a.model_id,onChange:e=>s({...a,model_id:e.target.value}),placeholder:u(`模型 ID(留空使用默认)`)}),(0,G.jsx)(`button`,{className:`primary`,disabled:!a.provider||d,onClick:()=>void g(),children:u(d?`正在切换…`:`切换模型`)})]}),c&&(0,G.jsxs)(`div`,{className:`notice error`,role:`alert`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:c})]})]}),(0,G.jsxs)(`div`,{className:`two-col`,children:[(0,G.jsx)(X,{title:`摘要与压缩`,note:`控制上下文压缩时使用的模型。`,children:(0,G.jsxs)(`div`,{className:`field-grid`,children:[(0,G.jsx)(Q,{label:`Provider`,hint:`留空时跟随主线模型`,children:(0,G.jsxs)(`select`,{value:p.summary.provider,onChange:e=>_(`summary.provider`,e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`跟随主线({{provider}})`,{provider:p.active.provider})}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]})}),(0,G.jsx)(Q,{label:`模型`,hint:`留空时跟随主线模型`,children:(0,G.jsx)(`input`,{value:p.summary.model,onChange:e=>_(`summary.model`,e.target.value),placeholder:p.active.model})}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:p.summary.thinking,onChange:e=>_(`summary.thinking`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用 Thinking`)})]})]})}),(0,G.jsx)(X,{title:`视觉理解`,note:`为纯文本主模型提供图片分析能力。`,children:(0,G.jsxs)(`div`,{className:`field-grid`,children:[(0,G.jsx)(Q,{label:`Provider`,children:(0,G.jsxs)(`select`,{value:p.vision.provider,onChange:e=>_(`vision.provider`,e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`关闭`)}),p.providers.map(e=>(0,G.jsx)(`option`,{children:e},e))]})}),(0,G.jsx)(Q,{label:`模型`,children:(0,G.jsx)(`input`,{value:p.vision.model,onChange:e=>_(`vision.model`,e.target.value)})}),(0,G.jsxs)(`label`,{className:`switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:p.vision.thinking,onChange:e=>_(`vision.thinking`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`启用 Thinking`)})]})]})})]}),(0,G.jsxs)(X,{title:`失败降级链`,note:`每行填写 provider 或 provider/model,按从上到下的顺序接棒。`,children:[(0,G.jsx)(`textarea`,{className:`code-area short`,value:(p.fallbacks||[]).join(` `),onChange:e=>m({...p,fallbacks:e.target.value.split(` `).map(e=>e.trim()).filter(Boolean)})}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsxs)(`button`,{className:`primary`,onClick:()=>void h(),children:[(0,G.jsx)(oe,{size:15}),u(`保存并热更新`)]}),(0,G.jsx)(`button`,{className:`ghost`,onClick:()=>void r(),children:u(`放弃修改`)})]})]})]})}function Q({label:e,children:t,hint:n,hot:r=!1}){return(0,G.jsxs)(`label`,{className:`field`,children:[(0,G.jsxs)(`span`,{children:[u(e),r&&(0,G.jsx)(`em`,{className:`effect-badge hot`,children:u(`立即生效`)})]}),t,n&&(0,G.jsx)(`small`,{children:u(n)})]})}function Ke({label:e,hint:t,value:n,onChange:r,placeholder:i}){let[a,o]=(0,W.useState)(``),s=()=>{let e=a.trim();!e||n.includes(e)||(r([...n,e]),o(``))};return(0,G.jsxs)(`div`,{className:`field string-list-field`,children:[(0,G.jsx)(`span`,{children:u(e)}),(0,G.jsxs)(`div`,{className:`string-list-editor`,children:[n.length>0?(0,G.jsx)(`div`,{className:`string-list-items`,children:n.map((e,t)=>(0,G.jsxs)(`div`,{className:`string-list-item`,children:[(0,G.jsx)(`code`,{children:e}),(0,G.jsx)(`button`,{type:`button`,onClick:()=>r(n.filter((e,n)=>n!==t)),title:u(`删除匹配规则`),"aria-label":u(`删除匹配规则 {{item}}`,{item:e}),children:(0,G.jsx)(m,{size:13})})]},`${e}:${t}`))}):(0,G.jsx)(`div`,{className:`string-list-empty`,children:u(`当前不按 participant 匹配`)}),(0,G.jsxs)(`div`,{className:`string-list-add`,children:[(0,G.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),s())},placeholder:i}),(0,G.jsxs)(`button`,{type:`button`,className:`ghost mini`,disabled:!a.trim()||n.includes(a.trim()),onClick:s,children:[(0,G.jsx)(re,{size:14}),u(`添加规则`)]})]})]}),(0,G.jsx)(`small`,{children:u(t)})]})}function qe({value:e,onChange:t}){let n=[{value:`websocket`,label:`WebSocket`,hint:`桌面与网页聊天的实时连接`},{value:`sse`,label:`SSE`,hint:`基于事件流的实时连接`}],r=(n,r)=>{t(r?[...e,n]:e.filter(e=>e!==n))};return(0,G.jsxs)(`div`,{className:`field transport-list-field`,children:[(0,G.jsx)(`span`,{children:u(`透明接管实时信道`)}),(0,G.jsx)(`div`,{className:`transport-list-editor`,children:n.map(t=>(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:e.includes(t.value),onChange:e=>r(t.value,e.target.checked)}),(0,G.jsx)(`i`,{children:(0,G.jsx)(S,{size:12})}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:t.label}),(0,G.jsx)(`small`,{children:u(t.hint)})]})]},t.value))}),(0,G.jsx)(`small`,{children:u(`勾选后,这些实时信道会显示泡泡接手和归还的结构化状态。修改后需要安全重启。`)})]})}function Je({value:e,onChange:t,onValidityChange:n}){let r=e=>JSON.stringify(e,null,2)??``,[i,a]=(0,W.useState)(()=>r(e)),[o,s]=(0,W.useState)(!0),c=(0,W.useRef)(r(e)),l=(0,W.useRef)(!1),d=(0,W.useRef)(n);d.current=n,(0,W.useEffect)(()=>{let t=r(e);(l.current||t!==c.current)&&(a(t),s(!0),l.current=!1,d.current(!0)),c.current=t},[e]);let f=e=>{a(e);try{let i=JSON.parse(e),a=r(i);s(!0),l.current=!1,c.current=a,n(!0),t(i)}catch{s(!1),l.current=!0,n(!1)}};return(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`textarea`,{className:`code-area compact${o?``:` invalid`}`,value:i,onChange:e=>f(e.target.value),"aria-invalid":!o}),!o&&(0,G.jsx)(`small`,{className:`field-error`,role:`alert`,children:u(`JSON 格式无效;修正后才能保存这个字段。`)})]})}var Ye={llm:`模型与 Provider`,i18n:`运行时语言`,memory:`记忆系统`,agent:`Agent 循环`,api:`API 服务`,wecom:`企业微信`,desktop_updates:`桌面更新`,admin:`管理端`,...ye()},Xe=new Set([`admin.token`,`desktop_updates.admin_token`]),Ze=new Set([`summary_provider`,`summary_model`,`summary_thinking`,`fallbacks`,`vision_provider`,`vision_model`,`vision_thinking`]),Qe=[{value:`minutes`,label:`分钟`,seconds:60},{value:`hours`,label:`小时`,seconds:3600},{value:`days`,label:`天`,seconds:86400}],$e=[{value:`MiB`,bytes:1024*1024},{value:`GiB`,bytes:1024*1024*1024}];function et(){if(typeof crypto<`u`&&typeof crypto.randomUUID==`function`)return crypto.randomUUID();let e=new Uint8Array(16);if(typeof crypto<`u`&&typeof crypto.getRandomValues==`function`)crypto.getRandomValues(e);else for(let t=0;te.toString(16).padStart(2,`0`)).join(``);return`${t.slice(0,8)}-${t.slice(8,12)}-${t.slice(12,16)}-${t.slice(16,20)}-${t.slice(20)}`}function tt(e=`github`){let t=et();return e===`github`?{id:t,name:u(`GitHub 上游`),type:e,api_base_url:`https://api.github.com`,repository:``,token:``,include_drafts:!1,include_prereleases:!1}:{id:t,name:u(`Coworker 上游`),type:e,base_url:``,token:``,include_prereleases:!1}}function nt(e){return`desktop_updates.sync_sources.${e}.token`}function rt(e){return e?.type===`coworker`?`Coworker Feed`:`GitHub Releases`}function it(e){return e?.type===`coworker`?e.base_url||``:e?.repository||``}function at(e){return e?.type===`coworker`?e.base_url||``:e?.api_base_url||``}function ot(e){return!!(e&&e.name?.trim()&&at(e).trim()&&(e.type===`coworker`||e.repository?.trim()))}function st(e){return e>=86400&&e%86400==0?{value:e/86400,unit:`days`}:e>=3600&&e%3600==0?{value:e/3600,unit:`hours`}:{value:Math.max(1,Math.round(e/60)),unit:`minutes`}}function ct(e){let t=1024*1024*1024;return e>=t&&e%t==0?{value:e/t,unit:`GiB`}:{value:Math.max(1,Math.round(e/(1024*1024))),unit:`MiB`}}function lt(e,t){return Math.max(0,Math.round(e*(Qe.find(e=>e.value===t)?.seconds||60)))}function ut(e,t){return Math.max(0,Math.round(e*($e.find(e=>e.value===t)?.bytes||1024*1024)))}function dt(e={},t={},n){let r=e.sync_active_source||``,i=t.sync_active_source||``,a=Array.isArray(e.sync_sources)?e.sync_sources:[],o=Array.isArray(t.sync_sources)?t.sync_sources:[],s=a.find(e=>e.id===r),c=o.find(e=>e.id===i);return!i&&r?u(`同步已关闭;已保存的上游来源会继续保留。`):i&&r!==i?u(`当前上游已切换为 {{name}},保存后立即生效。`,{name:c?.name||rt(c)}):i&&JSON.stringify(s||{})!==JSON.stringify(c||{})?u(`当前上游配置已更新,保存后立即生效。`):JSON.stringify(a)===JSON.stringify(o)?e.sync_interval_seconds!==t.sync_interval_seconds||e.sync_max_asset_bytes!==t.sync_max_asset_bytes||e.sync_max_run_bytes!==t.sync_max_run_bytes?u(`同步周期和下载限制已更新,保存后立即生效。`):e.sync_on_start!==t.sync_on_start||e.feed_token!==t.feed_token?u(`桌面更新全局设置已保存。`):n:u(`备用上游来源已更新,不会影响当前同步任务。`)}function ft({value:e,change:t,secretInputs:n,setSecretInputs:r,secretStatus:i,onValidationChange:a}){let o=Array.isArray(e.sync_sources)?e.sync_sources:[],s=e.sync_active_source||``,c=new URLSearchParams(window.location.search).get(`source`)||``,[l,d]=(0,W.useState)(()=>c||s||o[0]?.id||``),f=o.find(e=>e.id===l)||null,p=o.find(e=>e.id===s)||null,m=(0,W.useRef)(null),h=st(Number(e.sync_interval_seconds||21600)),g=ct(Number(e.sync_max_asset_bytes||2*1024*1024*1024)),_=ct(Number(e.sync_max_run_bytes||4*1024*1024*1024)),[v,y]=(0,W.useState)(h.unit),[b,x]=(0,W.useState)(g.unit),[S,C]=(0,W.useState)(_.unit),w=(e,n=s)=>{t(`sync_sources`,e),t(`sync_active_source`,n||null)},T=(e,t)=>w(o.map(n=>n.id===e?{...n,...t}:n)),E=(e,t=!1)=>{let n=new URLSearchParams(window.location.search);n.set(`section`,`settings`),n.set(`group`,`desktop_updates`),e?n.set(`source`,e):n.delete(`source`),window.history[t?`replaceState`:`pushState`](null,``,`?${n.toString()}`)},D=(e,t=!1)=>{d(e),E(e,t)},O=e=>{let t=tt(e);w([...o,t]),D(t.id)},k=e=>{let t={...e,id:et(),name:u(`{{name}} 副本`,{name:e.name||rt(e)}),token:``};w([...o,t]),r(Object.fromEntries(Object.entries(n).filter(([e])=>e!==nt(t.id)))),D(t.id)},ee=e=>{let t=o.find(t=>t.id===e);if(!t)return;let i=s===e;if(!confirm(u(i?`删除当前上游“{{name}}”?同步会切换为关闭,但不会影响已导入的草稿。`:`删除上游来源“{{name}}”?`,{name:t.name||rt(t)})))return;let a=o.findIndex(t=>t.id===e),c=o.filter(t=>t.id!==e);w(c,i?``:s),r(Object.fromEntries(Object.entries(n).filter(([t])=>t!==nt(e)))),l===e&&D(c[Math.min(a,c.length-1)]?.id||``,!0)},A=(e,t)=>{let n=[...o],r=e+t;r<0||r>=n.length||([n[e],n[r]]=[n[r],n[e]],w(n))},j=e=>t(`sync_active_source`,e||null),te=(e,n=v)=>t(`sync_interval_seconds`,lt(e,n)),M=(e,n,r)=>t(e,ut(n,r)),N=Number(((e.sync_interval_seconds||21600)/(Qe.find(e=>e.value===v)?.seconds||60)).toFixed(2)),P=Number(((e.sync_max_asset_bytes||0)/($e.find(e=>e.value===b)?.bytes||1)).toFixed(2)),F=Number(((e.sync_max_run_bytes||0)/($e.find(e=>e.value===S)?.bytes||1)).toFixed(2)),I=(0,W.useMemo)(()=>{let t=o.map(e=>(e.name||``).trim().toLowerCase()).filter(Boolean);return t.length===new Set(t).size?Number(e.sync_interval_seconds||0)<300?u(`检测间隔至少为 5 分钟。`):Number(e.sync_max_asset_bytes||0)<1024||Number(e.sync_max_run_bytes||0)<1024?u(`下载大小上限必须大于 1 KiB。`):Number(e.sync_max_run_bytes||0){a(I)},[a,I]),(0,W.useEffect)(()=>{let e=new URLSearchParams(window.location.search).get(`source`)||``;if(e&&o.some(t=>t.id===e)){d(e);return}l&&!o.some(e=>e.id===l)&&d(s||o[0]?.id||``)},[s,l,o]);let L=i[`desktop_updates.feed_token`],ne=ot(p||void 0);return(0,G.jsxs)(`div`,{className:`desktop-update-settings`,children:[(0,G.jsxs)(`section`,{className:`desktop-sync-overview `+(s?ne?`ready`:`warning`:`disabled`),children:[(0,G.jsxs)(`div`,{className:`desktop-sync-overview-copy`,children:[(0,G.jsx)(R,{size:22}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`上游同步`)}),(0,G.jsx)(`h3`,{children:u(s?ne?`当前上游已就绪`:`当前上游未配置完整`:`上游同步已关闭`)}),(0,G.jsx)(`p`,{children:p?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`b`,{children:p.name||rt(p)}),` · `,rt(p),it(p)?` · ${it(p)}`:``]}):u(`选择一个来源作为当前上游后,定时同步和立即同步才会运行。`)})]})]}),(0,G.jsx)(Q,{label:`当前上游`,hint:`切换后保存即可立即应用,不需要重启`,children:(0,G.jsxs)(`select`,{value:s||``,onChange:e=>j(e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`关闭上游同步`)}),o.map(e=>(0,G.jsx)(`option`,{value:e.id,children:e.name||rt(e)},e.id))]})})]}),I&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:I})]}),(0,G.jsxs)(`div`,{className:`desktop-source-toolbar`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`上游来源`)}),(0,G.jsx)(`small`,{children:u(`可以保存多个同类型来源,但同一时间只有当前上游会运行。`)})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{className:`ghost mini`,onClick:()=>O(`github`),children:[(0,G.jsx)(re,{size:14}),u(`添加 GitHub 来源`)]}),(0,G.jsxs)(`button`,{className:`ghost mini`,onClick:()=>O(`coworker`),children:[(0,G.jsx)(re,{size:14}),u(`添加 Coworker 来源`)]})]})]}),o.length?(0,G.jsxs)(`div`,{className:`desktop-source-workbench`,children:[(0,G.jsx)(`div`,{className:`desktop-source-list`,role:`list`,children:o.map((e,t)=>{let n=ot(e),r=s===e.id,i=l===e.id;return(0,G.jsxs)(`article`,{role:`listitem`,className:`desktop-source-item `+(r?`active `:``)+(i?`selected `:``)+(n?``:`warning`),children:[(0,G.jsxs)(`button`,{type:`button`,onClick:()=>D(e.id),children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:e.name||u(`未命名来源`)}),(0,G.jsxs)(`small`,{children:[rt(e),it(e)?` · ${it(e)}`:``]})]}),(0,G.jsx)(`em`,{className:`desktop-source-badge `+(r?`active`:n?``:`warning`),children:u(r?`当前`:n?`备用`:`待补全`)})]}),(0,G.jsxs)(`div`,{className:`desktop-source-row-actions`,children:[(0,G.jsx)(`button`,{className:`ghost mini`,disabled:r,onClick:()=>j(e.id),children:u(`设为当前`)}),(0,G.jsx)(`button`,{className:`ghost mini`,disabled:t===0,onClick:()=>A(t,-1),children:u(`上移`)}),(0,G.jsx)(`button`,{className:`ghost mini`,disabled:t===o.length-1,onClick:()=>A(t,1),children:u(`下移`)}),(0,G.jsx)(`button`,{className:`ghost mini`,onClick:()=>k(e),children:u(`复制`)}),(0,G.jsx)(`button`,{className:`danger-outline mini`,onClick:()=>ee(e.id),children:u(`删除`)})]})]},e.id)})}),(0,G.jsx)(`section`,{className:`desktop-source-detail`,ref:m,children:f?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`来源详情`)}),(0,G.jsx)(`h3`,{children:f.name||u(`未命名来源`)}),(0,G.jsx)(`p`,{children:f.id})]}),(0,G.jsx)(`em`,{className:`desktop-source-badge `+(s===f.id?`active`:``),children:s===f.id?u(`当前上游`):u(`备用上游`)})]}),(0,G.jsxs)(`div`,{className:`desktop-source-form`,children:[(0,G.jsxs)(`div`,{className:`desktop-form-row two`,children:[(0,G.jsx)(Q,{label:`来源名称`,children:(0,G.jsx)(`input`,{value:f.name||``,onChange:e=>T(f.id,{name:e.target.value})})}),(0,G.jsx)(Q,{label:`Provider 类型`,hint:`切换类型会清空此来源的地址字段,并需要重新确认访问 Token`,children:(0,G.jsxs)(`select`,{value:f.type,onChange:e=>{let t=e.target.value;t!==f.type&&!confirm(u(`切换来源类型会清空当前地址字段和未保存 Token,继续吗?`))||(T(f.id,t===`github`?{type:t,api_base_url:`https://api.github.com`,repository:``,base_url:void 0,token:``}:{type:t,base_url:``,api_base_url:void 0,repository:void 0,include_drafts:void 0,token:``}),r({...n,[nt(f.id)]:``}))},children:[(0,G.jsx)(`option`,{value:`github`,children:`GitHub Releases`}),(0,G.jsx)(`option`,{value:`coworker`,children:`Coworker Feed`})]})})]}),(0,G.jsxs)(`div`,{className:`desktop-form-row two`,children:[f.type===`github`?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(Q,{label:`GitHub API Base URL`,children:(0,G.jsx)(`input`,{value:f.api_base_url||``,onChange:e=>T(f.id,{api_base_url:e.target.value}),placeholder:`https://api.github.com`})}),(0,G.jsx)(Q,{label:`上游仓库(owner/repo)`,children:(0,G.jsx)(`input`,{value:f.repository||``,onChange:e=>T(f.id,{repository:e.target.value}),placeholder:`owner/repo`})})]}):(0,G.jsx)(Q,{label:`Coworker Base URL`,hint:`会读取该实例的 published release feed`,children:(0,G.jsx)(`input`,{value:f.base_url||``,onChange:e=>T(f.id,{base_url:e.target.value}),placeholder:`https://coworker.example.com`})}),(0,G.jsx)(Q,{label:`访问 Token`,hint:i[nt(f.id)]?.configured?u(`当前已配置 · 尾号 {{last4}}`,{last4:i[nt(f.id)]?.last4||``}):u(`当前未配置`),children:(0,G.jsx)(`input`,{type:`password`,value:n[nt(f.id)]||``,onChange:e=>r({...n,[nt(f.id)]:e.target.value}),placeholder:i[nt(f.id)]?.configured?u(`留空保留,输入新值则替换`):u(`可选`)})})]}),(0,G.jsxs)(`div`,{className:`desktop-option-grid`,children:[f.type===`github`&&(0,G.jsxs)(`label`,{className:`desktop-option-card`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:!!f.include_drafts,onChange:e=>T(f.id,{include_drafts:e.target.checked})}),(0,G.jsx)(`span`,{children:u(`同步 GitHub 草稿`)}),(0,G.jsx)(`small`,{children:u(`仅在需要接收上游 draft 时开启。`)})]}),(0,G.jsxs)(`label`,{className:`desktop-option-card`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:!!f.include_prereleases,onChange:e=>T(f.id,{include_prereleases:e.target.checked})}),(0,G.jsx)(`span`,{children:u(`同步预发布版本`)}),(0,G.jsx)(`small`,{children:u(`允许 SemVer 预发布版本进入本地草稿。`)})]})]})]})]}):(0,G.jsx)(`div`,{className:`provider-empty`,children:u(`选择一个上游来源查看详情。`)})})]}):(0,G.jsx)(`div`,{className:`provider-empty desktop-source-empty`,children:u(`还没有上游来源。添加 GitHub 或 Coworker 来源后,在上方选择一个作为当前上游。`)}),(0,G.jsxs)(`section`,{className:`desktop-global-settings`,children:[(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`全局同步设置`)}),(0,G.jsx)(`small`,{children:u(`这些限制和周期作用于当前活跃来源。`)})]})}),(0,G.jsxs)(`div`,{className:`config-fields`,children:[(0,G.jsx)(Q,{label:`检测间隔`,children:(0,G.jsxs)(`div`,{className:`unit-field`,children:[(0,G.jsx)(`input`,{type:`number`,min:`1`,value:N,onChange:e=>te(Number(e.target.value))}),(0,G.jsx)(`select`,{value:v,onChange:e=>{let t=e.target.value;y(t),te(N,t)},children:Qe.map(e=>(0,G.jsx)(`option`,{value:e.value,children:u(e.label)},e.value))})]})}),(0,G.jsxs)(`label`,{className:`switch config-switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:e.sync_on_start!==!1,onChange:e=>t(`sync_on_start`,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`span`,{children:u(`服务启动时立即检测`)})]}),(0,G.jsxs)(Q,{label:`单个制品大小上限`,children:[(0,G.jsxs)(`div`,{className:`unit-field`,children:[(0,G.jsx)(`input`,{type:`number`,min:`1`,value:P,onChange:e=>M(`sync_max_asset_bytes`,Number(e.target.value),b)}),(0,G.jsx)(`select`,{value:b,onChange:e=>{let t=e.target.value;x(t),M(`sync_max_asset_bytes`,P,t)},children:$e.map(e=>(0,G.jsx)(`option`,{value:e.value,children:e.value},e.value))})]}),(0,G.jsx)(`small`,{children:Zt(e.sync_max_asset_bytes||0)})]}),(0,G.jsxs)(Q,{label:`单次同步总量上限`,children:[(0,G.jsxs)(`div`,{className:`unit-field`,children:[(0,G.jsx)(`input`,{type:`number`,min:`1`,value:F,onChange:e=>M(`sync_max_run_bytes`,Number(e.target.value),S)}),(0,G.jsx)(`select`,{value:S,onChange:e=>{let t=e.target.value;C(t),M(`sync_max_run_bytes`,F,t)},children:$e.map(e=>(0,G.jsx)(`option`,{value:e.value,children:e.value},e.value))})]}),(0,G.jsx)(`small`,{children:Zt(e.sync_max_run_bytes||0)})]}),(0,G.jsx)(Q,{label:`下游同步 Feed Token`,hint:L?.configured?u(`当前已配置 · 尾号 {{last4}}`,{last4:L.last4||``}):u(`当前 feed 已关闭`),children:(0,G.jsx)(`input`,{type:`password`,value:n[`desktop_updates.feed_token`]||``,onChange:e=>r({...n,"desktop_updates.feed_token":e.target.value}),placeholder:L?.configured?u(`留空保留,输入新值则替换`):u(`设置后允许其他 Coworker 实例同步本实例已发布版本`)})})]})]})]})}var pt={"llm.default_provider":`启动时使用的 Provider`,"llm.default_model":`启动时使用的模型`,"llm.max_tokens":`单次输出上限`,"i18n.locale":`模型与运行时语言`,"agent.passive_mode":`Passive 模式`,"agent.idle_sleep_seconds":`主动模式自唤醒间隔(秒)`,"agent.bubble_handoff_transparency_participant_matches":`透明接管对象`,"agent.bubble_handoff_transparency_stream_transports":`透明接管实时信道`,"api.communication_token":`桌面通信令牌`,"desktop_updates.dir":`本地发布目录`,"desktop_updates.sync_sources":`上游来源`,"desktop_updates.sync_active_source":`当前上游`,"desktop_updates.sync_interval_seconds":`检测间隔(秒)`,"desktop_updates.sync_on_start":`服务启动时立即检测`,"desktop_updates.sync_max_asset_bytes":`单个制品大小上限(字节)`,"desktop_updates.sync_max_run_bytes":`单次同步总量上限(字节)`};function mt(){let{data:e,error:t,setData:n}=Y(()=>q(`/api/admin/config`),[]),{change:r,changeProvider:a,clearOverridePaths:o,desktopValidationError:s,dirtyGroups:l,draft:d,group:p,invalidJsonPaths:m,isHot:h,message:g,resetGroup:_,save:v,saving:y,secretInputs:b,selectGroup:S,setDesktopValidationError:C,setJsonValidity:w,setSecretInputs:T,toggleClearOverride:E}=be({serverData:e,updateServerData:n,request:q,describeDesktopSave:dt});if(!e||!d)return(0,G.jsx)(Z,{error:t});let D=e.effective_providers||[],O=D.filter(e=>!e.managed),k=Object.keys(d).filter(e=>Ye[e]),A=e.secret_status[`admin.token`],te=e.secret_status[`desktop_updates.admin_token`],M=A?.configured?A:te,P=u(`有效配置来自 {{env}}{{providers}},并由 {{override}} 覆盖。`,{env:`.env`,providers:e.sources?.providers?u(`、{{source}}`,{source:e.sources.providers}):``,override:e.override_path}),F=(e.overridden_fields||[]).filter(e=>{let t=e.split(`.`)[1]||``;return e.startsWith(`${p}.`)&&!Xe.has(e)&&t!==`config_file`&&!t.endsWith(`runtime_config_file`)&&!(p===`llm`&&/_(api_key|base_url)$/.test(t))}),I=ve(p)?.component;return(0,G.jsxs)(`div`,{className:`settings-layout`,children:[(0,G.jsx)(`nav`,{className:`subnav`,children:k.map(e=>(0,G.jsxs)(`button`,{className:p===e?`active`:``,disabled:y,onClick:()=>S(e),children:[(0,G.jsxs)(`span`,{children:[u(Ye[e]),l.has(e)&&(0,G.jsx)(`i`,{className:`settings-dirty-dot`,title:u(`有未保存修改`)})]}),(0,G.jsx)(f,{size:14})]},e))}),(0,G.jsxs)(X,{title:Ye[p],note:P,className:`config-panel`,children:[e.pending_restart&&(0,G.jsxs)(`div`,{className:`notice amber`,children:[(0,G.jsx)(H,{size:16}),u(`存在等待重启的修改`)]}),p!==`admin`&&F.length>0&&(0,G.jsxs)(`div`,{className:`config-override-strip`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(i,{size:16}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(`管理端覆盖`)}),(0,G.jsx)(`small`,{children:u(`这些字段不会跟随启动环境变化;可以恢复为继承配置。`)})]})]}),(0,G.jsx)(`div`,{children:F.map(e=>{let t=o.has(e);return(0,G.jsxs)(`button`,{className:t?`pending`:``,onClick:()=>E(e),children:[(0,G.jsx)(`span`,{children:u(pt[e]||ht(e.split(`.`)[1]||e))}),(0,G.jsx)(z,{size:12}),u(t?`保存后恢复继承`:`恢复继承`)]},e)})})]}),p===`admin`?(0,G.jsxs)(`div`,{className:`admin-settings-status`,children:[(0,G.jsxs)(`section`,{className:`admin-security-hero ${M?.configured?`ready`:`missing`}`,children:[(0,G.jsxs)(`div`,{className:`security-seal`,children:[(0,G.jsx)(B,{size:27}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`保护状态`)}),(0,G.jsx)(`h3`,{children:u(M?.configured?`管理端访问已受保护`:`管理端令牌尚未配置`)}),(0,G.jsx)(`p`,{children:M?.configured?u(`当前令牌已加载,仅显示尾号 {{last4}}。完整值不会发送到浏览器。`,{last4:M.last4}):u(`请在启动环境中设置 ADMIN__TOKEN,然后重启 Coworker。`)})]}),(0,G.jsx)(`b`,{children:u(M?.configured?`已启用`:`未启用`)})]}),(0,G.jsxs)(`div`,{className:`admin-setting-cards`,children:[(0,G.jsxs)(`article`,{children:[(0,G.jsx)(N,{size:18}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`令牌来源`)}),(0,G.jsx)(`b`,{children:A?.configured?`ADMIN__TOKEN`:te?.configured?`DESKTOP_UPDATES__ADMIN_TOKEN`:u(`未配置`)}),(0,G.jsx)(`small`,{children:u(`令牌只能通过启动配置轮换,管理页不会回显或覆盖。`)})]})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(ee,{size:18}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`配置覆盖文件`)}),(0,G.jsx)(`code`,{children:e.override_path}),(0,G.jsx)(`small`,{children:u(`其他设置在这里持久化;管理员令牌不写入普通表单。`)})]})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(R,{size:18}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`配置生效状态`)}),(0,G.jsx)(`b`,{children:u(e.pending_restart?`等待安全重启`:`当前配置已加载`)}),(0,G.jsx)(`small`,{children:u(e.pending_restart?`保存的修改会在下一次安全重启后生效。`:`当前没有等待重启的管理端修改。`)})]})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(j,{size:18}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`浏览器会话`)}),(0,G.jsx)(`b`,{children:u(`仅当前标签会话`)}),(0,G.jsx)(`small`,{children:u(`令牌保存在 sessionStorage,关闭标签页后不会长期留存。`)})]})]})]}),(0,G.jsxs)(`div`,{className:`admin-security-note`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsxs)(`p`,{children:[(0,G.jsx)(`b`,{children:u(`如何轮换管理员令牌`)}),(0,G.jsxs)(`span`,{children:[u(`修改部署环境中的`),` `,(0,G.jsx)(`code`,{children:`ADMIN__TOKEN`}),u(`,再执行安全重启。旧会话会在重启后失效。`)]})]})]})]}):(0,G.jsxs)(G.Fragment,{children:[p===`desktop_updates`?(0,G.jsx)(ft,{value:d.desktop_updates||{},change:r,secretInputs:b,setSecretInputs:T,secretStatus:e.secret_status||{},onValidationChange:C}):I?(0,G.jsx)(I,{value:d[p]||{},change:r,apply:v,dirty:l.has(p),saving:y,request:q}):(0,G.jsxs)(G.Fragment,{children:[p===`llm`&&(0,G.jsxs)(`div`,{className:`llm-config-overview`,children:[(0,G.jsxs)(`div`,{className:`llm-config-copy`,children:[(0,G.jsx)(x,{size:22}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`启动配置`)}),(0,G.jsx)(`h3`,{children:u(`启动默认值与服务连接`)}),(0,G.jsx)(`p`,{children:u(`这里决定 Coworker 重启时先连接哪个模型服务。运行中的模型切换、摘要模型和降级链请在“模型编排”页面调整。`)})]})]}),(0,G.jsxs)(`div`,{className:`llm-config-facts`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(d.llm.default_provider||`未设置`)}),u(`启动 Provider`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(d.llm.default_model||`使用 Provider 默认值`)}),u(`启动模型`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:D.length}),u(`个可用连接`)]})]})]}),(0,G.jsxs)(`div`,{className:`config-fields`,children:[p===`llm`&&(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`启动默认值`)}),(0,G.jsx)(`small`,{children:u(`只在进程启动时读取;修改后需要安全重启。`)})]})}),p===`i18n`&&(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`实例级运行时语言`)}),(0,G.jsx)(`small`,{children:u(`控制系统 Prompt、工具说明和系统通知;与本页界面语言相互独立。修改后需要安全重启。`)})]})}),p===`agent`&&(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`空闲唤醒策略`)}),(0,G.jsx)(`small`,{children:u(`Passive 模式只等待消息、闹钟或任务等外部事件;主动模式才使用自唤醒间隔。`)})]})}),p===`wecom`&&(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`长连接热配置`)}),(0,G.jsx)(`small`,{children:u(`保存后立即启用、停用或重连企业微信;切换期间可能短暂不可用,无需重启 Coworker。`)})]})}),Object.entries(d[p]||{}).map(([t,n])=>{let o=`${p}.${t}`;if(Xe.has(o)||t===`config_file`||o.endsWith(`runtime_config_file`)||p===`llm`&&(t===`providers_file`||Ze.has(t)||/_(api_key|base_url)$/.test(t)))return null;if(t===`managed_providers`&&Array.isArray(n))return(0,G.jsxs)(`div`,{className:`provider-editor`,children:[(0,G.jsxs)(`div`,{className:`provider-editor-head`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`b`,{children:[u(`Provider 连接`),` `,(0,G.jsx)(`em`,{className:`effect-badge hot`,children:u(`修改后立即生效`)})]}),(0,G.jsx)(`small`,{children:u(`一个连接代表一套模型服务地址、接口协议和访问密钥。正在执行的单次调用不受影响,下一次调用使用新连接。`)})]}),(0,G.jsxs)(`button`,{className:`ghost mini`,onClick:()=>r(`managed_providers`,[...n,{name:``,type:`openai`,api_key:``,base_url:``,default_model:``}]),children:[(0,G.jsx)(re,{size:14}),u(`添加连接`)]})]}),(0,G.jsxs)(`div`,{className:`provider-source-note`,children:[(0,G.jsx)(i,{size:16}),(0,G.jsxs)(`p`,{children:[(0,G.jsx)(`b`,{children:u(`配置来源彼此独立`)}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`code`,{children:`.env`}),` `,u(`和`),` `,(0,G.jsx)(`code`,{children:`providers.json`}),u(`中的连接只读展示;下方只编辑管理端覆盖,不会复制或接管外部密钥。`)]})]})]}),O.length>0&&(0,G.jsxs)(`div`,{className:`provider-effective`,children:[(0,G.jsx)(`b`,{children:u(`外部有效连接(只读)`)}),O.map(e=>(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`strong`,{children:e.name}),(0,G.jsx)(`code`,{children:e.type}),(0,G.jsx)(`small`,{children:e.base_url||u(`协议默认地址`)})]},e.name))]}),n.length?n.map((t,i)=>{let o=`llm.managed_providers.${i}.api_key`,s=e.secret_status[o];return(0,G.jsxs)(`article`,{className:`provider-row`,children:[(0,G.jsx)(Q,{label:`连接名称`,hint:`在模型编排中引用的名称`,children:(0,G.jsx)(`input`,{value:t.name||``,onChange:e=>a(i,`name`,e.target.value),placeholder:u(`例如 openai-work`)})}),(0,G.jsx)(Q,{label:`接口协议`,children:(0,G.jsx)(`select`,{value:t.type||`openai`,onChange:e=>a(i,`type`,e.target.value),children:[`openai`,`anthropic`,`deepseek`,`qwen`,`zhipu`,`minimax`].map(e=>(0,G.jsx)(`option`,{children:e},e))})}),(0,G.jsx)(Q,{label:`服务地址(Base URL)`,children:(0,G.jsx)(`input`,{value:t.base_url||``,onChange:e=>a(i,`base_url`,e.target.value),placeholder:u(`留空使用协议默认地址`)})}),(0,G.jsx)(Q,{label:`默认模型`,hint:`调用未指定模型时使用`,children:(0,G.jsx)(`input`,{value:t.default_model||``,onChange:e=>a(i,`default_model`,e.target.value),placeholder:u(`可留空`)})}),(0,G.jsx)(Q,{label:`API Key`,hint:s?.configured?u(`当前已配置 · 尾号 {{last4}}`,{last4:s.last4||``}):u(`当前未配置`),children:(0,G.jsx)(`input`,{type:`password`,value:b[o]||``,onChange:e=>T({...b,[o]:e.target.value}),placeholder:s?.configured?u(`••••••••{{last4}}(留空保留)`,{last4:s.last4||``}):u(`输入 API Key`)})}),(0,G.jsx)(`button`,{className:`danger-icon provider-remove`,title:u(`移除 Provider`),onClick:()=>{r(`managed_providers`,n.filter((e,t)=>t!==i)),T(e=>Object.fromEntries(Object.entries(e).filter(([e])=>!e.startsWith(`llm.managed_providers.`))))},children:(0,G.jsx)(c,{size:15})})]},i)}):(0,G.jsx)(`div`,{className:`provider-empty`,children:u(`还没有可用的 Provider 连接。点击“添加连接”配置模型服务。`)})]},t);if(o===`llm.default_provider`){let e=Array.from(new Set([...D,...d.llm.managed_providers||[]].map(e=>e.name).filter(Boolean)));return(0,G.jsx)(Q,{label:pt[o],hint:`Coworker 启动后首先使用的连接`,children:(0,G.jsxs)(`select`,{value:String(n),onChange:e=>r(t,e.target.value),children:[!e.includes(n)&&(0,G.jsx)(`option`,{value:String(n),children:String(n)}),e.map(e=>(0,G.jsx)(`option`,{children:e},e))]})},t)}if(o===`i18n.locale`)return(0,G.jsx)(Q,{label:pt[o],hint:`保存后需安全重启;不会自动翻译用户内容或历史数据`,children:(0,G.jsxs)(`select`,{value:String(n),onChange:e=>r(t,e.target.value),children:[(0,G.jsx)(`option`,{value:`zh-CN`,children:`简体中文 (zh-CN)`}),(0,G.jsx)(`option`,{value:`en`,children:`English (en)`})]})},t);if(o===`agent.bubble_handoff_transparency_participant_matches`)return(0,G.jsxs)(W.Fragment,{children:[(0,G.jsx)(`div`,{className:`config-section-heading`,children:(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`泡泡接管提示`)}),(0,G.jsx)(`small`,{children:u(`控制哪些对话能看到泡泡接手、代答和归还;修改后需要安全重启。`)})]})}),(0,G.jsx)(Ke,{label:pt[o],hint:`支持完整 participant_id 和 glob(例如 weixin:*)。留空表示不按 participant 匹配。`,value:Array.isArray(n)?n:[],onChange:e=>r(t,e),placeholder:`weixin:*`})]},t);if(o===`agent.bubble_handoff_transparency_stream_transports`)return(0,G.jsx)(qe,{value:Array.isArray(n)?n:[],onChange:e=>r(t,e)},t);if(e.secret_status[o]){let n=e.secret_status[o],r=o===`api.communication_token`&&!n.configured&&M?.configured,i=n.configured?u(`当前已配置 · 尾号 {{last4}}`,{last4:n.last4||``}):u(r?`当前使用管理员令牌`:`当前未配置`),a=n.configured?u(`••••••••{{last4}}(留空保留)`,{last4:n.last4||``}):u(r?`留空继续使用管理员令牌`:`输入新值`);return(0,G.jsx)(Q,{hot:h(o),label:pt[o]||ht(t),hint:i,children:(0,G.jsx)(`input`,{type:`password`,value:b[o]||``,onChange:e=>T({...b,[o]:e.target.value}),placeholder:a})},t)}if(typeof n==`boolean`)return(0,G.jsxs)(`label`,{className:`switch config-switch`,children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:n,onChange:e=>r(t,e.target.checked)}),(0,G.jsx)(`i`,{}),(0,G.jsxs)(`span`,{children:[u(pt[o]||ht(t)),h(o)&&(0,G.jsx)(`em`,{className:`effect-badge hot`,children:u(`立即生效`)})]})]},t);if(typeof n==`number`){let e=o===`llm.max_tokens`?`模型单次响应允许生成的最大 token 数`:o===`agent.idle_sleep_seconds`?d.agent.passive_mode?`Passive 模式忽略此间隔;sleep(0) 表示持续等待外部事件。`:`主动模式空闲后多久自行唤醒;0 表示立即进入下一轮。`:void 0,i=o===`llm.max_tokens`?1:o===`agent.idle_sleep_seconds`?0:void 0;return(0,G.jsx)(Q,{hot:h(o),label:pt[o]||ht(t),hint:e,children:(0,G.jsx)(`input`,{type:`number`,value:n,min:i,step:o===`llm.max_tokens`?1:`any`,onChange:e=>r(t,Number(e.target.value))})},t)}return typeof n==`string`?(0,G.jsx)(Q,{hot:h(o),label:pt[o]||ht(t),hint:o===`llm.default_model`?`Provider 连接没有单独指定模型时使用`:void 0,children:(0,G.jsx)(`input`,{value:n,onChange:e=>r(t,e.target.value)})},t):(0,G.jsx)(Q,{hot:h(o),label:pt[o]||ht(t),hint:`JSON 结构`,children:(0,G.jsx)(Je,{value:n,onChange:e=>r(t,e),onValidityChange:e=>w(o,e)})},t)})]})]}),g&&(0,G.jsx)(`div`,{className:`notice ${g.kind}`,role:g.kind===`error`?`alert`:`status`,children:g.text}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`span`,{className:`save-state `+(l.has(p)?`dirty`:``),children:u(l.has(p)?`有未保存修改`:`当前分组已同步`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:y||!l.has(p)||p===`desktop_updates`&&!!s||m.size>0,onClick:()=>void v(),children:[(0,G.jsx)(oe,{size:15}),u(y?`正在保存…`:p===`desktop_updates`||p===`wecom`||p===`weixin`?`保存并立即应用`:`保存覆盖`)]}),(0,G.jsx)(`button`,{className:`ghost`,disabled:y||!l.has(p),onClick:_,children:u(`放弃本组修改`)})]})]})]})]})}function ht(e){return e.replace(/_/g,` `).replace(/\b\w/g,e=>e.toUpperCase())}var gt={pending:`待处理`,in_progress:`进行中`,completed:`已完成`};function _t(e){let t=new Date(e).getTime()-Date.now(),n=Math.abs(t),[r,i]=[[864e5,`天`],[36e5,`小时`],[6e4,`分钟`]].find(([e])=>n>=e)||[1e3,`秒`],a={amount:Math.max(1,Math.round(n/r)),unit:u(i)};return u(t>=0?`{{amount}} {{unit}}后`:`已过 {{amount}} {{unit}}`,a)}function vt(e){return e?e%86400==0?u(`每 {{amount}} 天`,{amount:e/86400}):e%3600==0?u(`每 {{amount}} 小时`,{amount:e/3600}):e%60==0?u(`每 {{amount}} 分钟`,{amount:e/60}):u(`每 {{amount}} 秒`,{amount:e}):u(`仅一次`)}function yt({confirmationName:e}){let[t,n]=(0,W.useState)(`tasks`);return(0,G.jsxs)(`div`,{className:`page-stack`,children:[(0,G.jsx)(`div`,{className:`tabbar`,children:[[`tasks`,`任务`],[`alarms`,`闹钟`],[`logs`,`运行日志`],[`maintenance`,`维护`]].map(([e,r])=>(0,G.jsx)(`button`,{className:t===e?`active`:``,onClick:()=>n(e),children:u(r)},e))}),t===`tasks`&&(0,G.jsx)(At,{}),t===`alarms`&&(0,G.jsx)(It,{}),t===`logs`&&(0,G.jsx)(Lt,{}),t===`maintenance`&&(0,G.jsx)(zt,{confirmationName:e})]})}function bt({coworkerName:e,confirmationName:t}){let[n,r]=(0,W.useState)(`short`);return(0,G.jsxs)(`div`,{className:`page-stack memory-center`,children:[(0,G.jsxs)(`div`,{className:`tabbar memory-tabs`,children:[(0,G.jsxs)(`button`,{className:n===`short`?`active`:``,onClick:()=>r(`short`),children:[(0,G.jsx)(I,{size:14}),u(`短期记忆`)]}),(0,G.jsxs)(`button`,{className:n===`long`?`active`:``,onClick:()=>r(`long`),children:[(0,G.jsx)(i,{size:14}),u(`长期记忆`)]}),(0,G.jsxs)(`button`,{className:n===`thoughts`?`active`:``,onClick:()=>r(`thoughts`),children:[(0,G.jsx)(L,{size:14}),u(`并行思考记录`)]})]}),n===`short`?(0,G.jsx)(kt,{coworkerName:e,confirmationName:t}):n===`long`?(0,G.jsx)(Ft,{}):(0,G.jsx)(jt,{coworkerName:e})]})}var xt={user:`消息`,assistant:`搭档`,system:`系统`,tool:`工具结果`},St={file:`文件投递`,rest:`REST API`,websocket:`WebSocket`,wecom:`企业微信`,weixin:`微信 Claw`,coworker_desktop:`桌面端`,codex:`Codex`,bubble:`气泡`,alarm:`闹钟提醒`,code_job:`代码任务`,task_reminder:`任务提醒`,system:`系统`,并行思考:`并行思考`,system_recovery:`系统恢复`,system_error:`系统错误`,skill_warning:`技能提醒`,tick:`自主循环`,model_switch:`模型切换`,auto_recall:`自动回忆`,recent_activity_auto_recall:`近期回忆`,compress_memory:`记忆压缩`,sleep_interrupt:`唤醒消息`};function Ct(e){let t=String(e||``).split(` + `).map(e=>St[e]).filter(e=>!!e);return t.length?t.map(e=>u(e)).join(` + `):u(`消息`)}function wt(e){return typeof e==`string`?e:Array.isArray(e)?e.map(e=>{if(!e||typeof e!=`object`)return String(e);let t=e,n=String(t.type||u(`结构化内容`));if([`text`,`input_text`,`output_text`].includes(n))return String(t.text??``);let r=t.filename||t._filename||t.name;return`[`+n+(r?` · `+r:``)+`]`}).join(` `):String(e??``)}function Tt(e){return Array.isArray(e)?wt(e):e&&typeof e==`object`?JSON.stringify(e,null,2):String(e??``)}function Et(e){let t=e.tool_calls||[],n=t.length?u(`调用 {{names}}`,{names:t.map(e=>e.name).join(u(`、`))}):u(`无可预览内容`);return(wt(e.content).trim()||String(e.reasoning_content||``).trim()||n).replace(/\s+/g,` `)}function Dt({message:e,index:t,defaultOpen:n=!1,coworkerName:r=``}){let i=e.role===`assistant`&&r&&r.toLowerCase()!==`coworker`?r:e.role===`user`?Ct(e.source):u(xt[e.role]||e.role),a=e.role===`assistant`&&e.usage?u(` · 输入 {{input}} / 输出 {{output}} token`,{input:Number(e.usage.input_tokens||0).toLocaleString(),output:Number(e.usage.output_tokens||0).toLocaleString()}):``,o=Ct(e.source),s=e.pin_id?u(`固定`):e.tool_calls?.length?u(`{{count}} 个工具调用`,{count:e.tool_calls.length}):e.stop_reason||``;return(0,G.jsxs)(`details`,{className:`short-message role-`+e.role,open:n,children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsx)(`span`,{className:`message-index`,children:String(t+1).padStart(2,`0`)}),(0,G.jsxs)(`span`,{className:`message-summary-copy`,children:[(0,G.jsx)(`b`,{children:i}),(0,G.jsxs)(`small`,{children:[new Date(e.timestamp).toLocaleString(),` · `,o,a]}),(0,G.jsx)(`em`,{className:`message-preview`,children:Et(e)})]}),(0,G.jsx)(`i`,{children:s})]}),(0,G.jsxs)(`div`,{className:`short-message-body`,children:[(0,G.jsx)(`pre`,{children:wt(e.content)}),e.reasoning_content&&(0,G.jsxs)(`section`,{className:`message-reasoning`,children:[(0,G.jsxs)(`b`,{children:[(0,G.jsx)(x,{size:12}),u(`思考`)]}),(0,G.jsx)(`pre`,{children:e.reasoning_content})]}),e.tool_calls?.length>0&&(0,G.jsxs)(`section`,{className:`message-tool-section`,children:[(0,G.jsxs)(`b`,{children:[(0,G.jsx)(U,{size:12}),u(`工具调用`)]}),(0,G.jsx)(`div`,{className:`message-tools`,children:e.tool_calls.map(e=>(0,G.jsxs)(`details`,{className:`tool-exchange`,open:!0,children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(U,{size:11}),e.name]}),(0,G.jsx)(`small`,{children:`result`in e?u(`已返回`):u(`等待结果`)})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`label`,{children:u(`参数`)}),(0,G.jsx)(`pre`,{children:Tt(e.arguments)}),(0,G.jsx)(`label`,{children:u(`结果`)}),(0,G.jsx)(`pre`,{children:`result`in e?Tt(e.result):u(`尚未返回结果`)})]})]},e.id||e.name))})]}),e.recalled_memory_ids?.length>0&&(0,G.jsxs)(`p`,{children:[u(`召回长期记忆:`),` `,e.recalled_memory_ids.join(` · `)]}),e.tool_call_id&&(0,G.jsxs)(`p`,{children:[u(`工具调用 ID:`),` `,e.tool_call_id]})]})]})}function Ot({node:e,depth:t=0}){let n=e.children||[];return(0,G.jsxs)(`details`,{className:`short-tree-node`,open:t===0,style:{"--indent":Math.min(t,3)*7+`px`},children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsxs)(`span`,{className:`tree-level`,children:[`L`,e.level]}),(0,G.jsxs)(`span`,{className:`tree-node-copy`,children:[(0,G.jsxs)(`b`,{children:[new Date(e.t_start).toLocaleString(),` → `,new Date(e.t_end).toLocaleString()]}),(0,G.jsxs)(`small`,{children:[u(`{{count}} 条消息`,{count:e.msg_count}),` · `,Number(e.token_estimate).toLocaleString(),` token`,` · `,e.token_count_source===`exact`?u(`精确摘要计数`):u(`估算摘要计数`)]})]}),(0,G.jsx)(`span`,{className:e.raw_available?`raw-state`:`raw-state summary-only`,children:e.raw_available?u(`原文可达`):u(`仅摘要`)})]}),(0,G.jsxs)(`div`,{className:`tree-node-detail`,children:[(0,G.jsx)(`p`,{children:e.summary}),n.length>0&&(0,G.jsx)(`div`,{className:`tree-children`,children:n.map((e,n)=>(0,G.jsx)(Ot,{node:e,depth:t+1},e.t_start+`-`+e.level+`-`+n))})]})]})}function kt({coworkerName:e,confirmationName:t}){let{data:n,error:r,loading:i,reload:a,setData:o}=Y(()=>q(`/api/admin/memory/short-term`),[]),[s,l]=(0,W.useState)(64),[d,f]=(0,W.useState)({label:``,content:``}),[p,m]=(0,W.useState)(!1),[h,g]=(0,W.useState)(``),[_,v]=(0,W.useState)(``),[y,x]=(0,W.useState)(``),[S,C]=(0,W.useState)(``);if((0,W.useEffect)(()=>{if(!n?.backfill?.running)return;let e=window.setInterval(()=>{q(`/api/admin/memory/short-term`).then(o).catch(()=>void 0)},1500);return()=>window.clearInterval(e)},[n?.backfill?.running,o]),i||!n)return(0,G.jsx)(Z,{error:r});let w=n.token_watermark,T=Math.max(0,Number(w.ratio||0)),E=Math.round(T*100),D=w.measured_at?new Date(w.measured_at).toLocaleString():u(`当前读取`),O=async()=>{x(``),C(``);try{await q(`/api/admin/memory/backfill?max_leaves=`+Math.max(1,Math.min(512,s)),{method:`POST`}),C(u(`记忆树回溯已开始`)),await a()}catch(e){x(e instanceof Error?e.message:u(`回溯启动失败`))}},k=async e=>{e.preventDefault(),g(``),v(``),m(!0);try{await q(`/api/admin/memory/pinned`,{method:`POST`,body:JSON.stringify(d)}),f({label:``,content:``}),v(u(`固定上下文已添加`)),await a()}catch(e){g(e instanceof Error?e.message:u(`固定上下文添加失败`))}finally{m(!1)}},ee=async e=>{if(confirm(u(`删除固定上下文“{{label}}”?`,{label:e.label}))){g(``),v(``);try{await q(`/api/admin/memory/pinned/`+encodeURIComponent(e.pin_id),{method:`DELETE`}),v(u(`固定上下文已删除`)),await a()}catch(e){g(e instanceof Error?e.message:u(`固定上下文删除失败`))}}};return(0,G.jsxs)(`div`,{className:`page-stack short-memory-page`,children:[(0,G.jsxs)(`section`,{className:`short-watermark`,children:[(0,G.jsxs)(`div`,{className:`watermark-reading`,children:[(0,G.jsx)(`div`,{className:`watermark-orbit`,style:{"--water":Math.min(100,E)+`%`},children:(0,G.jsxs)(`span`,{children:[(0,G.jsxs)(`b`,{children:[E,`%`]}),(0,G.jsx)(`small`,{children:u(`上下文水位`)})]})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`最近一次模型输入`)}),(0,G.jsxs)(`h2`,{children:[Number(w.tokens).toLocaleString(),` `,(0,G.jsxs)(`small`,{children:[`/ `,Number(w.capacity).toLocaleString(),` token`]})]}),(0,G.jsx)(`div`,{className:`watermark-track`,children:(0,G.jsx)(`i`,{style:{width:Math.min(100,E)+`%`}})}),(0,G.jsxs)(`p`,{children:[w.source===`provider`?u(`Provider 精确值`):u(`本地估算值`),` · `,D]})]})]}),(0,G.jsxs)(`div`,{className:`watermark-facts`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`small`,{children:u(`采样模型`)}),(0,G.jsxs)(`b`,{children:[w.provider,`/`,w.model]})]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`small`,{children:u(`当前短期估算`)}),(0,G.jsxs)(`b`,{children:[Number(w.estimated_short_term_tokens).toLocaleString(),` token`]})]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`small`,{children:u(`消息 / 脊柱 / 固定项`)}),(0,G.jsxs)(`b`,{children:[n.stats.message_count,` / `,n.stats.tree_node_count,` / `,n.stats.pinned_count]})]}),(0,G.jsxs)(`p`,{children:[(0,G.jsx)(B,{size:13}),u(`精确值包含系统提示与工具定义;短期估算用于判断压缩水位。`)]})]}),(0,G.jsx)(`button`,{className:`icon-btn watermark-refresh`,onClick:()=>void a(),title:u(`刷新短期记忆`),"aria-label":u(`刷新短期记忆`),children:(0,G.jsx)(R,{size:16})})]}),(0,G.jsxs)(`div`,{className:`short-memory-grid`,children:[(0,G.jsx)(X,{title:`记忆脊柱`,note:`越老的记忆层级越高;展开节点可向下查看保留的细节。`,className:`short-tree-panel`,children:(0,G.jsx)(`div`,{className:`short-tree`,children:n.tree.nodes.length?n.tree.nodes.map((e,t)=>(0,G.jsx)(Ot,{node:e},e.t_start+`-`+e.level+`-`+t)):(0,G.jsx)(xn,{text:`记忆树还是空的;上下文压缩后会在这里形成时间脊柱。`})})}),(0,G.jsx)(X,{title:`当前消息尾部`,note:`这些消息会按顺序直接进入下一次主线思考。`,className:`short-tail-panel`,children:(0,G.jsx)(`div`,{className:`short-message-list`,children:n.messages.length?n.messages.map((t,r)=>(0,G.jsx)(Dt,{message:t,index:r,defaultOpen:r>=n.messages.length-3,coworkerName:e},t.timestamp+`-`+t.index)):(0,G.jsx)(xn,{text:`当前没有短期消息;新的输入会从这里开始累积。`})})})]}),(0,G.jsxs)(X,{title:`固定上下文`,note:`固定项会在缺失时重新注入主线,避免关键资料被压缩带走。`,children:[(h||_)&&(0,G.jsx)(`div`,{className:`notice `+(h?`error`:`success`),children:h||_}),(0,G.jsxs)(`form`,{className:`pin-compose`,onSubmit:k,children:[(0,G.jsx)(`input`,{required:!0,maxLength:80,value:d.label,onChange:e=>f({...d,label:e.target.value}),placeholder:u(`标题,例如:项目约定`)}),(0,G.jsx)(`textarea`,{required:!0,value:d.content,onChange:e=>f({...d,content:e.target.value}),placeholder:u(`需要始终保留在上下文里的内容`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:p,children:[(0,G.jsx)(re,{size:14}),u(p?`添加中…`:`添加固定项`)]})]}),(0,G.jsx)(`div`,{className:`pinned-context-list`,children:n.pinned_items.length?n.pinned_items.map(e=>(0,G.jsxs)(`details`,{children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsx)(j,{size:15}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:e.label}),(0,G.jsxs)(`small`,{children:[e.pin_id,` · `,new Date(e.created_at).toLocaleString()]})]}),(0,G.jsx)(`button`,{type:`button`,className:`icon-btn pin-delete`,title:u(`删除固定上下文`),"aria-label":u(`删除固定上下文 {{label}}`,{label:e.label}),onClick:t=>{t.preventDefault(),ee(e)},children:(0,G.jsx)(c,{size:14})})]}),(0,G.jsx)(`pre`,{children:e.content}),e.file_path&&(0,G.jsxs)(`p`,{children:[(0,G.jsx)(A,{size:12}),u(`跟随文件:`),` `,e.file_path]})]},e.pin_id)):(0,G.jsx)(xn,{text:`当前没有固定上下文;可以从上方添加。`})})]}),(0,G.jsxs)(X,{title:`记忆维护`,note:`压缩会调用模型;回溯在后台从持久日志重建时间脊柱。`,children:[(y||S)&&(0,G.jsx)(`div`,{className:`notice `+(y?`error`:`success`),children:y||S}),(0,G.jsxs)(`div`,{className:`danger-list memory-maintenance`,children:[(0,G.jsx)(Rt,{title:`全量压缩短期记忆`,description:`把当前主线消息压缩进记忆树,释放上下文空间。执行期间会产生模型调用。`,button:`开始压缩`,confirmationName:t,onConfirm:async()=>{await q(`/api/admin/memory/compress`,{method:`POST`,body:JSON.stringify({confirm_name:t})}),await a()}}),(0,G.jsxs)(`article`,{className:`danger-card mild`,children:[(0,G.jsx)(b,{size:20}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`回溯记忆树`)}),(0,G.jsx)(`p`,{children:n.backfill.running?u(`正在重建:{{done}}/{{total}}`,{done:n.backfill.done,total:n.backfill.total||`—`}):u(`从持久日志后台重建多尺度记忆树,不阻塞主循环。`)})]}),(0,G.jsx)(`input`,{className:`tiny-input`,"aria-label":u(`最多回溯叶子数`),type:`number`,min:`1`,max:`512`,value:s,onChange:e=>l(Number(e.target.value))}),(0,G.jsx)(`button`,{className:`ghost`,disabled:n.backfill.running,onClick:()=>void O(),children:n.backfill.running?u(`回溯中…`):u(`开始回溯`)})]})]})]})]})}function At(){let{data:e,error:t,loading:n,reload:i}=Y(()=>q(`/api/admin/tasks`),[]),[a,o]=(0,W.useState)({description:``,details:``}),[s,l]=(0,W.useState)(`active`),[d,f]=(0,W.useState)(null),p=async()=>{await q(`/api/admin/tasks`,{method:`POST`,body:JSON.stringify(a)}),o({description:``,details:``}),await i()};if(n||!e)return(0,G.jsx)(Z,{error:t});let m=e.tasks.reduce((e,t)=>({...e,[t.status]:(e[t.status]||0)+1}),{}),h=e.tasks.filter(e=>s===`all`||(s===`active`?e.status!==`completed`:e.status===s)),g=async()=>{d&&(await q(`/api/admin/tasks/`+d.id,{method:`PATCH`,body:JSON.stringify(d)}),f(null),await i())},_=[[`active`,u(`进行中 {{count}}`,{count:Number(m.pending||0)+Number(m.in_progress||0)})],[`completed`,u(`已完成 {{count}}`,{count:m.completed||0})],[`all`,u(`全部 {{count}}`,{count:e.tasks.length})]];return(0,G.jsxs)(X,{title:`任务板`,note:`任务说明与执行细节会和 Coworker 的 task 工具实时共享。`,children:[(0,G.jsxs)(`div`,{className:`task-compose`,children:[(0,G.jsx)(`input`,{value:a.description,onChange:e=>o({...a,description:e.target.value}),onKeyDown:e=>{e.key===`Enter`&&a.description.trim()&&p()},placeholder:u(`要完成什么?`)}),(0,G.jsx)(`textarea`,{value:a.details,onChange:e=>o({...a,details:e.target.value}),placeholder:u(`补充执行细节(可选)`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:!a.description.trim(),onClick:()=>void p(),children:[(0,G.jsx)(re,{size:15}),u(`添加任务`)]})]}),(0,G.jsxs)(`div`,{className:`list-toolbar`,children:[(0,G.jsx)(`div`,{className:`task-filters`,children:_.map(([e,t])=>(0,G.jsx)(`button`,{className:s===e?`active`:``,onClick:()=>l(e),children:t},e))}),(0,G.jsx)(`button`,{className:`icon-btn`,onClick:()=>void i(),title:u(`刷新任务`),"aria-label":u(`刷新任务`),children:(0,G.jsx)(R,{size:15})})]}),(0,G.jsx)(`div`,{className:`record-list`,children:h.length?h.map(e=>(0,G.jsxs)(`article`,{className:`record task-record `+e.status,children:[(0,G.jsxs)(`div`,{className:`record-main`,children:[(0,G.jsx)(`span`,{className:`status-pill `+e.status,children:u(gt[e.status]||e.status)}),(0,G.jsx)(`b`,{children:e.description}),e.details&&(0,G.jsx)(`p`,{className:`record-details`,children:e.details}),(0,G.jsxs)(`small`,{children:[u(`更新于 {{time}}`,{time:new Date(e.updated_at).toLocaleString()}),` · `,e.id]})]}),(0,G.jsxs)(`div`,{className:`row-actions`,children:[(0,G.jsx)(`select`,{"aria-label":u(`更新任务“{{description}}”的状态`,{description:e.description}),value:e.status,onChange:async t=>{await q(`/api/admin/tasks/`+e.id,{method:`PATCH`,body:JSON.stringify({description:e.description,details:e.details||``,status:t.target.value})}),await i()},children:Object.entries(gt).map(([e,t])=>(0,G.jsx)(`option`,{value:e,children:u(t)},e))}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`编辑任务`),"aria-label":u(`编辑任务`),onClick:()=>f({...e}),children:(0,G.jsx)(r,{size:15})}),(0,G.jsx)(`button`,{className:`danger-icon`,title:u(`删除任务`),"aria-label":u(`删除任务“{{description}}”`,{description:e.description}),onClick:async()=>{confirm(u(`删除任务“{{description}}”?`,{description:e.description}))&&(await q(`/api/admin/tasks/`+e.id,{method:`DELETE`}),await i())},children:(0,G.jsx)(c,{size:15})})]})]},e.id)):(0,G.jsx)(xn,{text:e.tasks.length?`这个分类里没有任务。`:`还没有任务,先写下第一件要推进的事。`})}),d&&(0,G.jsx)(`div`,{className:`modal-layer`,children:(0,G.jsxs)(`div`,{className:`confirm-modal task-modal`,children:[(0,G.jsx)(P,{size:24}),(0,G.jsx)(`h3`,{children:u(`编辑任务`)}),(0,G.jsx)(Q,{label:`任务描述`,children:(0,G.jsx)(`input`,{autoFocus:!0,value:d.description,onChange:e=>f({...d,description:e.target.value})})}),(0,G.jsx)(Q,{label:`执行细节`,children:(0,G.jsx)(`textarea`,{value:d.details||``,onChange:e=>f({...d,details:e.target.value}),placeholder:u(`记录计划、进度或下一步`)})}),(0,G.jsx)(Q,{label:`状态`,children:(0,G.jsx)(`select`,{value:d.status,onChange:e=>f({...d,status:e.target.value}),children:Object.entries(gt).map(([e,t])=>(0,G.jsx)(`option`,{value:e,children:u(t)},e))})}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`button`,{className:`ghost`,onClick:()=>f(null),children:u(`取消`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:!d.description.trim(),onClick:()=>void g(),children:[(0,G.jsx)(S,{size:15}),u(`保存任务`)]})]})]})})]})}function jt({coworkerName:e}){let[t,n]=(0,W.useState)(`bubbles`),r=t===`bubbles`?`/api/admin/bubbles`:`/api/admin/subconscious`,{data:i,error:a,loading:o,reload:s,setData:c}=Y(()=>q(r+`?limit=50`),[t]),[l,d]=(0,W.useState)(!1),[f,p]=(0,W.useState)(``);if((0,W.useEffect)(()=>p(``),[t]),o||!i)return(0,G.jsx)(Z,{error:a});let m=async()=>{d(!0),p(``);try{let e=await q(r+`?limit=50&offset=`+i.bubbles.length);c({...e,bubbles:[...i.bubbles,...e.bubbles||[]]})}catch(e){p(e instanceof Error?e.message:u(`更多历史记录加载失败`))}finally{d(!1)}};return(0,G.jsxs)(X,{title:`并行思考记录`,note:`查看主动 Bubble 和潜意识已落盘的完整思考轨迹。`,children:[(0,G.jsxs)(`div`,{className:`list-toolbar`,children:[(0,G.jsxs)(`div`,{className:`task-filters`,children:[(0,G.jsx)(`button`,{className:t===`bubbles`?`active`:``,onClick:()=>n(`bubbles`),children:u(`主动 Bubble`)}),(0,G.jsx)(`button`,{className:t===`subconscious`?`active`:``,onClick:()=>n(`subconscious`),children:u(`潜意识`)})]}),(0,G.jsx)(`button`,{className:`icon-btn`,onClick:()=>void s(),title:u(`刷新思考记录`),"aria-label":u(`刷新思考记录`),children:(0,G.jsx)(R,{size:15})})]}),(0,G.jsx)(`div`,{className:`bubble-list`,children:i.bubbles.length?i.bubbles.map(n=>(0,G.jsx)(Pt,{bubble:n,reload:s,scope:t,coworkerName:e},n.log_id||n.id)):(0,G.jsx)(xn,{text:t===`bubbles`?`当前没有 Bubble 记录。`:`当前没有潜意识记录。`})}),f&&(0,G.jsx)(`div`,{className:`notice error`,children:f}),i.has_more&&(0,G.jsx)(`button`,{className:`bubble-load-more ghost`,disabled:l,onClick:()=>void m(),children:l?u(`加载中…`):u(`加载更多(已显示 {{shown}}/{{total}})`,{shown:i.bubbles.length,total:i.total})})]})}var Mt={running:`运行中`,done:`完成`,error:`失败`,cancelled:`已取消`,timeout:`超时`};function Nt(e){let t=new Map(e.filter(e=>e.type===`tool_result`).map(e=>[e.id,e]));return e.flatMap((e,n)=>{let r={timestamp:e.ts,index:n,source:`并行思考`};if(e.type===`tool_call`||e.type===`tool_result`)return[];if(e.type===`message_in`)return[{...r,role:e.participant_id===`system`?`system`:`user`,source:e.source||`并行思考`,content:e.content}];if(e.type===`thinking_start`)return[{...r,role:`system`,content:u(`第 {{count}} 轮开始{{mode}}`,{count:Number(e.cycle||0)+1,mode:e.thinking===!1?u(`(快速模式)`):``})}];if(e.type===`llm_response`)return[{...r,role:`assistant`,source:e.model||`并行思考`,content:e.content||``,reasoning_content:e.reasoning_content,usage:e.usage,stop_reason:e.stop_reason,tool_calls:(e.tool_calls||[]).map(e=>{let n=t.get(e.id);return n?{...e,result:n.content}:e})}];if(e.__meta__)return[{...r,role:`system`,content:u(`并行思考结束 @@ -43,4 +43,4 @@ version: 1.0.0 `).length:0}}function Gt(){let[e,t]=(0,W.useState)(`skills`),[n,r]=(0,W.useState)(``),[i,a]=(0,W.useState)(``),[o,s]=(0,W.useState)(``),[l,d]=(0,W.useState)(``),[f,p]=(0,W.useState)(``),[m,h]=(0,W.useState)(``),[g,_]=(0,W.useState)(``),[v,y]=(0,W.useState)(``),[b,x]=(0,W.useState)([]),[C,w]=(0,W.useState)(``),[T,E]=(0,W.useState)(!1),{data:D,error:O,loading:ee,reload:j}=Y(()=>q(`/api/admin/content/`+e),[e]),M=i!==o,N=(0,W.useMemo)(()=>Wt(i),[i]),P=(0,W.useMemo)(()=>(D?.items||[]).filter(e=>(e.id+` `+e.name+` `+e.summary).toLowerCase().includes(f.trim().toLowerCase())),[D,f]),F=u($[e].label),I=u($[e].description);(0,W.useEffect)(()=>{let e=e=>{M&&e.preventDefault()};return window.addEventListener(`beforeunload`,e),()=>window.removeEventListener(`beforeunload`,e)},[M]);let L=()=>!M||confirm(u(`当前内容尚未保存,确定放弃修改?`)),ne=t=>{if(t===n||!L())return;let i=D?.items.find(e=>e.id===t),o=$[e].filename;r(t),d(``),y(o),x(i?.files||[]),a(i?.raw||``),s(i?.raw||``),h(``),_(``),E(!1)},ie=n=>{n===e||!L()||(t(n),r(``),d(``),y(``),x([]),a(``),s(``),p(``),h(``),_(``))},ae=()=>{L()&&(r(``),d(``),y($[e].filename),x([]),a(Ut(e)),s(``),h(``),_(``))},z=async(t=n)=>{if(!t)return;let r=await q(`/api/admin/content/`+e+`/`+encodeURIComponent(t)+`/files`);x(r.files||[])},ce=async t=>{if(!(!n||t===v||!L())){_(``),h(``);try{if(t===$[e].filename){let e=D?.items.find(e=>e.id===n);a(e?.raw||``),s(e?.raw||``)}else{let r=await q(`/api/admin/content/`+e+`/`+encodeURIComponent(n)+`/files/`+t.split(`/`).map(encodeURIComponent).join(`/`));a(r.content||``),s(r.content||``)}y(t)}catch(e){_(e instanceof Error?e.message:u(`文件读取失败`))}}},le=async()=>{let t=n||l.trim();if(t){_(``),h(``);try{let n=!v||v===$[e].filename;await q(n?`/api/admin/content/`+e+`/`+encodeURIComponent(t):`/api/admin/content/`+e+`/`+encodeURIComponent(t)+`/files/`+v.split(`/`).map(encodeURIComponent).join(`/`),{method:`PUT`,body:JSON.stringify(n?{raw:i}:{content:i})}),r(t),d(``),y(v||$[e].filename),s(i),h(u(n?`已保存并重新加载,新的能力定义现在已生效。`:`文件已保存到能力目录。`)),await j(),await z(t)}catch(e){_(e instanceof Error?e.message:u(`保存失败`))}}},U=async()=>{let t=C.trim().replace(/\\/g,`/`);if(!(!n||!t||!L())){_(``);try{await q(`/api/admin/content/`+e+`/`+encodeURIComponent(n)+`/files/`+t.split(`/`).map(encodeURIComponent).join(`/`),{method:`PUT`,body:JSON.stringify({content:``})}),await z(),w(``),E(!1),y(t),a(``),s(``),h(u(`文件已创建,可以开始编辑。`))}catch(e){_(e instanceof Error?e.message:u(`文件创建失败`))}}},K=async()=>{if(!n||!v||v===$[e].filename||!confirm(u(`删除 {{path}}?`,{path:n+`/`+v})))return;await q(`/api/admin/content/`+e+`/`+encodeURIComponent(n)+`/files/`+v.split(`/`).map(encodeURIComponent).join(`/`),{method:`DELETE`}),await z();let t=D?.items.find(e=>e.id===n),r=$[e].filename;y(r),a(t?.raw||``),s(t?.raw||``),h(u(`文件已删除。`))},ue=D?.items.find(e=>e.id===n),de=!!(i||n||l),fe=/^[A-Za-z0-9._-]+$/.test(l.trim()),pe=!!D?.items?.some(e=>String(e.id).toLowerCase()===l.trim().toLowerCase()),me=e===`skills`?N.description:e===`palaces`?N.whenToAttach:N.trigger&&N.purpose,he=!!(fe&&!pe&&N.name&&me),ge=n?ue?.name||n:de?u(`新建 {{label}}`,{label:F}):u(`选择一项能力内容`),_e=de?v&&v!==$[e].filename?u(`正在编辑 {{file}}`,{file:v}):N.summary||I:u(`从左侧选择现有内容,或创建一项新的能力定义。`);return(0,G.jsxs)(`div`,{className:`content-workspace`,children:[(0,G.jsx)(`section`,{className:`capability-strip`,children:Object.entries($).map(([t,n])=>(0,G.jsxs)(`button`,{className:e===t?`active`:``,onClick:()=>ie(t),children:[(0,G.jsx)(`span`,{children:u(n.label)}),(0,G.jsx)(`b`,{children:t===e?D?.items?.length??`—`:``}),(0,G.jsx)(`small`,{children:u(n.description)})]},t))}),(0,G.jsxs)(`div`,{className:`content-layout`,children:[(0,G.jsxs)(`aside`,{className:`content-index`,children:[(0,G.jsxs)(`div`,{className:`content-index-head`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`{{label}} 能力目录`,{label:F})}),(0,G.jsx)(`b`,{children:u(`{{count}} 项能力`,{count:D?.items?.length||0})})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`刷新能力目录`),"aria-label":u(`刷新能力目录`),onClick:()=>void j(),children:(0,G.jsx)(R,{size:14})})]}),(0,G.jsxs)(`label`,{className:`content-search`,children:[(0,G.jsx)(se,{size:14}),(0,G.jsx)(`input`,{value:f,onChange:e=>p(e.target.value),placeholder:u(`搜索名称或用途`)})]}),ee?(0,G.jsx)(Z,{}):O?(0,G.jsx)(Z,{error:O}):(0,G.jsx)(`div`,{className:`content-items`,children:P.length?P.map(e=>(0,G.jsxs)(`button`,{className:n===e.id?`active`:``,onClick:()=>ne(e.id),children:[(0,G.jsx)(`span`,{className:`content-health `+(e.valid?`valid`:`invalid`)}),(0,G.jsxs)(`span`,{className:`content-item-copy`,children:[(0,G.jsx)(`b`,{children:e.name||e.id}),(0,G.jsx)(`small`,{children:e.summary||e.warning||u(`尚未填写用途说明`)})]}),e.metadata?.protected&&(0,G.jsx)(B,{size:13})]},e.id)):(0,G.jsx)(`div`,{className:`content-no-result`,children:u(f?`没有匹配的能力内容`:`这个分类还是空的`)})}),(0,G.jsxs)(`button`,{className:`new-content`,onClick:ae,children:[(0,G.jsx)(re,{size:14}),u(`新建 {{label}}`,{label:F})]})]}),n&&(0,G.jsxs)(`aside`,{className:`content-files`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(te,{size:15}),(0,G.jsx)(`span`,{children:n})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`新建文件`),"aria-label":u(`新建文件`),onClick:()=>E(!T),children:(0,G.jsx)(re,{size:14})})]}),T&&(0,G.jsxs)(`div`,{className:`file-create`,children:[(0,G.jsx)(`input`,{autoFocus:!0,value:C,onChange:e=>w(e.target.value),onKeyDown:e=>e.key===`Enter`&&void U(),placeholder:`scripts/check.py`}),(0,G.jsx)(`button`,{disabled:!C.trim(),onClick:()=>void U(),children:(0,G.jsx)(S,{size:13})})]}),(0,G.jsx)(`div`,{className:`file-tree`,children:b.map(e=>(0,G.jsxs)(`button`,{className:v===e.path?`active`:``,disabled:!e.editable,title:e.editable?e.path:u(`该文件不支持在线编辑`),onClick:()=>void ce(e.path),children:[e.primary?(0,G.jsx)(A,{size:14}):(0,G.jsx)(k,{size:14}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:e.path}),(0,G.jsx)(`small`,{children:e.editable?Number(e.size_bytes).toLocaleString()+` B`:u(`仅展示`)})]}),e.primary&&(0,G.jsx)(`i`,{children:u(`主`)})]},e.path))}),(0,G.jsx)(`footer`,{children:u(`仅编辑 UTF-8 文本,不会执行脚本`)})]}),(0,G.jsx)(X,{title:ge,note:_e,className:`content-editor`,children:de?(0,G.jsxs)(G.Fragment,{children:[!n&&(0,G.jsxs)(`section`,{className:`source-create-card`,children:[(0,G.jsx)(`div`,{className:`source-create-mark`,children:(0,G.jsx)(k,{size:20})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`新建源定义`)}),(0,G.jsx)(`h3`,{children:u(`创建 {{label}}`,{label:F})}),(0,G.jsx)(`p`,{children:u(`模板已准备好。填写目录 ID,然后直接编辑定义文件。`)})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`目录 ID`)}),(0,G.jsx)(`input`,{autoFocus:!0,className:l&&(!fe||pe)?`invalid`:``,value:l,onChange:e=>{d(e.target.value),h(``)},placeholder:e===`skills`?`release-check`:e===`palaces`?`product-testing`:`architecture-review`}),(0,G.jsx)(`small`,{children:u(pe?`这个 ID 已存在`:!l||fe?`字母、数字、点、短横线或下划线`:`ID 含有不支持的字符`)})]})]}),ue&&!ue.valid&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),ue.warning]}),(0,G.jsxs)(`div`,{className:`source-workbench`,children:[(0,G.jsxs)(`div`,{className:`source-toolbar`,children:[(0,G.jsxs)(`div`,{className:`editor-file`,children:[(0,G.jsx)(`span`,{className:`source-status `+(M?`dirty`:``),title:u(M?`有未保存修改`:`内容已同步`)}),(0,G.jsx)(A,{size:14}),(0,G.jsxs)(`code`,{children:[(0,G.jsx)(`b`,{children:n||l||u(`目录-id`)}),(0,G.jsx)(`i`,{children:`/`}),v||$[e].filename]})]}),(0,G.jsxs)(`div`,{className:`source-readout`,children:[(0,G.jsx)(`span`,{children:`YAML + MD`}),(0,G.jsx)(`span`,{children:`UTF-8`}),(0,G.jsx)(`b`,{children:u(`{{count}} 行`,{count:N.lines})}),(0,G.jsxs)(`b`,{children:[new Blob([i]).size.toLocaleString(),` B`]})]})]}),(0,G.jsxs)(`div`,{className:`source-schema`,children:[(0,G.jsx)(`span`,{children:u(`必填字段`)}),(0,G.jsx)(`code`,{children:Ht[e].required}),(0,G.jsx)(`i`,{}),` `,(0,G.jsx)(`p`,{children:u(Ht[e].tip)}),(0,G.jsx)(`kbd`,{children:`Ctrl S`})]}),(0,G.jsx)(`textarea`,{className:`source-editor`,value:i,onChange:e=>{a(e.target.value),h(``)},onKeyDown:e=>{(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`s`&&(e.preventDefault(),(n||he)&&i.trim()&&le())},spellCheck:!1,"aria-label":u(`能力内容源码`)})]}),g&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),g]}),m&&(0,G.jsxs)(`div`,{className:`notice success`,children:[(0,G.jsx)(S,{size:16}),m]}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`span`,{className:`save-state `+(M?`dirty`:``),children:u(n?M?`有未保存修改`:`内容已同步`:he?`定义已就绪`:!fe||pe?`填写有效的目录 ID`:`补全源码中的必填字段`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:n?!M:!he||!M,onClick:()=>void le(),children:[(0,G.jsx)(oe,{size:15}),n?v&&v!==$[e].filename?u(`保存文件`):u(`保存并加载`):u(`创建并加载`)]}),n&&v!==$[e].filename&&(0,G.jsxs)(`button`,{className:`danger-outline`,onClick:()=>void K(),children:[(0,G.jsx)(c,{size:14}),u(`删除文件`)]}),n&&v===$[e].filename&&(0,G.jsxs)(`button`,{className:`danger-outline`,onClick:async()=>{confirm(u(`删除 {{kind}}/{{id}} 整个能力目录?其中的 scripts、references 和其他附属文件也会一并删除。`,{kind:e,id:n}))&&(await q(`/api/admin/content/`+e+`/`+encodeURIComponent(n),{method:`DELETE`}),r(``),y(``),x([]),a(``),s(``),h(``),await j())},children:[(0,G.jsx)(c,{size:14}),u(`删除能力`)]})]})]}):(0,G.jsxs)(`div`,{className:`content-welcome`,children:[(0,G.jsxs)(`div`,{className:`welcome-orbit`,children:[(0,G.jsx)(V,{size:28}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`h3`,{children:u(`{{label}} 能力目录`,{label:F})}),(0,G.jsxs)(`p`,{children:[I,u(`。选择左侧条目查看源码与预览。`)]}),(0,G.jsxs)(`button`,{className:`ghost`,onClick:ae,children:[(0,G.jsx)(re,{size:14}),u(`创建第一项内容`)]})]})})]})]})}var Kt=[`windows-x86_64`,`windows-i686`,`windows-aarch64`,`windows-armv7`,`darwin-x86_64`,`darwin-i686`,`darwin-aarch64`,`darwin-armv7`,`linux-x86_64`,`linux-i686`,`linux-aarch64`,`linux-armv7`];function qt(e){return e.type===`coworker_release`?`Coworker 发布同步`:e.draft?`GitHub 草稿同步`:e.prerelease?`GitHub 预发布同步`:`GitHub Release 同步`}var Jt=128,Yt=512*1024*1024,Xt=0;function Zt(e=0){if(e<1024)return`${e} B`;let t=[`KB`,`MB`,`GB`],n=e/1024,r=t[0];for(let e=1;e=1024;e+=1)n/=1024,r=t[e];return`${n>=10?n.toFixed(0):n.toFixed(1)} ${r}`}function Qt(e){return e.split(/[\\/]/).filter(Boolean).pop()||``}function $t(e){return/\.zip$/i.test(e)}function en(e){return/\.sig$/i.test(e)}function tn(e){return e.replace(/\.sig$/i,``)}function nn(e){return en(e)||/\.app\.tar\.gz$/i.test(e)||/\.(exe|dmg|appimage|deb|rpm|msi)$/i.test(e)}function rn(e,t){return e.getUint16(t,!0)}function an(e,t){return e.getUint32(t,!0)}function on(e){let t=Math.max(0,e.byteLength-22-65535);for(let n=e.byteLength-22;n>=t;--n)if(an(e,n)===101010256)return n;return-1}function sn(e,t){let n=new Date((e>>9&127)+1980,(e>>5&15)-1,e&31,t>>11&31,t>>5&63,(t&31)*2).getTime();return Number.isNaN(n)?Date.now():n}async function cn(e,t,n){if(typeof DecompressionStream>`u`)throw Error(u(`当前浏览器不支持解压 deflate ZIP,请先解压后上传散文件。`));let r=new Blob([e]).stream().pipeThrough(new DecompressionStream(`deflate-raw`)).getReader(),i=[],a=0;for(;;){let{done:e,value:o}=await r.read();if(e)break;if(a+=o.byteLength,a>t||a>n)throw await r.cancel(),Error(u(`ZIP 解压大小超过声明值或 512 MiB 限制。`));i.push(o)}if(a!==t)throw Error(u(`ZIP 条目大小与目录记录不一致。`));let o=new Uint8Array(a),s=0;return i.forEach(e=>{o.set(e,s),s+=e.byteLength}),o.buffer}async function ln(e){let t=await e.arrayBuffer(),n=new DataView(t),r=on(n);if(r<0)throw Error(u(`不是有效的 ZIP 文件。`));let i=rn(n,r+10),a=an(n,r+16),o=0,s=0,c=[];for(let r=0;rn.byteLength||an(n,a)!==33639248)throw Error(u(`ZIP 中央目录结构异常。`));let r=rn(n,a+8),i=rn(n,a+10),l=rn(n,a+12),d=rn(n,a+14),f=an(n,a+20),p=an(n,a+24),m=rn(n,a+28),h=rn(n,a+30),g=rn(n,a+32),_=an(n,a+42),v=new TextDecoder(`utf-8`).decode(new Uint8Array(t,a+46,m));if(a+=46+m+h+g,!v||v.endsWith(`/`)||!nn(v))continue;if(o+=1,s+=p,o>Jt)throw Error(u(`相关发布文件超过 {{count}} 个。`,{count:Jt}));if(s>Yt)throw Error(u(`相关发布文件解压后超过 512 MiB。`));if(r&1)throw Error(u(`{{name}} 已加密,浏览器无法读取。`,{name:v}));if(f===4294967295||p===4294967295)throw Error(u(`{{name}} 使用 Zip64,当前页面不支持。`,{name:v}));if(![0,8].includes(i))throw Error(u(`{{name}} 使用了不支持的压缩方法 {{method}}。`,{name:v,method:i}));if(_+30>n.byteLength||an(n,_)!==67324752)throw Error(u(`{{name}} 的本地文件头异常。`,{name:v}));let y=_+30+rn(n,_+26)+rn(n,_+28);if(y+f>t.byteLength)throw Error(u(`{{name}} 的压缩数据不完整。`,{name:v}));let b=t.slice(y,y+f),x=i===0?b:await cn(b,p,Yt-(s-p));if(x.byteLength!==p)throw Error(u(`{{name}} 的文件大小不正确。`,{name:v}));c.push({id:`release-file-${++Xt}`,file:new File([x],Qt(v),{type:`application/octet-stream`,lastModified:sn(d,l)}),entryName:v,archiveName:e.name})}if(!c.length)throw Error(u(`ZIP 中没有识别到桌面发布产物。`));return c}async function un(e){let t=[],n=[];for(let r of e){if(!$t(r.name)){t.push({id:`release-file-${++Xt}`,file:r,entryName:r.name,archiveName:``});continue}try{t.push(...await ln(r))}catch(e){n.push(u(`{{file}}:{{error}}`,{file:r.name,error:e instanceof Error?e.message:u(`解压失败`)}))}}return{expanded:t,errors:n}}function dn(e){return[e.file.name,e.entryName,e.archiveName].filter(Boolean).join(` `)}function fn(e){let t=dn(e).toLowerCase().replace(/[\s._-]+/g,``),n=/aarch64|arm64|applesilicon/.test(t),r=/armv7|armhf/.test(t),i=/i686|x86(?!64)/.test(t),a=n?`aarch64`:r?`armv7`:i?`i686`:`x86_64`,o=e.file.name;return/\.dmg$/i.test(o)||/\.app\.tar\.gz$/i.test(o)||/darwin|macos|appledarwin/.test(t)?`darwin-${a}`:/\.(deb|rpm|appimage)$/i.test(o)||/linux/.test(t)?`linux-${a}`:/\.(exe|msi)$/i.test(o)||/windows|win32|win64|nsis|setup/.test(t)?`windows-${a}`:``}function pn(e){return/\.(dmg|deb|rpm|msi)$/i.test(e.file.name)?`installer`:`updater`}function mn(e){let t=[e.entryName,e.file.name].filter(Boolean).map(tn);return Array.from(new Set(t.flatMap(t=>e.archiveName?[`${e.archiveName}\n${t}`,t]:[t]).map(e=>e.toLowerCase())))}function hn(e){let t=new Set,n=/(?:^|[^0-9A-Za-z])v?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?=$|[^0-9A-Za-z])/g;return e.forEach(e=>{let r,i=dn(e);for(;(r=n.exec(i))!==null;)t.add(r[1])}),Array.from(t).sort()}function gn(e,t,n){let r=e.filter(e=>en(e.file.name)),i=new Set,a=new Map;r.forEach(e=>mn(e).forEach(t=>a.set(t,[...a.get(t)||[],e])));let o=e.filter(e=>!en(e.file.name)).map(e=>{let r;for(let t of mn(e)){let e=a.get(t)||[];if(e.length===1){r=e[0];break}}return r&&i.add(r.id),{id:e.id,file:e,signatureFile:r,platform:t[e.id]?.platform??fn(e),kind:t[e.id]?.kind??pn(e),duplicate:!1,error:``,state:n[e.id]||{status:`idle`}}}),s=new Map;return o.forEach(e=>{if(e.platform){let t=`${e.kind}:${e.platform}`;s.set(t,(s.get(t)||0)+1)}}),o.forEach(e=>{e.duplicate=!!(e.platform&&(s.get(`${e.kind}:${e.platform}`)||0)>1),e.error=e.file.file.size?e.platform?e.duplicate?u(`同类型平台重复`):e.kind===`updater`&&!e.signatureFile?u(`缺少同名 .sig`):``:u(`无法识别平台`):u(`文件为空`)}),{rows:o,orphanSignatures:r.filter(e=>!i.has(e.id))}}function _n({version:e,title:t,note:n,assets:r}){let i=Object.entries(r||{}),a=t=>{J(`/api/desktop-updates/assets/`+encodeURIComponent(e)+`/`+encodeURIComponent(t.file),t.file).catch(e=>window.alert(e instanceof Error?e.message:u(`下载失败`)))};return(0,G.jsxs)(`section`,{className:`release-asset-lane`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(t)}),(0,G.jsx)(`small`,{children:u(n)})]}),(0,G.jsx)(`span`,{children:i.length})]}),i.length?(0,G.jsx)(`div`,{children:i.map(([e,t])=>(0,G.jsxs)(`article`,{children:[(0,G.jsxs)(`div`,{className:`asset-platform`,children:[(0,G.jsx)(`i`,{}),` `,(0,G.jsx)(`span`,{children:e})]}),(0,G.jsxs)(`div`,{className:`asset-file`,children:[(0,G.jsx)(`b`,{title:t.file,children:t.file}),(0,G.jsxs)(`small`,{children:[Zt(t.size),` · `,t.uploaded_at?new Date(t.uploaded_at).toLocaleString():u(`时间未知`)]})]}),(0,G.jsx)(`button`,{type:`button`,onClick:()=>a(t),title:u(`下载 {{file}}`,{file:t.file}),children:(0,G.jsx)(D,{size:14})})]},e))}):(0,G.jsx)(`p`,{className:`release-lane-empty`,children:u(`还没有这类产物`)})]})}function vn({statistics:e,loading:t,error:n,onRefresh:r}){let i=e?.total_desktops||0;return(0,G.jsxs)(`section`,{className:`release-fleet`,"aria-labelledby":`release-fleet-title`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`安装基线`)}),(0,G.jsx)(`h3`,{id:`release-fleet-title`,children:u(`客户端版本分布`)})]}),(0,G.jsx)(`button`,{className:`icon-btn`,type:`button`,title:u(`刷新版本统计`),"aria-label":u(`刷新版本统计`),onClick:r,disabled:t,children:(0,G.jsx)(R,{size:14})})]}),n&&!e?(0,G.jsxs)(`div`,{className:`release-fleet-state error`,children:[(0,G.jsx)(H,{size:17}),(0,G.jsx)(`span`,{children:n||u(`版本统计读取失败`)})]}):t&&!e?(0,G.jsxs)(`div`,{className:`release-fleet-state`,children:[(0,G.jsx)(R,{className:`spin`,size:17}),(0,G.jsx)(`span`,{children:u(`正在读取客户端版本…`)})]}):i?(0,G.jsxs)(`div`,{className:`release-fleet-body`,children:[(0,G.jsxs)(`div`,{className:`release-fleet-summary`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:i}),u(`台已注册桌面`)]}),(0,G.jsxs)(`span`,{className:`online`,children:[(0,G.jsx)(`b`,{children:e?.active_desktops||0}),u(`台在线`)]}),(0,G.jsxs)(`span`,{className:e?.outdated_desktops?`outdated`:``,children:[(0,G.jsx)(`b`,{children:e?.outdated_desktops||0}),u(`台待更新`)]})]}),(0,G.jsx)(`div`,{className:`release-version-tracks`,children:e?.versions.map(t=>{let n=!!(t.version&&t.version===e.latest_version);return(0,G.jsxs)(`div`,{className:`release-version-track `+(n?`latest`:t.outdated?`outdated`:t.version?``:`unknown`),children:[(0,G.jsxs)(`div`,{className:`release-version-label`,children:[(0,G.jsx)(`b`,{children:t.version?`v${t.version}`:u(`未知版本`)}),(0,G.jsx)(`span`,{children:n?u(`当前 latest`):t.outdated?u(`待更新`):``})]}),(0,G.jsx)(`div`,{className:`release-version-rail`,"aria-hidden":`true`,children:(0,G.jsx)(`i`,{style:{width:`${Math.max(4,t.desktops/i*100)}%`}})}),(0,G.jsx)(`small`,{children:u(`{{desktops}} 台 · {{active}} 在线`,{desktops:t.desktops,active:t.active_desktops})})]},t.version||`unknown`)})})]}):(0,G.jsxs)(`div`,{className:`release-fleet-state`,children:[(0,G.jsx)(w,{size:18}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(`暂无桌面注册`)}),u(`桌面端连接后,版本分布会显示在这里。`)]})]})]})}function yn(){let e=Y(()=>q(`/api/desktop-updates/releases`),[]),t=Y(()=>q(`/api/admin/desktop-updates/sync`),[]),n=Y(()=>q(`/api/desktop-updates/statistics`),[]),[r,i]=(0,W.useState)(!1),[a,o]=(0,W.useState)(``),[s,c]=(0,W.useState)(``),[l,d]=(0,W.useState)(``),[p,h]=(0,W.useState)(null),[g,_]=(0,W.useState)(!1),[v,y]=(0,W.useState)(``),[b,x]=(0,W.useState)(!1),[C,w]=(0,W.useState)(``),[T,D]=(0,W.useState)(``),[k,ee]=(0,W.useState)(!1),[A,j]=(0,W.useState)([]),[te,M]=(0,W.useState)({}),[N,P]=(0,W.useState)({}),[F,I]=(0,W.useState)(!1),[L,ie]=(0,W.useState)(!1),[oe,se]=(0,W.useState)(!1),[ce,V]=(0,W.useState)(``),[le,U]=(0,W.useState)(``),[K,ue]=(0,W.useState)([]),[de,fe]=(0,W.useState)([]),pe=(0,W.useCallback)(async e=>{x(!1),d(e),_(!0),y(``),V(``),U(``),j([]),M({}),P({});try{h(await q(`/api/desktop-updates/releases/`+encodeURIComponent(e)))}catch(e){h(null),y(e instanceof Error?e.message:u(`版本读取失败`))}finally{_(!1)}},[]);(0,W.useEffect)(()=>{let e=window.setInterval(()=>{t.reload()},t.data?.outcome===`running`?2e3:3e4);return()=>window.clearInterval(e)},[t.data?.outcome,t.reload]),(0,W.useEffect)(()=>{let e=window.setInterval(()=>{n.reload()},3e4);return()=>window.clearInterval(e)},[n.reload]);let me=(t.data?.imported_versions||[]).join(`|`);(0,W.useEffect)(()=>{!me||t.data?.outcome===`running`||e.reload()},[me,t.data?.outcome,e.reload]);let he=async()=>{i(!0),o(``),c(``);try{let e=await q(`/api/admin/desktop-updates/sync`,{method:`POST`});c(e.coalesced?u(`已有同步任务正在运行,已继续跟踪。`):u(`已开始检查当前上游。`)),await t.reload()}catch(e){o(e instanceof Error?e.message:u(`启动同步失败`))}finally{i(!1)}};(0,W.useEffect)(()=>{if(!e.data||b||l)return;let t=e.data.latest_version||e.data.releases[0]?.version;t?pe(t):x(!0)},[e.data,b,l,pe]);let ge=(0,W.useMemo)(()=>e.data?.releases.find(t=>t.version===e.data?.latest_version),[e.data]),_e=ge?.platforms.join(`|`)||``;(0,W.useEffect)(()=>{let t=!0,n=e.data?.latest_version;if(!n||!ge){fe([]);return}return Promise.all(ge.platforms.map(async e=>{let[t,r]=e.split(`-`,2);try{return(await q(`/api/desktop-updates/`+t+`/`+r+`/0.0.0`)).version===n?e:``}catch{return``}})).then(e=>{t&&fe(e.filter(Boolean))}),()=>{t=!1}},[e.data?.latest_version,_e,ge]);let ve=(0,W.useMemo)(()=>p?Object.entries(p.platforms||{}).filter(([,e])=>e.file&&e.signature).map(([e])=>e).sort():[],[p]),ye=ve.join(`|`);(0,W.useEffect)(()=>{ue(ve)},[p?.version,ye]);let be=(0,W.useMemo)(()=>gn(A,te,N),[A,te,N]),xe=(0,W.useMemo)(()=>hn(A),[A]),Se=xe.length>1?u(`文件中识别到多个版本:{{versions}}`,{versions:xe.join(u(`、`))}):xe.length===1&&p&&xe[0]!==p.version?u(`文件版本 {{fileVersion}} 与当前版本 {{currentVersion}} 不一致`,{fileVersion:xe[0],currentVersion:p.version}):``,Ce=be.rows.filter(e=>e.state.status!==`success`),we=!!(Se||Ce.some(e=>e.error)),Te=async e=>{if(!e.length)return;I(!0),V(``),U(``);let t=await un(e);t.expanded.length&&j(e=>[...e,...t.expanded]),t.errors.length&&V(t.errors.join(` `)),I(!1)},Ee=e=>{j(t=>t.filter(t=>t.id!==e)),M(t=>{let n={...t};return delete n[e],n}),P(t=>{let n={...t};return delete n[e],n})},De=async e=>{if(!p||e.error)return!1;P(t=>({...t,[e.id]:{status:`uploading`}}));try{let t=new FormData;t.set(`platform`,e.platform),t.set(`kind`,e.kind),t.set(`file`,e.file.file),t.set(`signature`,e.kind===`updater`&&e.signatureFile?(await e.signatureFile.file.text()).trim():``);let n=await q(`/api/desktop-updates/releases/`+encodeURIComponent(p.version)+`/assets`,{method:`POST`,body:t});return h(n),P(t=>({...t,[e.id]:{status:`success`}})),!0}catch(t){let n=t instanceof Error?t.message:u(`上传失败`);return P(t=>({...t,[e.id]:{status:`error`,error:n}})),!1}},Oe=async()=>{if(!p||we||!Ce.length)return;ie(!0),V(``),U(``);let t=0;for(let e of Ce)await De(e)&&(t+=1);await e.reload(),ie(!1),U(t===Ce.length?u(`已上传 {{count}} 个产物。`,{count:t}):u(`已上传 {{succeeded}}/{{total}} 个产物,失败项可直接重试。`,{succeeded:t,total:Ce.length}))},ke=async t=>{t.preventDefault();let n=C.trim().replace(/^v/,``);if(!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$/.test(n)){V(u(`版本号必须是 SemVer,例如 0.3.0。`));return}ee(!0),V(``),U(``);try{await q(`/api/desktop-updates/releases`,{method:`POST`,body:JSON.stringify({version:n,notes:T})}),await e.reload(),w(``),D(``),await pe(n),U(u(`版本草稿已创建,可以上传产物。`))}catch(t){t instanceof Fe&&t.status===409?(await e.reload(),await pe(n),U(u(`这个版本已经存在,已打开原有版本且没有覆盖说明。`))):V(t instanceof Error?t.message:u(`版本创建失败`))}finally{ee(!1)}},Ae=async t=>{if(!p||!K.length)return;let n=t===`rollback`,r=K.join(u(`、`)),i=u(n?`将 latest 指向 {{version}} 的这些平台:{{platforms}}? -这不会把已经升级的客户端强制降级。`:`发布 {{version}} 到这些平台:{{platforms}}?`,{version:p.version,platforms:r});if(confirm(i)){V(``),U(``);try{await q(`/api/desktop-updates/releases/`+encodeURIComponent(p.version)+`/`+t,{method:`POST`,body:JSON.stringify({platforms:K})}),await e.reload(),U(u(n?`latest 已切换到 {{version}}。`:`{{version}} 已发布。`,{version:p.version}))}catch(e){V(e instanceof Error?e.message:u(`发布失败`))}}},je=e.data?.releases||[],Me=e.data?.latest_version,Ne=(e,t)=>u(e===Me?`当前 latest`:t?`曾发布`:`草稿`),Pe=Me?u(`v{{version}} 正在投放`,{version:Me}):u(`还没有桌面更新`),J=t.data,Ie=J?.outcome===`running`,Le=J?.bytes_total?Math.min(100,Math.round((J.bytes_downloaded||0)/J.bytes_total*100)):0,Re=J?.outcome?u({succeeded:`同步成功`,not_modified:`上游没有变化`,no_updates:`没有更高版本`,conflict:`版本存在冲突`,rate_limited:`上游请求受限`,failed:`同步失败`,interrupted:`同步已中断`,running:`同步进行中`,idle:`等待检测`}[J.outcome]||J.outcome):u(`状态未知`),ze=!!(J?.enabled&&J?.ready&&J?.source),Be=J?.source?.source_id?`?section=settings&group=desktop_updates&source=${encodeURIComponent(J.source.source_id)}`:`?section=settings&group=desktop_updates`;return(0,G.jsxs)(`div`,{className:`release-page page-stack`,children:[(0,G.jsxs)(`section`,{className:`release-hero `+(Me?`ready`:`empty`),children:[(0,G.jsxs)(`div`,{className:`release-signal`,children:[(0,G.jsx)(ae,{size:25}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`桌面更新投放`)}),(0,G.jsx)(`h2`,{children:Pe}),!Me&&(0,G.jsx)(`p`,{children:u(`创建版本并上传签名产物后,从这里开启第一次投放。`)})]}),(0,G.jsxs)(`div`,{className:`release-hero-platforms`,children:[(0,G.jsx)(`span`,{children:u(`已投放平台`)}),(0,G.jsx)(`div`,{children:de.length?de.map(e=>(0,G.jsx)(`b`,{children:e},e)):(0,G.jsx)(`small`,{children:u(Me?`正在确认平台…`:`尚未发布`)})}),ge?.updated_at&&(0,G.jsx)(`time`,{children:new Date(ge.updated_at).toLocaleString()})]})]}),(0,G.jsx)(vn,{statistics:n.data,loading:n.loading,error:n.error,onRefresh:()=>{n.reload()}}),(0,G.jsxs)(`section`,{className:`release-sync-card `+(Ie?`running`:J?.outcome||`idle`),children:[(0,G.jsxs)(`div`,{className:`release-sync-main`,children:[(0,G.jsx)(`div`,{className:`release-sync-icon`,children:(0,G.jsx)(R,{size:20})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`上游同步`)}),(0,G.jsx)(`h3`,{children:ze?Re:u(J?.readiness===`unconfigured`?`当前上游未配置完整`:`上游同步已关闭`)}),(0,G.jsxs)(`p`,{children:[ze&&J?.source?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`code`,{children:J.source.name}),(0,G.jsxs)(`span`,{children:[` · `,J.source.provider,J.source.target?` · ${J.source.target}`:``]})]}):u(`配置一个 GitHub 或 Coworker 上游后,发布页才显示完整同步控制。`),` `,(0,G.jsx)(`a`,{href:Be,children:u(`配置上游`)})]})]})]}),ze&&(0,G.jsxs)(`div`,{className:`release-sync-facts`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J?.version?`v`+J.version:`—`}),u(`当前候选`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J?.next_run_at?new Date(J.next_run_at).toLocaleString():`—`}),u(`下次检测`)]}),J?.rate_limit?.remaining!=null&&(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J.rate_limit.remaining}),u(`剩余请求额度`)]})]}),ze&&Ie&&(0,G.jsxs)(`div`,{className:`release-sync-progress`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:J?.asset||u(`正在读取 Release…`)}),(0,G.jsx)(`b`,{children:J?.bytes_total?`${Zt(J.bytes_downloaded||0)} / ${Zt(J.bytes_total)}`:u(J?.phase||`正在检测`)})]}),(0,G.jsx)(`i`,{children:(0,G.jsx)(`em`,{style:{width:`${Le}%`}})})]}),!Ie&&J?.last_error&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:J.last_error})]}),(a||s||t.error)&&(0,G.jsxs)(`div`,{className:`notice `+(a||t.error?`error`:`success`),children:[a||t.error?(0,G.jsx)(H,{size:15}):(0,G.jsx)(S,{size:15}),(0,G.jsx)(`span`,{children:a||t.error||s})]}),(0,G.jsxs)(`div`,{className:`release-sync-footer`,children:[(0,G.jsxs)(`p`,{children:[(0,G.jsx)(B,{size:15}),u(`同步只导入本地草稿,不会修改 latest.json;请检查制品和签名后手动发布。`)]}),(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{className:`ghost`,onClick:()=>void t.reload(),disabled:t.loading,children:[(0,G.jsx)(R,{size:14}),u(`刷新状态`)]}),ze&&(0,G.jsxs)(`button`,{className:`primary`,onClick:()=>void he(),disabled:r||Ie,children:[(0,G.jsx)(E,{size:14}),u(Ie?`同步中…`:`立即同步`)]})]})]})]}),(0,G.jsxs)(`div`,{className:`release-layout`,children:[(0,G.jsxs)(`aside`,{className:`release-index`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`版本记录`)}),(0,G.jsx)(`b`,{children:u(`{{count}} 个版本`,{count:je.length})})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`刷新版本`),"aria-label":u(`刷新版本`),onClick:()=>{e.reload(),n.reload()},children:(0,G.jsx)(R,{size:14})})]}),(0,G.jsxs)(`button`,{className:`release-new `+(b?`active`:``),onClick:()=>{x(!0),d(``),h(null),j([]),V(``),U(``)},children:[(0,G.jsx)(re,{size:15}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(`新建版本`)}),(0,G.jsx)(`small`,{children:u(`准备下一次桌面更新`)})]})]}),e.loading?(0,G.jsx)(Z,{}):e.error?(0,G.jsx)(Z,{error:e.error}):(0,G.jsx)(`div`,{className:`release-trace`,children:je.length?je.map(e=>{let t=e.version===Me?`latest`:e.published?`published`:`draft`;return(0,G.jsxs)(`button`,{className:(l===e.version?`active `:``)+t,onClick:()=>void pe(e.version),children:[(0,G.jsx)(`span`,{className:`trace-node`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`span`,{className:`trace-copy`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsxs)(`b`,{children:[`v`,e.version]}),(0,G.jsx)(`em`,{children:Ne(e.version,e.published)})]}),(0,G.jsx)(`small`,{children:e.notes||u(`没有发布说明`)}),(0,G.jsx)(`span`,{className:`trace-platforms`,children:u(`{{updater}} 个自动更新包 · {{installer}} 个安装包`,{updater:e.platforms.length,installer:e.installers.length})}),(0,G.jsx)(`time`,{children:e.updated_at?new Date(e.updated_at).toLocaleDateString():`—`})]})]},e.version)}):(0,G.jsx)(`div`,{className:`release-index-empty`,children:u(`创建第一个桌面版本`)})})]}),(0,G.jsx)(`main`,{className:`release-workspace`,children:b?(0,G.jsxs)(X,{title:`创建桌面版本`,note:`只建立版本草稿;创建后在同一工作台上传 updater、installer 与签名。`,className:`release-create`,children:[(0,G.jsxs)(`form`,{onSubmit:ke,children:[(0,G.jsx)(Q,{label:`版本号`,hint:`遵循 SemVer,例如 0.3.0`,children:(0,G.jsx)(`input`,{autoFocus:!0,value:C,onChange:e=>w(e.target.value),placeholder:`0.3.0`})}),(0,G.jsx)(Q,{label:`发布说明`,hint:`会显示给检查到更新的桌面客户端`,children:(0,G.jsx)(`textarea`,{value:T,onChange:e=>D(e.target.value),placeholder:u(`这次更新解决了什么?`)})}),(0,G.jsx)(`div`,{className:`panel-actions`,children:(0,G.jsxs)(`button`,{className:`primary`,disabled:!C.trim()||k,children:[u(k?`正在创建…`:`创建版本草稿`),(0,G.jsx)(f,{size:15})]})})]}),ce&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:ce})]}),le&&(0,G.jsxs)(`div`,{className:`notice success`,children:[(0,G.jsx)(S,{size:16}),(0,G.jsx)(`span`,{children:le})]})]}):g?(0,G.jsx)(Z,{}):v?(0,G.jsx)(Z,{error:v}):p?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(X,{title:`v`+p.version,note:p.notes?(0,G.jsx)(Ve,{text:p.notes}):u(`这个版本没有发布说明。`),action:(0,G.jsx)(`span`,{className:`release-state `+(p.version===Me?`latest`:p.published?`published`:`draft`),children:Ne(p.version,p.published)}),className:`release-detail`,children:[(0,G.jsxs)(`div`,{className:`release-meta`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:p.created_at?new Date(p.created_at).toLocaleString():`—`}),u(`创建时间`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:p.updated_at?new Date(p.updated_at).toLocaleString():`—`}),u(`最近更新`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:Object.keys(p.platforms||{}).length+Object.keys(p.installers||{}).length}),u(`已存产物`)]})]}),p.source&&(0,G.jsxs)(`div`,{className:`release-provenance`,children:[(0,G.jsx)(`span`,{children:u(`同步来源`)}),(0,G.jsx)(`strong`,{children:u(qt(p.source))}),(0,G.jsxs)(`code`,{children:[p.source.repository||p.source.base_url||p.source.revision||u(`上游来源`),p.source.tag?` · ${p.source.tag}`:``]})]}),(0,G.jsxs)(`div`,{className:`release-asset-grid`,children:[(0,G.jsx)(_n,{version:p.version,title:`自动更新包`,note:`带签名,进入 latest.json`,assets:p.platforms}),(0,G.jsx)(_n,{version:p.version,title:`安装包`,note:`供首次安装或手动重装`,assets:p.installers})]})]}),(0,G.jsxs)(X,{title:`追加或替换产物`,note:`拖入 CI/发布产物 ZIP 或散文件;识别结果可在上传前修正。`,className:`release-upload-panel`,children:[(0,G.jsxs)(`label`,{className:`release-dropzone `+(oe?`dragging `:``)+(F?`busy`:``),onDragEnter:e=>{e.preventDefault(),se(!0)},onDragOver:e=>e.preventDefault(),onDragLeave:e=>{e.currentTarget===e.target&&se(!1)},onDrop:e=>{e.preventDefault(),se(!1),Te(Array.from(e.dataTransfer.files))},children:[(0,G.jsx)(E,{size:25}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(F?`正在读取产物…`:`拖入产物 ZIP 或点击选择文件`)}),(0,G.jsx)(`small`,{children:u(`自动匹配版本、平台、产物类型和同名 .sig`)})]}),(0,G.jsx)(`input`,{type:`file`,multiple:!0,accept:`.zip,.sig,.exe,.dmg,.appimage,.deb,.rpm,.msi,.gz`,disabled:F,onChange:e=>{Te(Array.from(e.target.files||[])),e.target.value=``}})]}),A.length?(0,G.jsxs)(`div`,{className:`release-queue`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(O,{size:16}),(0,G.jsx)(`span`,{children:u(`{{files}} 个文件 · {{assets}} 个产物`,{files:A.length,assets:be.rows.length})})]}),(0,G.jsx)(`button`,{className:`ghost mini`,onClick:()=>{j([]),M({}),P({})},children:u(`清空队列`)})]}),Se&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:Se})]}),(0,G.jsx)(`div`,{className:`release-queue-rows`,children:be.rows.map(t=>(0,G.jsxs)(`article`,{className:(t.error?`invalid `:``)+t.state.status,children:[(0,G.jsxs)(`div`,{className:`queue-file`,children:[(0,G.jsx)(`b`,{title:t.file.entryName,children:t.file.file.name}),(0,G.jsxs)(`small`,{children:[Zt(t.file.file.size),t.file.archiveName?` · `+t.file.archiveName:``]})]}),(0,G.jsxs)(`select`,{"aria-label":u(`{{file}} 的平台`,{file:t.file.file.name}),value:t.platform,onChange:e=>M(n=>({...n,[t.id]:{...n[t.id],platform:e.target.value}})),children:[(0,G.jsx)(`option`,{value:``,children:u(`选择平台`)}),Kt.map(e=>(0,G.jsx)(`option`,{children:e},e))]}),(0,G.jsxs)(`select`,{"aria-label":u(`{{file}} 的类型`,{file:t.file.file.name}),value:t.kind,onChange:e=>M(n=>({...n,[t.id]:{...n[t.id],kind:e.target.value}})),children:[(0,G.jsx)(`option`,{value:`updater`,children:u(`自动更新包`)}),(0,G.jsx)(`option`,{value:`installer`,children:u(`安装包`)})]}),(0,G.jsx)(`div`,{className:`queue-state`,children:t.state.status===`uploading`?u(`上传中…`):t.state.status===`success`?u(`已上传`):t.state.status===`error`?t.state.error:t.error||(t.kind===`updater`?u(`sig · {{file}}`,{file:t.signatureFile?.file.name||`—`}):u(`无需签名`))}),(0,G.jsxs)(`div`,{className:`queue-actions`,children:[t.state.status===`error`&&!t.error&&(0,G.jsx)(`button`,{className:`ghost mini`,onClick:async()=>{await De(t),await e.reload()},children:u(`重试`)}),(0,G.jsx)(`button`,{className:`danger-icon`,title:u(`移除文件`),"aria-label":u(`移除文件`),onClick:()=>Ee(t.id),children:(0,G.jsx)(m,{size:13})})]})]},t.id))}),be.orphanSignatures.length>0&&(0,G.jsxs)(`div`,{className:`orphan-signatures`,children:[(0,G.jsx)(`span`,{children:u(`未匹配签名`)}),be.orphanSignatures.map(e=>(0,G.jsxs)(`button`,{title:u(`移除未匹配签名`),onClick:()=>Ee(e.id),children:[e.file.name,(0,G.jsx)(m,{size:11})]},e.id))]}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`span`,{className:`queue-summary`,children:we?u(`先处理红色项目`):Ce.length?u(`{{count}} 个产物可上传`,{count:Ce.length}):u(`队列已完成`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:we||!Ce.length||L,onClick:()=>void Oe(),children:[(0,G.jsx)(E,{size:15}),u(L?`正在上传…`:`上传全部产物`)]})]})]}):(0,G.jsxs)(`div`,{className:`release-upload-hint`,children:[(0,G.jsx)(`span`,{children:u(`支持`)}),(0,G.jsx)(`code`,{children:`*.exe + *.sig`}),(0,G.jsx)(`code`,{children:`*.app.tar.gz + *.sig`}),(0,G.jsx)(`code`,{children:`*.AppImage + *.sig`}),(0,G.jsx)(`code`,{children:`*.dmg / *.deb`})]}),ce&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:ce})]}),le&&(0,G.jsxs)(`div`,{className:`notice success`,children:[(0,G.jsx)(S,{size:16}),(0,G.jsx)(`span`,{children:le})]})]}),(0,G.jsx)(X,{title:`投放自动更新`,note:`只发布已上传且签名完整的自动更新包;同版本可稍后补齐其他平台。`,className:`release-publish-panel`,children:ve.length?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`publish-platforms`,children:ve.map(e=>(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:K.includes(e),onChange:t=>ue(n=>t.target.checked?[...n,e]:n.filter(t=>t!==e))}),(0,G.jsx)(`i`,{children:(0,G.jsx)(S,{size:12})}),(0,G.jsx)(`span`,{children:e})]},e))}),(0,G.jsxs)(`div`,{className:`publish-note`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:u(`回滚只会改变服务端 latest;已经安装更高版本的客户端不会自动降级。`)})]}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsxs)(`button`,{className:`primary`,disabled:!K.length,onClick:()=>void Ae(`publish`),children:[(0,G.jsx)(ae,{size:15}),p.version===Me?u(`重新发布所选平台`):u(`发布所选平台`)]}),p.published&&p.version!==Me&&(0,G.jsxs)(`button`,{className:`danger-outline`,disabled:!K.length,onClick:()=>void Ae(`rollback`),children:[(0,G.jsx)(z,{size:14}),u(`回滚到此版本`)]})]})]}):(0,G.jsxs)(`div`,{className:`release-publish-empty`,children:[(0,G.jsx)(ne,{size:22}),(0,G.jsx)(`span`,{children:u(`先上传至少一个带签名的 updater,才能发布自动更新。`)})]})})]}):(0,G.jsx)(xn,{text:`选择一个版本查看发布详情。`})})]})]})}function bn(){let e=Y(()=>q(`/api/admin/audit?limit=300`),[]),t=Y(()=>q(`/api/admin/diagnostics/tasks`),[]),[n,r]=(0,W.useState)(`audit`),[i,o]=(0,W.useState)(``),[s,c]=(0,W.useState)(``),l=e.data?.entries||[],d=new Date().toDateString(),f=l.filter(e=>new Date(e.ts).toDateString()===d).length,p=l.filter(e=>e.result!==`ok`).length,m=new Set(l.map(e=>e.source).filter(Boolean)).size,h=l.filter(e=>(!s||(s===`ok`?e.result===`ok`:e.result!==`ok`))&&(!i||JSON.stringify(e).toLowerCase().includes(i.toLowerCase()))),g=()=>n===`audit`?e.reload():t.reload();return(0,G.jsxs)(`div`,{className:`audit-workspace`,children:[(0,G.jsxs)(`section`,{className:`audit-vitals`,children:[(0,G.jsxs)(`article`,{children:[(0,G.jsx)(B,{size:17}),(0,G.jsx)(`span`,{children:u(`今日操作`)}),(0,G.jsx)(`b`,{children:f}),(0,G.jsx)(`small`,{children:u(`最近保留 {{count}} 条`,{count:l.length})})]}),(0,G.jsxs)(`article`,{className:p?`alert`:``,children:[(0,G.jsx)(H,{size:17}),(0,G.jsx)(`span`,{children:u(`异常结果`)}),(0,G.jsx)(`b`,{children:p}),(0,G.jsx)(`small`,{children:u(p?`需要检查失败记录`:`没有操作失败`)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(le,{size:17}),(0,G.jsx)(`span`,{children:u(`活跃任务`)}),(0,G.jsx)(`b`,{children:t.data?.pending??`—`}),(0,G.jsx)(`small`,{children:u(`事件循环中的等待任务`)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(j,{size:17}),(0,G.jsx)(`span`,{children:u(`操作来源`)}),(0,G.jsx)(`b`,{children:m}),(0,G.jsx)(`small`,{children:u(`不同客户端地址`)})]})]}),(0,G.jsxs)(`div`,{className:`audit-switcher`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{className:n===`audit`?`active`:``,onClick:()=>r(`audit`),children:[(0,G.jsx)(B,{size:15}),u(`操作时间线`)]}),(0,G.jsxs)(`button`,{className:n===`runtime`?`active`:``,onClick:()=>r(`runtime`),children:[(0,G.jsx)(a,{size:15}),u(`运行诊断`)]})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`刷新当前视图`),"aria-label":u(`刷新当前视图`),onClick:()=>void g(),children:(0,G.jsx)(R,{size:15})})]}),n===`audit`?(0,G.jsxs)(X,{title:`管理员操作时间线`,note:`只记录操作元数据,不包含令牌、密钥和完整正文。`,className:`audit-panel`,children:[(0,G.jsxs)(`div`,{className:`audit-filters`,children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(se,{size:14}),(0,G.jsx)(`input`,{value:i,onChange:e=>o(e.target.value),placeholder:u(`搜索操作、目标或来源`)})]}),(0,G.jsxs)(`select`,{value:s,onChange:e=>c(e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`全部结果`)}),(0,G.jsx)(`option`,{value:`ok`,children:u(`仅成功`)}),(0,G.jsx)(`option`,{value:`failed`,children:u(`仅异常`)})]}),(0,G.jsx)(`span`,{children:u(`{{count}} 条记录`,{count:h.length})})]}),e.data?h.length?(0,G.jsx)(`div`,{className:`audit-timeline`,children:h.map((e,t)=>{let n=String(e.action||`unknown`).split(`.`),r=n.shift()||`system`,i=n.join(` · `)||e.action;return(0,G.jsxs)(`article`,{className:e.result===`ok`?`ok`:`failed`,children:[(0,G.jsx)(`div`,{className:`audit-rail`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`time`,{children:[(0,G.jsx)(`b`,{children:new Date(e.ts).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})}),(0,G.jsx)(`span`,{children:new Date(e.ts).toLocaleDateString()})]}),(0,G.jsxs)(`div`,{className:`audit-event`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsx)(`span`,{children:r}),(0,G.jsx)(`b`,{children:i}),(0,G.jsx)(`i`,{children:e.result===`ok`?u(`成功`):e.result})]}),(0,G.jsx)(`code`,{children:e.target||`—`}),e.detail&&(0,G.jsx)(`p`,{children:e.detail}),(0,G.jsxs)(`footer`,{children:[u(`来源`),` `,e.source||`unknown`]})]})]},e.ts+`-`+t)})}):(0,G.jsx)(xn,{text:`没有符合当前筛选条件的审计记录。`}):(0,G.jsx)(Z,{error:e.error})]}):(0,G.jsx)(X,{title:`事件循环诊断`,note:`pending 通常表示任务正在等待消息或定时器,并不等同于故障。`,className:`runtime-diagnostics`,children:t.data?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`div`,{className:`runtime-callout`,children:[(0,G.jsx)(a,{size:20}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`{{count}} 个任务正在等待`,{count:t.data.pending})}),(0,G.jsx)(`span`,{children:u(`共采样 {{count}} 个 asyncio task;展开条目查看完整快照。`,{count:t.data.total})})]})]}),(0,G.jsx)(`div`,{className:`runtime-task-grid`,children:t.data.tasks.map((e,t)=>(0,G.jsxs)(`details`,{className:e.current?`current`:e.done?`done`:``,children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsx)(`span`,{className:`task-signal`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:e.name||`task-`+t}),(0,G.jsx)(`code`,{children:e.coro||`unknown coroutine`})]}),(0,G.jsx)(`span`,{className:`task-state`,children:e.current?u(`当前请求`):e.done?u(`已完成`):u(`等待中`)})]}),(0,G.jsxs)(`div`,{className:`task-waiting`,children:[(0,G.jsx)(`span`,{children:u(`等待位置`)}),(0,G.jsx)(`code`,{children:e.waiting_at||u(`没有 Python 栈,可能尚未开始或正在等待底层 I/O`)}),(0,G.jsx)(`pre`,{children:JSON.stringify(e,null,2)})]})]},(e.name||`task`)+`-`+t))})]}):(0,G.jsx)(Z,{error:t.error})})]})}function xn({text:e}){return(0,G.jsxs)(`div`,{className:`empty`,children:[(0,G.jsx)(U,{size:23}),(0,G.jsx)(`p`,{children:u(e)})]})}function Sn(){let{language:e}=p(),[t,n]=(0,W.useState)(!1),[r,i]=(0,W.useState)(!1),[a,o]=(0,W.useState)(null),[s,c]=(0,W.useState)({name:``,confirmation_name:``}),[l,d]=(0,W.useState)(Ne),[m,g]=(0,W.useState)(`quiet`);(0,W.useEffect)(()=>{if(!Pe()){i(!0);return}q(`/api/admin/session/verify`,{method:`POST`}).then(e=>{c(e),n(!0)}).catch(()=>sessionStorage.removeItem(`coworker-admin-token`)).finally(()=>i(!0))},[]),(0,W.useEffect)(()=>{t&&q(`/api/admin/bootstrap`).then(o).catch(()=>o({required:!1}))},[t]),(0,W.useEffect)(()=>{let e=()=>d(Ne());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,W.useEffect)(()=>{if(!t)return;let e=!0,n=async()=>{try{let t=await q(`/api/admin/overview`);if(!e)return;let n=!!t.status?.is_running;g(n?t.status?.is_sleeping?`resting`:`live`:`quiet`)}catch{e&&g(`quiet`)}};n();let r=window.setInterval(()=>void n(),3e4);return()=>{e=!1,window.clearInterval(r)}},[t]);let _=(0,W.useMemo)(()=>je.find(e=>e.id===l)||je[0],[l]),v=e=>{if(e===l)return;let t=new URL(window.location.href);e===`overview`?t.searchParams.delete(`section`):t.searchParams.set(`section`,e),window.history.pushState({},``,`${t.pathname}${t.search}${t.hash}`),d(e)},y=s.name||``,b=s.confirmation_name||``,x=u(m===`live`?`生命信号在线`:m===`resting`?`安静休息中`:`等待生命信号`);return r?t?a?a.required?(0,G.jsx)(Be,{data:a,onComplete:()=>{o({required:!1}),location.reload()}}):(0,G.jsxs)(`main`,{className:`admin-shell life-${m}`,"data-language":e,children:[(0,G.jsxs)(`aside`,{className:`admin-sidebar`,children:[(0,G.jsxs)(`a`,{className:`admin-brand`,href:`/`,children:[(0,G.jsxs)(`div`,{className:`brand-mark`,children:[(0,G.jsx)(L,{size:22}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:y||`Coworker`}),(0,G.jsx)(`span`,{children:u(`生命值守台`)})]})]}),(0,G.jsx)(`nav`,{"aria-label":u(`照看室导航`),children:Me.map(e=>(0,G.jsxs)(`div`,{className:`nav-group`,children:[(0,G.jsx)(`p`,{children:u(e)}),je.filter(t=>t.group===e).map(e=>(0,G.jsxs)(`button`,{type:`button`,className:l===e.id?`active`:``,"aria-current":l===e.id?`page`:void 0,title:u(e.description),onClick:()=>v(e.id),children:[(0,G.jsx)(e.icon,{size:18}),(0,G.jsx)(`span`,{children:u(e.label)}),(0,G.jsx)(f,{className:`nav-chevron`,size:14})]},e.id))]},e))}),(0,G.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,G.jsxs)(`span`,{className:`sidebar-presence`,children:[(0,G.jsx)(`i`,{}),x]}),(0,G.jsxs)(`button`,{type:`button`,onClick:()=>{sessionStorage.removeItem(`coworker-admin-token`),location.reload()},children:[(0,G.jsx)(F,{size:16}),(0,G.jsx)(`span`,{children:u(`退出本次值守`)})]})]})]}),(0,G.jsxs)(`section`,{className:`admin-main`,children:[(0,G.jsxs)(`header`,{className:`admin-topbar`,children:[(0,G.jsxs)(`div`,{className:`topbar-title`,children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`值守控制台`)}),(0,G.jsx)(`h1`,{children:u(_.label)}),(0,G.jsx)(`span`,{children:u(_.description)})]}),(0,G.jsxs)(`div`,{className:`topbar-actions`,children:[(0,G.jsx)(h,{}),(0,G.jsxs)(`div`,{className:`shell-life`,"aria-label":x,children:[(0,G.jsxs)(`div`,{className:`life-trace`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`i`,{}),x]})]}),(0,G.jsxs)(`a`,{href:`/`,children:[u(`查看生命体主页`),` `,(0,G.jsx)(f,{size:14})]})]})]}),(0,G.jsxs)(`div`,{className:`admin-content`,children:[l===`overview`&&(0,G.jsx)(We,{name:y}),l===`models`&&(0,G.jsx)(Ge,{}),l===`settings`&&(0,G.jsx)(mt,{}),l===`memory`&&(0,G.jsx)(bt,{coworkerName:y,confirmationName:b}),l===`runtime`&&(0,G.jsx)(yt,{confirmationName:b}),l===`identity`&&(0,G.jsx)(Vt,{onIdentity:c}),l===`content`&&(0,G.jsx)(Gt,{}),l===`releases`&&(0,G.jsx)(yn,{}),l===`audit`&&(0,G.jsx)(bn,{})]})]})]}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsx)(`main`,{className:`admin-login`,children:(0,G.jsxs)(`div`,{className:`state-box`,children:[(0,G.jsxs)(`span`,{className:`state-pulse`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(`正在读取初始化状态…`)})]})})]}):(0,G.jsx)(Ie,{onReady:e=>{c(e),n(!0)}}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsx)(`main`,{className:`admin-login`,children:(0,G.jsxs)(`div`,{className:`state-box`,children:[(0,G.jsxs)(`span`,{className:`state-pulse`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(`正在确认本地值守状态…`)})]})})]})}export{Sn as default}; \ No newline at end of file +这不会把已经升级的客户端强制降级。`:`发布 {{version}} 到这些平台:{{platforms}}?`,{version:p.version,platforms:r});if(confirm(i)){V(``),U(``);try{await q(`/api/desktop-updates/releases/`+encodeURIComponent(p.version)+`/`+t,{method:`POST`,body:JSON.stringify({platforms:K})}),await e.reload(),U(u(n?`latest 已切换到 {{version}}。`:`{{version}} 已发布。`,{version:p.version}))}catch(e){V(e instanceof Error?e.message:u(`发布失败`))}}},je=e.data?.releases||[],Me=e.data?.latest_version,Ne=(e,t)=>u(e===Me?`当前 latest`:t?`曾发布`:`草稿`),Pe=Me?u(`v{{version}} 正在投放`,{version:Me}):u(`还没有桌面更新`),J=t.data,Ie=J?.outcome===`running`,Le=J?.bytes_total?Math.min(100,Math.round((J.bytes_downloaded||0)/J.bytes_total*100)):0,Re=J?.outcome?u({succeeded:`同步成功`,not_modified:`上游没有变化`,no_updates:`没有更高版本`,conflict:`版本存在冲突`,rate_limited:`上游请求受限`,failed:`同步失败`,interrupted:`同步已中断`,running:`同步进行中`,idle:`等待检测`}[J.outcome]||J.outcome):u(`状态未知`),ze=!!(J?.enabled&&J?.ready&&J?.source),Be=J?.source?.source_id?`?section=settings&group=desktop_updates&source=${encodeURIComponent(J.source.source_id)}`:`?section=settings&group=desktop_updates`;return(0,G.jsxs)(`div`,{className:`release-page page-stack`,children:[(0,G.jsxs)(`section`,{className:`release-hero `+(Me?`ready`:`empty`),children:[(0,G.jsxs)(`div`,{className:`release-signal`,children:[(0,G.jsx)(ae,{size:25}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`桌面更新投放`)}),(0,G.jsx)(`h2`,{children:Pe}),!Me&&(0,G.jsx)(`p`,{children:u(`创建版本并上传签名产物后,从这里开启第一次投放。`)})]}),(0,G.jsxs)(`div`,{className:`release-hero-platforms`,children:[(0,G.jsx)(`span`,{children:u(`已投放平台`)}),(0,G.jsx)(`div`,{children:de.length?de.map(e=>(0,G.jsx)(`b`,{children:e},e)):(0,G.jsx)(`small`,{children:u(Me?`正在确认平台…`:`尚未发布`)})}),ge?.updated_at&&(0,G.jsx)(`time`,{children:new Date(ge.updated_at).toLocaleString()})]})]}),(0,G.jsx)(vn,{statistics:n.data,loading:n.loading,error:n.error,onRefresh:()=>{n.reload()}}),(0,G.jsxs)(`section`,{className:`release-sync-card `+(Ie?`running`:J?.outcome||`idle`),children:[(0,G.jsxs)(`div`,{className:`release-sync-main`,children:[(0,G.jsx)(`div`,{className:`release-sync-icon`,children:(0,G.jsx)(R,{size:20})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`上游同步`)}),(0,G.jsx)(`h3`,{children:ze?Re:u(J?.readiness===`unconfigured`?`当前上游未配置完整`:`上游同步已关闭`)}),(0,G.jsxs)(`p`,{children:[ze&&J?.source?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`code`,{children:J.source.name}),(0,G.jsxs)(`span`,{children:[` · `,J.source.provider,J.source.target?` · ${J.source.target}`:``]})]}):u(`配置一个 GitHub 或 Coworker 上游后,发布页才显示完整同步控制。`),` `,(0,G.jsx)(`a`,{href:Be,children:u(`配置上游`)})]})]})]}),ze&&(0,G.jsxs)(`div`,{className:`release-sync-facts`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J?.version?`v`+J.version:`—`}),u(`当前候选`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J?.next_run_at?new Date(J.next_run_at).toLocaleString():`—`}),u(`下次检测`)]}),J?.rate_limit?.remaining!=null&&(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:J.rate_limit.remaining}),u(`剩余请求额度`)]})]}),ze&&Ie&&(0,G.jsxs)(`div`,{className:`release-sync-progress`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:J?.asset||u(`正在读取 Release…`)}),(0,G.jsx)(`b`,{children:J?.bytes_total?`${Zt(J.bytes_downloaded||0)} / ${Zt(J.bytes_total)}`:u(J?.phase||`正在检测`)})]}),(0,G.jsx)(`i`,{children:(0,G.jsx)(`em`,{style:{width:`${Le}%`}})})]}),!Ie&&J?.last_error&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:J.last_error})]}),(a||s||t.error)&&(0,G.jsxs)(`div`,{className:`notice `+(a||t.error?`error`:`success`),children:[a||t.error?(0,G.jsx)(H,{size:15}):(0,G.jsx)(S,{size:15}),(0,G.jsx)(`span`,{children:a||t.error||s})]}),(0,G.jsxs)(`div`,{className:`release-sync-footer`,children:[(0,G.jsxs)(`p`,{children:[(0,G.jsx)(B,{size:15}),u(`同步只导入本地草稿,不会修改 latest.json;请检查制品和签名后手动发布。`)]}),(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{className:`ghost`,onClick:()=>void t.reload(),disabled:t.loading,children:[(0,G.jsx)(R,{size:14}),u(`刷新状态`)]}),ze&&(0,G.jsxs)(`button`,{className:`primary`,onClick:()=>void he(),disabled:r||Ie,children:[(0,G.jsx)(E,{size:14}),u(Ie?`同步中…`:`立即同步`)]})]})]})]}),(0,G.jsxs)(`div`,{className:`release-layout`,children:[(0,G.jsxs)(`aside`,{className:`release-index`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`版本记录`)}),(0,G.jsx)(`b`,{children:u(`{{count}} 个版本`,{count:je.length})})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`刷新版本`),"aria-label":u(`刷新版本`),onClick:()=>{e.reload(),n.reload()},children:(0,G.jsx)(R,{size:14})})]}),(0,G.jsxs)(`button`,{className:`release-new `+(b?`active`:``),onClick:()=>{x(!0),d(``),h(null),j([]),V(``),U(``)},children:[(0,G.jsx)(re,{size:15}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(`新建版本`)}),(0,G.jsx)(`small`,{children:u(`准备下一次桌面更新`)})]})]}),e.loading?(0,G.jsx)(Z,{}):e.error?(0,G.jsx)(Z,{error:e.error}):(0,G.jsx)(`div`,{className:`release-trace`,children:je.length?je.map(e=>{let t=e.version===Me?`latest`:e.published?`published`:`draft`;return(0,G.jsxs)(`button`,{className:(l===e.version?`active `:``)+t,onClick:()=>void pe(e.version),children:[(0,G.jsx)(`span`,{className:`trace-node`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`span`,{className:`trace-copy`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsxs)(`b`,{children:[`v`,e.version]}),(0,G.jsx)(`em`,{children:Ne(e.version,e.published)})]}),(0,G.jsx)(`small`,{children:e.notes||u(`没有发布说明`)}),(0,G.jsx)(`span`,{className:`trace-platforms`,children:u(`{{updater}} 个自动更新包 · {{installer}} 个安装包`,{updater:e.platforms.length,installer:e.installers.length})}),(0,G.jsx)(`time`,{children:e.updated_at?new Date(e.updated_at).toLocaleDateString():`—`})]})]},e.version)}):(0,G.jsx)(`div`,{className:`release-index-empty`,children:u(`创建第一个桌面版本`)})})]}),(0,G.jsx)(`main`,{className:`release-workspace`,children:b?(0,G.jsxs)(X,{title:`创建桌面版本`,note:`只建立版本草稿;创建后在同一工作台上传 updater、installer 与签名。`,className:`release-create`,children:[(0,G.jsxs)(`form`,{onSubmit:ke,children:[(0,G.jsx)(Q,{label:`版本号`,hint:`遵循 SemVer,例如 0.3.0`,children:(0,G.jsx)(`input`,{autoFocus:!0,value:C,onChange:e=>w(e.target.value),placeholder:`0.3.0`})}),(0,G.jsx)(Q,{label:`发布说明`,hint:`会显示给检查到更新的桌面客户端`,children:(0,G.jsx)(`textarea`,{value:T,onChange:e=>D(e.target.value),placeholder:u(`这次更新解决了什么?`)})}),(0,G.jsx)(`div`,{className:`panel-actions`,children:(0,G.jsxs)(`button`,{className:`primary`,disabled:!C.trim()||k,children:[u(k?`正在创建…`:`创建版本草稿`),(0,G.jsx)(f,{size:15})]})})]}),ce&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:ce})]}),le&&(0,G.jsxs)(`div`,{className:`notice success`,children:[(0,G.jsx)(S,{size:16}),(0,G.jsx)(`span`,{children:le})]})]}):g?(0,G.jsx)(Z,{}):v?(0,G.jsx)(Z,{error:v}):p?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(X,{title:`v`+p.version,note:p.notes?(0,G.jsx)(Ve,{text:p.notes}):u(`这个版本没有发布说明。`),action:(0,G.jsx)(`span`,{className:`release-state `+(p.version===Me?`latest`:p.published?`published`:`draft`),children:Ne(p.version,p.published)}),className:`release-detail`,children:[(0,G.jsxs)(`div`,{className:`release-meta`,children:[(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:p.created_at?new Date(p.created_at).toLocaleString():`—`}),u(`创建时间`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:p.updated_at?new Date(p.updated_at).toLocaleString():`—`}),u(`最近更新`)]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:Object.keys(p.platforms||{}).length+Object.keys(p.installers||{}).length}),u(`已存产物`)]})]}),p.source&&(0,G.jsxs)(`div`,{className:`release-provenance`,children:[(0,G.jsx)(`span`,{children:u(`同步来源`)}),(0,G.jsx)(`strong`,{children:u(qt(p.source))}),(0,G.jsxs)(`code`,{children:[p.source.repository||p.source.base_url||p.source.revision||u(`上游来源`),p.source.tag?` · ${p.source.tag}`:``]})]}),(0,G.jsxs)(`div`,{className:`release-asset-grid`,children:[(0,G.jsx)(_n,{version:p.version,title:`自动更新包`,note:`带签名,进入 latest.json`,assets:p.platforms}),(0,G.jsx)(_n,{version:p.version,title:`安装包`,note:`供首次安装或手动重装`,assets:p.installers})]})]}),(0,G.jsxs)(X,{title:`追加或替换产物`,note:`拖入 CI/发布产物 ZIP 或散文件;识别结果可在上传前修正。`,className:`release-upload-panel`,children:[(0,G.jsxs)(`label`,{className:`release-dropzone `+(oe?`dragging `:``)+(F?`busy`:``),onDragEnter:e=>{e.preventDefault(),se(!0)},onDragOver:e=>e.preventDefault(),onDragLeave:e=>{e.currentTarget===e.target&&se(!1)},onDrop:e=>{e.preventDefault(),se(!1),Te(Array.from(e.dataTransfer.files))},children:[(0,G.jsx)(E,{size:25}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`b`,{children:u(F?`正在读取产物…`:`拖入产物 ZIP 或点击选择文件`)}),(0,G.jsx)(`small`,{children:u(`自动匹配版本、平台、产物类型和同名 .sig`)})]}),(0,G.jsx)(`input`,{type:`file`,multiple:!0,accept:`.zip,.sig,.exe,.dmg,.appimage,.deb,.rpm,.msi,.gz`,disabled:F,onChange:e=>{Te(Array.from(e.target.files||[])),e.target.value=``}})]}),A.length?(0,G.jsxs)(`div`,{className:`release-queue`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsxs)(`div`,{children:[(0,G.jsx)(O,{size:16}),(0,G.jsx)(`span`,{children:u(`{{files}} 个文件 · {{assets}} 个产物`,{files:A.length,assets:be.rows.length})})]}),(0,G.jsx)(`button`,{className:`ghost mini`,onClick:()=>{j([]),M({}),P({})},children:u(`清空队列`)})]}),Se&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:Se})]}),(0,G.jsx)(`div`,{className:`release-queue-rows`,children:be.rows.map(t=>(0,G.jsxs)(`article`,{className:(t.error?`invalid `:``)+t.state.status,children:[(0,G.jsxs)(`div`,{className:`queue-file`,children:[(0,G.jsx)(`b`,{title:t.file.entryName,children:t.file.file.name}),(0,G.jsxs)(`small`,{children:[Zt(t.file.file.size),t.file.archiveName?` · `+t.file.archiveName:``]})]}),(0,G.jsxs)(`select`,{"aria-label":u(`{{file}} 的平台`,{file:t.file.file.name}),value:t.platform,onChange:e=>M(n=>({...n,[t.id]:{...n[t.id],platform:e.target.value}})),children:[(0,G.jsx)(`option`,{value:``,children:u(`选择平台`)}),Kt.map(e=>(0,G.jsx)(`option`,{children:e},e))]}),(0,G.jsxs)(`select`,{"aria-label":u(`{{file}} 的类型`,{file:t.file.file.name}),value:t.kind,onChange:e=>M(n=>({...n,[t.id]:{...n[t.id],kind:e.target.value}})),children:[(0,G.jsx)(`option`,{value:`updater`,children:u(`自动更新包`)}),(0,G.jsx)(`option`,{value:`installer`,children:u(`安装包`)})]}),(0,G.jsx)(`div`,{className:`queue-state`,children:t.state.status===`uploading`?u(`上传中…`):t.state.status===`success`?u(`已上传`):t.state.status===`error`?t.state.error:t.error||(t.kind===`updater`?u(`sig · {{file}}`,{file:t.signatureFile?.file.name||`—`}):u(`无需签名`))}),(0,G.jsxs)(`div`,{className:`queue-actions`,children:[t.state.status===`error`&&!t.error&&(0,G.jsx)(`button`,{className:`ghost mini`,onClick:async()=>{await De(t),await e.reload()},children:u(`重试`)}),(0,G.jsx)(`button`,{className:`danger-icon`,title:u(`移除文件`),"aria-label":u(`移除文件`),onClick:()=>Ee(t.id),children:(0,G.jsx)(m,{size:13})})]})]},t.id))}),be.orphanSignatures.length>0&&(0,G.jsxs)(`div`,{className:`orphan-signatures`,children:[(0,G.jsx)(`span`,{children:u(`未匹配签名`)}),be.orphanSignatures.map(e=>(0,G.jsxs)(`button`,{title:u(`移除未匹配签名`),onClick:()=>Ee(e.id),children:[e.file.name,(0,G.jsx)(m,{size:11})]},e.id))]}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`span`,{className:`queue-summary`,children:we?u(`先处理红色项目`):Ce.length?u(`{{count}} 个产物可上传`,{count:Ce.length}):u(`队列已完成`)}),(0,G.jsxs)(`button`,{className:`primary`,disabled:we||!Ce.length||L,onClick:()=>void Oe(),children:[(0,G.jsx)(E,{size:15}),u(L?`正在上传…`:`上传全部产物`)]})]})]}):(0,G.jsxs)(`div`,{className:`release-upload-hint`,children:[(0,G.jsx)(`span`,{children:u(`支持`)}),(0,G.jsx)(`code`,{children:`*.exe + *.sig`}),(0,G.jsx)(`code`,{children:`*.app.tar.gz + *.sig`}),(0,G.jsx)(`code`,{children:`*.AppImage + *.sig`}),(0,G.jsx)(`code`,{children:`*.dmg / *.deb`})]}),ce&&(0,G.jsxs)(`div`,{className:`notice error`,children:[(0,G.jsx)(H,{size:16}),(0,G.jsx)(`span`,{children:ce})]}),le&&(0,G.jsxs)(`div`,{className:`notice success`,children:[(0,G.jsx)(S,{size:16}),(0,G.jsx)(`span`,{children:le})]})]}),(0,G.jsx)(X,{title:`投放自动更新`,note:`只发布已上传且签名完整的自动更新包;同版本可稍后补齐其他平台。`,className:`release-publish-panel`,children:ve.length?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(`div`,{className:`publish-platforms`,children:ve.map(e=>(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`input`,{type:`checkbox`,checked:K.includes(e),onChange:t=>ue(n=>t.target.checked?[...n,e]:n.filter(t=>t!==e))}),(0,G.jsx)(`i`,{children:(0,G.jsx)(S,{size:12})}),(0,G.jsx)(`span`,{children:e})]},e))}),(0,G.jsxs)(`div`,{className:`publish-note`,children:[(0,G.jsx)(H,{size:15}),(0,G.jsx)(`span`,{children:u(`回滚只会改变服务端 latest;已经安装更高版本的客户端不会自动降级。`)})]}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsxs)(`button`,{className:`primary`,disabled:!K.length,onClick:()=>void Ae(`publish`),children:[(0,G.jsx)(ae,{size:15}),p.version===Me?u(`重新发布所选平台`):u(`发布所选平台`)]}),p.published&&p.version!==Me&&(0,G.jsxs)(`button`,{className:`danger-outline`,disabled:!K.length,onClick:()=>void Ae(`rollback`),children:[(0,G.jsx)(z,{size:14}),u(`回滚到此版本`)]})]})]}):(0,G.jsxs)(`div`,{className:`release-publish-empty`,children:[(0,G.jsx)(ne,{size:22}),(0,G.jsx)(`span`,{children:u(`先上传至少一个带签名的 updater,才能发布自动更新。`)})]})})]}):(0,G.jsx)(xn,{text:`选择一个版本查看发布详情。`})})]})]})}function bn(){let e=Y(()=>q(`/api/admin/audit?limit=300`),[]),t=Y(()=>q(`/api/admin/diagnostics/tasks`),[]),[n,r]=(0,W.useState)(`audit`),[i,o]=(0,W.useState)(``),[s,c]=(0,W.useState)(``),l=e.data?.entries||[],d=new Date().toDateString(),f=l.filter(e=>new Date(e.ts).toDateString()===d).length,p=l.filter(e=>e.result!==`ok`).length,m=new Set(l.map(e=>e.source).filter(Boolean)).size,h=l.filter(e=>(!s||(s===`ok`?e.result===`ok`:e.result!==`ok`))&&(!i||JSON.stringify(e).toLowerCase().includes(i.toLowerCase()))),g=()=>n===`audit`?e.reload():t.reload();return(0,G.jsxs)(`div`,{className:`audit-workspace`,children:[(0,G.jsxs)(`section`,{className:`audit-vitals`,children:[(0,G.jsxs)(`article`,{children:[(0,G.jsx)(B,{size:17}),(0,G.jsx)(`span`,{children:u(`今日操作`)}),(0,G.jsx)(`b`,{children:f}),(0,G.jsx)(`small`,{children:u(`最近保留 {{count}} 条`,{count:l.length})})]}),(0,G.jsxs)(`article`,{className:p?`alert`:``,children:[(0,G.jsx)(H,{size:17}),(0,G.jsx)(`span`,{children:u(`异常结果`)}),(0,G.jsx)(`b`,{children:p}),(0,G.jsx)(`small`,{children:u(p?`需要检查失败记录`:`没有操作失败`)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(le,{size:17}),(0,G.jsx)(`span`,{children:u(`活跃任务`)}),(0,G.jsx)(`b`,{children:t.data?.pending??`—`}),(0,G.jsx)(`small`,{children:u(`事件循环中的等待任务`)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(j,{size:17}),(0,G.jsx)(`span`,{children:u(`操作来源`)}),(0,G.jsx)(`b`,{children:m}),(0,G.jsx)(`small`,{children:u(`不同客户端地址`)})]})]}),(0,G.jsxs)(`div`,{className:`audit-switcher`,children:[(0,G.jsxs)(`div`,{children:[(0,G.jsxs)(`button`,{className:n===`audit`?`active`:``,onClick:()=>r(`audit`),children:[(0,G.jsx)(B,{size:15}),u(`操作时间线`)]}),(0,G.jsxs)(`button`,{className:n===`runtime`?`active`:``,onClick:()=>r(`runtime`),children:[(0,G.jsx)(a,{size:15}),u(`运行诊断`)]})]}),(0,G.jsx)(`button`,{className:`icon-btn`,title:u(`刷新当前视图`),"aria-label":u(`刷新当前视图`),onClick:()=>void g(),children:(0,G.jsx)(R,{size:15})})]}),n===`audit`?(0,G.jsxs)(X,{title:`管理员操作时间线`,note:`只记录操作元数据,不包含令牌、密钥和完整正文。`,className:`audit-panel`,children:[(0,G.jsxs)(`div`,{className:`audit-filters`,children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(se,{size:14}),(0,G.jsx)(`input`,{value:i,onChange:e=>o(e.target.value),placeholder:u(`搜索操作、目标或来源`)})]}),(0,G.jsxs)(`select`,{value:s,onChange:e=>c(e.target.value),children:[(0,G.jsx)(`option`,{value:``,children:u(`全部结果`)}),(0,G.jsx)(`option`,{value:`ok`,children:u(`仅成功`)}),(0,G.jsx)(`option`,{value:`failed`,children:u(`仅异常`)})]}),(0,G.jsx)(`span`,{children:u(`{{count}} 条记录`,{count:h.length})})]}),e.data?h.length?(0,G.jsx)(`div`,{className:`audit-timeline`,children:h.map((e,t)=>{let n=String(e.action||`unknown`).split(`.`),r=n.shift()||`system`,i=n.join(` · `)||e.action;return(0,G.jsxs)(`article`,{className:e.result===`ok`?`ok`:`failed`,children:[(0,G.jsx)(`div`,{className:`audit-rail`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`time`,{children:[(0,G.jsx)(`b`,{children:new Date(e.ts).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})}),(0,G.jsx)(`span`,{children:new Date(e.ts).toLocaleDateString()})]}),(0,G.jsxs)(`div`,{className:`audit-event`,children:[(0,G.jsxs)(`header`,{children:[(0,G.jsx)(`span`,{children:r}),(0,G.jsx)(`b`,{children:i}),(0,G.jsx)(`i`,{children:e.result===`ok`?u(`成功`):e.result})]}),(0,G.jsx)(`code`,{children:e.target||`—`}),e.detail&&(0,G.jsx)(`p`,{children:e.detail}),(0,G.jsxs)(`footer`,{children:[u(`来源`),` `,e.source||`unknown`]})]})]},e.ts+`-`+t)})}):(0,G.jsx)(xn,{text:`没有符合当前筛选条件的审计记录。`}):(0,G.jsx)(Z,{error:e.error})]}):(0,G.jsx)(X,{title:`事件循环诊断`,note:`pending 通常表示任务正在等待消息或定时器,并不等同于故障。`,className:`runtime-diagnostics`,children:t.data?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`div`,{className:`runtime-callout`,children:[(0,G.jsx)(a,{size:20}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:u(`{{count}} 个任务正在等待`,{count:t.data.pending})}),(0,G.jsx)(`span`,{children:u(`共采样 {{count}} 个 asyncio task;展开条目查看完整快照。`,{count:t.data.total})})]})]}),(0,G.jsx)(`div`,{className:`runtime-task-grid`,children:t.data.tasks.map((e,t)=>(0,G.jsxs)(`details`,{className:e.current?`current`:e.done?`done`:``,children:[(0,G.jsxs)(`summary`,{children:[(0,G.jsx)(`span`,{className:`task-signal`,children:(0,G.jsx)(`i`,{})}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:e.name||`task-`+t}),(0,G.jsx)(`code`,{children:e.coro||`unknown coroutine`})]}),(0,G.jsx)(`span`,{className:`task-state`,children:e.current?u(`当前请求`):e.done?u(`已完成`):u(`等待中`)})]}),(0,G.jsxs)(`div`,{className:`task-waiting`,children:[(0,G.jsx)(`span`,{children:u(`等待位置`)}),(0,G.jsx)(`code`,{children:e.waiting_at||u(`没有 Python 栈,可能尚未开始或正在等待底层 I/O`)}),(0,G.jsx)(`pre`,{children:JSON.stringify(e,null,2)})]})]},(e.name||`task`)+`-`+t))})]}):(0,G.jsx)(Z,{error:t.error})})]})}function xn({text:e}){return(0,G.jsxs)(`div`,{className:`empty`,children:[(0,G.jsx)(U,{size:23}),(0,G.jsx)(`p`,{children:u(e)})]})}function Sn(){let e=Y(()=>q(`/api/admin/relay`),[]),[t,n]=(0,W.useState)(``),[r,i]=(0,W.useState)(``),[a,o]=(0,W.useState)(!1),[s,c]=(0,W.useState)(``),[l,d]=(0,W.useState)(``),[f,p]=(0,W.useState)(null);(0,W.useEffect)(()=>{let t=window.setInterval(()=>void e.reload(),1e4);return()=>window.clearInterval(t)},[e.reload]),(0,W.useEffect)(()=>{e.data&&!e.data.instance_id&&(o(!1),c(``))},[e.data]);let m=async(t,n,r)=>{d(t),p(null);try{await n(),p({kind:`success`,text:u(r)}),await e.reload()}catch(e){p({kind:`error`,text:e instanceof Error?e.message:u(`操作失败`)})}finally{d(``)}},h=async(e,t)=>{await navigator.clipboard.writeText(e),p({kind:`success`,text:u(`{{label}}已复制`,{label:u(t)})})},g=async()=>{let e=await q(`/api/admin/relay/token`),t=String(e.communication_token||``);return c(t),t};if(e.loading&&!e.data)return(0,G.jsx)(Z,{error:e.error});let _=e.data||{},v=!!_.instance_id,y=_.status===`connected`;return(0,G.jsx)(`div`,{className:`page-stack relay-access`,children:(0,G.jsxs)(X,{title:`自托管 Relay`,note:`Coworker 主动建立加密出站隧道,不需要开放内网端口。`,children:[(0,G.jsxs)(`section`,{className:`relay-hero ${y?`connected`:v?`waiting`:`idle`}`,children:[(0,G.jsxs)(`div`,{className:`relay-signal`,children:[(0,G.jsx)(E,{size:26}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`span`,{children:u(`远程访问状态`)}),(0,G.jsx)(`h3`,{children:u(y?`已安全连接`:v?`正在等待连接`:`尚未配对`)}),(0,G.jsx)(`p`,{children:u(y?`桌面端可以使用下方地址连接这台 Coworker。`:v?`内置 Relay Client 会自动重连,本地访问不受影响。`:`使用 Relay 管理员创建的一次性配对码完成连接。`)})]}),(0,G.jsx)(`b`,{children:u(String(_.status||`disabled`))})]}),v?(0,G.jsxs)(G.Fragment,{children:[(0,G.jsxs)(`div`,{className:`relay-config-grid`,children:[(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`span`,{children:u(`桌面端 Base URL`)}),(0,G.jsx)(`code`,{children:String(_.public_base_url||``)}),(0,G.jsx)(`button`,{className:`ghost mini`,onClick:()=>void h(String(_.public_base_url||``),`Base URL`),children:u(`复制到剪贴板`)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`span`,{children:u(`Bearer Token`)}),(0,G.jsx)(`code`,{children:a?s:`••••••••••••••••`}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`button`,{className:`ghost mini`,disabled:l===`token`,onClick:()=>{if(a){o(!1),c(``);return}d(`token`),g().then(()=>o(!0)).catch(e=>p({kind:`error`,text:e instanceof Error?e.message:u(`操作失败`)})).finally(()=>d(``))},children:u(a?`隐藏`:`显示`)}),(0,G.jsx)(`button`,{className:`ghost mini`,disabled:l===`token`,onClick:()=>{d(`token`),(s?Promise.resolve(s):g()).then(e=>h(e,`Bearer Token`)).catch(e=>p({kind:`error`,text:e instanceof Error?e.message:u(`操作失败`)})).finally(()=>d(``))},children:u(`复制到剪贴板`)})]})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`span`,{children:u(`实例 ID`)}),(0,G.jsx)(`code`,{children:String(_.instance_id||``)})]}),(0,G.jsxs)(`article`,{children:[(0,G.jsx)(`span`,{children:u(`最后心跳`)}),(0,G.jsx)(`b`,{children:_.last_heartbeat?new Date(String(_.last_heartbeat)).toLocaleString():u(`尚无`)}),(0,G.jsx)(`small`,{children:_.latency_ms==null?``:`${_.latency_ms} ms`})]})]}),_.last_error&&(0,G.jsx)(`div`,{className:`notice error`,role:`alert`,children:String(_.last_error)}),y&&_.verifier_synced===!1&&(0,G.jsx)(`div`,{className:`notice error`,role:`alert`,children:u(`Token verifier 尚未同步;Relay 会继续使用上一版本并自动重试。`)}),(0,G.jsxs)(`div`,{className:`panel-actions`,children:[(0,G.jsx)(`button`,{className:`ghost`,disabled:!!l,onClick:()=>void m(`test`,()=>q(`/api/admin/relay/test`,{method:`POST`}),`Relay 端到端连接测试成功`),children:u(`测试远程连接`)}),(0,G.jsx)(`button`,{className:`ghost`,disabled:!!l,onClick:()=>void m(`reconnect`,()=>q(`/api/admin/relay/reconnect`,{method:`POST`}),`已请求重新连接`),children:u(`重新连接`)}),(0,G.jsx)(`button`,{className:`ghost`,disabled:!!l,onClick:()=>{window.confirm(u(`轮换 Relay 实例凭据并立即重新连接?`))&&m(`rotate-credential`,()=>q(`/api/admin/relay/rotate-credential`,{method:`POST`}),`Relay 实例凭据已轮换`)},children:u(`轮换实例凭据`)}),(0,G.jsx)(`button`,{className:`danger`,disabled:!!l,onClick:()=>{window.confirm(u(`断开 Relay 并删除本地实例凭据?Relay 上的实例仍需使用 coworker-relay 撤销。`))&&m(`disconnect`,()=>q(`/api/admin/relay`,{method:`DELETE`}),`Relay 已断开`)},children:u(`断开连接`)})]})]}):(0,G.jsxs)(`form`,{className:`relay-enroll`,onSubmit:e=>{e.preventDefault(),m(`connect`,()=>q(`/api/admin/relay/connect`,{method:`POST`,body:JSON.stringify({relay_url:t,pairing_code:r})}),`Relay 配对成功`)},children:[(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`Relay 地址`)}),(0,G.jsx)(`input`,{type:`url`,required:!0,placeholder:`https://relay.example.com`,value:t,onChange:e=>n(e.target.value)})]}),(0,G.jsxs)(`label`,{children:[(0,G.jsx)(`span`,{children:u(`一次性配对码`)}),(0,G.jsx)(`input`,{required:!0,autoComplete:`one-time-code`,value:r,onChange:e=>i(e.target.value)})]}),(0,G.jsxs)(`button`,{className:`primary`,disabled:!!l,children:[(0,G.jsx)(E,{size:15}),u(l===`connect`?`正在连接…`:`连接 Relay`)]})]}),f&&(0,G.jsx)(`div`,{className:`notice ${f.kind}`,role:f.kind===`error`?`alert`:`status`,children:f.text})]})})}function Cn(){let{language:e}=p(),[t,n]=(0,W.useState)(!1),[r,i]=(0,W.useState)(!1),[a,o]=(0,W.useState)(null),[s,c]=(0,W.useState)({name:``,confirmation_name:``}),[l,d]=(0,W.useState)(Ne),[m,g]=(0,W.useState)(`quiet`);(0,W.useEffect)(()=>{if(!Pe()){i(!0);return}q(`/api/admin/session/verify`,{method:`POST`}).then(e=>{c(e),n(!0)}).catch(()=>sessionStorage.removeItem(`coworker-admin-token`)).finally(()=>i(!0))},[]),(0,W.useEffect)(()=>{t&&q(`/api/admin/bootstrap`).then(o).catch(()=>o({required:!1}))},[t]),(0,W.useEffect)(()=>{let e=()=>d(Ne());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,W.useEffect)(()=>{if(!t)return;let e=!0,n=async()=>{try{let t=await q(`/api/admin/overview`);if(!e)return;let n=!!t.status?.is_running;g(n?t.status?.is_sleeping?`resting`:`live`:`quiet`)}catch{e&&g(`quiet`)}};n();let r=window.setInterval(()=>void n(),3e4);return()=>{e=!1,window.clearInterval(r)}},[t]);let _=(0,W.useMemo)(()=>je.find(e=>e.id===l)||je[0],[l]),v=e=>{if(e===l)return;let t=new URL(window.location.href);e===`overview`?t.searchParams.delete(`section`):t.searchParams.set(`section`,e),window.history.pushState({},``,`${t.pathname}${t.search}${t.hash}`),d(e)},y=s.name||``,b=s.confirmation_name||``,x=u(m===`live`?`生命信号在线`:m===`resting`?`安静休息中`:`等待生命信号`);return r?t?a?a.required?(0,G.jsx)(Be,{data:a,onComplete:()=>{o({required:!1}),location.reload()}}):(0,G.jsxs)(`main`,{className:`admin-shell life-${m}`,"data-language":e,children:[(0,G.jsxs)(`aside`,{className:`admin-sidebar`,children:[(0,G.jsxs)(`a`,{className:`admin-brand`,href:`/`,children:[(0,G.jsxs)(`div`,{className:`brand-mark`,children:[(0,G.jsx)(L,{size:22}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`div`,{children:[(0,G.jsx)(`b`,{children:y||`Coworker`}),(0,G.jsx)(`span`,{children:u(`生命值守台`)})]})]}),(0,G.jsx)(`nav`,{"aria-label":u(`照看室导航`),children:Me.map(e=>(0,G.jsxs)(`div`,{className:`nav-group`,children:[(0,G.jsx)(`p`,{children:u(e)}),je.filter(t=>t.group===e).map(e=>(0,G.jsxs)(`button`,{type:`button`,className:l===e.id?`active`:``,"aria-current":l===e.id?`page`:void 0,title:u(e.description),onClick:()=>v(e.id),children:[(0,G.jsx)(e.icon,{size:18}),(0,G.jsx)(`span`,{children:u(e.label)}),(0,G.jsx)(f,{className:`nav-chevron`,size:14})]},e.id))]},e))}),(0,G.jsxs)(`div`,{className:`sidebar-foot`,children:[(0,G.jsxs)(`span`,{className:`sidebar-presence`,children:[(0,G.jsx)(`i`,{}),x]}),(0,G.jsxs)(`button`,{type:`button`,onClick:()=>{sessionStorage.removeItem(`coworker-admin-token`),location.reload()},children:[(0,G.jsx)(F,{size:16}),(0,G.jsx)(`span`,{children:u(`退出本次值守`)})]})]})]}),(0,G.jsxs)(`section`,{className:`admin-main`,children:[(0,G.jsxs)(`header`,{className:`admin-topbar`,children:[(0,G.jsxs)(`div`,{className:`topbar-title`,children:[(0,G.jsx)(`p`,{className:`eyebrow`,children:u(`值守控制台`)}),(0,G.jsx)(`h1`,{children:u(_.label)}),(0,G.jsx)(`span`,{children:u(_.description)})]}),(0,G.jsxs)(`div`,{className:`topbar-actions`,children:[(0,G.jsx)(h,{}),(0,G.jsxs)(`div`,{className:`shell-life`,"aria-label":x,children:[(0,G.jsxs)(`div`,{className:`life-trace`,"aria-hidden":`true`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsxs)(`span`,{children:[(0,G.jsx)(`i`,{}),x]})]}),(0,G.jsxs)(`a`,{href:`/`,children:[u(`查看生命体主页`),` `,(0,G.jsx)(f,{size:14})]})]})]}),(0,G.jsxs)(`div`,{className:`admin-content`,children:[l===`overview`&&(0,G.jsx)(We,{name:y}),l===`models`&&(0,G.jsx)(Ge,{}),l===`settings`&&(0,G.jsx)(mt,{}),l===`memory`&&(0,G.jsx)(bt,{coworkerName:y,confirmationName:b}),l===`runtime`&&(0,G.jsx)(yt,{confirmationName:b}),l===`identity`&&(0,G.jsx)(Vt,{onIdentity:c}),l===`content`&&(0,G.jsx)(Gt,{}),l===`relay`&&(0,G.jsx)(Sn,{}),l===`releases`&&(0,G.jsx)(yn,{}),l===`audit`&&(0,G.jsx)(bn,{})]})]})]}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsx)(`main`,{className:`admin-login`,children:(0,G.jsxs)(`div`,{className:`state-box`,children:[(0,G.jsxs)(`span`,{className:`state-pulse`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(`正在读取初始化状态…`)})]})})]}):(0,G.jsx)(Ie,{onReady:e=>{c(e),n(!0)}}):(0,G.jsxs)(G.Fragment,{children:[(0,G.jsx)(h,{className:`admin-language-toggle-floating`}),(0,G.jsx)(`main`,{className:`admin-login`,children:(0,G.jsxs)(`div`,{className:`state-box`,children:[(0,G.jsxs)(`span`,{className:`state-pulse`,children:[(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{}),(0,G.jsx)(`i`,{})]}),(0,G.jsx)(`span`,{children:u(`正在确认本地值守状态…`)})]})})]})}export{Cn as default}; \ No newline at end of file diff --git a/src/coworker/web/assets/index-Di_aognP.js b/src/coworker/web/assets/index-3W-zuFpp.js similarity index 92% rename from src/coworker/web/assets/index-Di_aognP.js rename to src/coworker/web/assets/index-3W-zuFpp.js index 9da4af8..a267862 100644 --- a/src/coworker/web/assets/index-Di_aognP.js +++ b/src/coworker/web/assets/index-3W-zuFpp.js @@ -1,4 +1,4 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/AdminApp-CHWrWirI.js","assets/AdminApp-B8P4UePQ.css"])))=>i.map(i=>d[i]); +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/AdminApp-Dkmb1p-H.js","assets/AdminApp-Cv-2W5Va.css"])))=>i.map(i=>d[i]); var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function E(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ee(e,t){return E(e.type,t,e.props)}function D(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function te(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ne=/\/+/g;function re(e,t){return typeof e==`object`&&e&&e.key!=null?te(``+e.key):t.toString(36)}function ie(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ae(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ae(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+re(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(ne,`$&/`)+`/`),ae(o,r,i,``,function(e){return e})):o!=null&&(D(o)&&(o=ee(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ne,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=u()})),f=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,D());else{var t=n(l);t!==null&&re(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&re(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?D():S=!1}}}var D;if(typeof y==`function`)D=function(){y(ee)};else if(typeof MessageChannel<`u`){var te=new MessageChannel,ne=te.port2;te.port1.onmessage=ee,D=function(){ne.postMessage(null)}}else D=function(){_(ee,0)};function re(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,re(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,D()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),p=o(((e,t)=>{t.exports=f()})),m=o((e=>{var t=d();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=m()})),g=o((e=>{var t=p(),n=d(),r=h();function i(e){var t=`https://react.dev/errors/`+e;if(1ue||(e.current=le[ue],le[ue]=null,ue--)}function A(e,t){ue++,le[ue]=e.current,e.current=t}var pe=de(null),me=de(null),he=de(null),ge=de(null);function _e(e,t){switch(A(he,t),A(me,e),A(pe,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}fe(pe),A(pe,e)}function ve(){fe(pe),fe(me),fe(he)}function ye(e){e.memoizedState!==null&&A(ge,e);var t=pe.current,n=Hd(t,e.type);t!==n&&(A(me,e),A(pe,n))}function be(e){me.current===e&&(fe(pe),fe(me)),ge.current===e&&(fe(ge),Qf._currentValue=ce)}var xe,Se;function Ce(e){if(xe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);xe=t&&t[1]||``,Se=-1)`:-1`,...c.current()})),l+=c.move(`>`)):(s=n.enter(`destinationRaw`),l+=c.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...c.current()}))),s(),e.title&&(s=n.enter(`title${a}`),l+=c.move(` `+i),l+=c.move(n.safe(e.title,{before:l,after:i,...c.current()})),l+=c.move(i),s()),l+=c.move(`)`),o(),l}function fc(){return`!`}pc.peek=mc;function pc(e,t,n,r){let i=e.referenceType,a=n.enter(`imageReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`![`),l=n.safe(e.alt,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function mc(){return`!`}hc.peek=gc;function hc(e,t,n){let r=e.value||``,i="`",a=-1;for(;RegExp("(^|[^`])"+i+"([^`]|$)").test(r);)i+="`";for(/[^ \r\n]/.test(r)&&(/^[ \r\n]/.test(r)&&/[ \r\n]$/.test(r)||/^`|`$/.test(r))&&(r=` `+r+` `);++a\u007F]/.test(e.url))}vc.peek=yc;function vc(e,t,n,r){let i=ec(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.createTracker(r),s,c;if(_c(e,n)){let t=n.stack;n.stack=[],s=n.enter(`autolink`);let r=o.move(`<`);return r+=o.move(n.containerPhrasing(e,{before:r,after:`>`,...o.current()})),r+=o.move(`>`),s(),n.stack=t,r}s=n.enter(`link`),c=n.enter(`label`);let l=o.move(`[`);return l+=o.move(n.containerPhrasing(e,{before:l,after:`](`,...o.current()})),l+=o.move(`](`),c(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(c=n.enter(`destinationLiteral`),l+=o.move(`<`),l+=o.move(n.safe(e.url,{before:l,after:`>`,...o.current()})),l+=o.move(`>`)):(c=n.enter(`destinationRaw`),l+=o.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...o.current()}))),c(),e.title&&(c=n.enter(`title${a}`),l+=o.move(` `+i),l+=o.move(n.safe(e.title,{before:l,after:i,...o.current()})),l+=o.move(i),c()),l+=o.move(`)`),s(),l}function yc(e,t,n){return _c(e,n)?`<`:`[`}bc.peek=xc;function bc(e,t,n,r){let i=e.referenceType,a=n.enter(`linkReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`[`),l=n.containerPhrasing(e,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function xc(){return`[`}function Sc(e){let t=e.options.bullet||`*`;if(t!==`*`&&t!==`+`&&t!==`-`)throw Error("Cannot serialize items with `"+t+"` for `options.bullet`, expected `*`, `+`, or `-`");return t}function Cc(e){let t=Sc(e),n=e.options.bulletOther;if(!n)return t===`*`?`-`:`*`;if(n!==`*`&&n!==`+`&&n!==`-`)throw Error("Cannot serialize items with `"+n+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(n===t)throw Error("Expected `bullet` (`"+t+"`) and `bulletOther` (`"+n+"`) to be different");return n}function wc(e){let t=e.options.bulletOrdered||`.`;if(t!==`.`&&t!==`)`)throw Error("Cannot serialize items with `"+t+"` for `options.bulletOrdered`, expected `.` or `)`");return t}function Tc(e){let t=e.options.rule||`*`;if(t!==`*`&&t!==`-`&&t!==`_`)throw Error("Cannot serialize rules with `"+t+"` for `options.rule`, expected `*`, `-`, or `_`");return t}function Ec(e,t,n,r){let i=n.enter(`list`),a=n.bulletCurrent,o=e.ordered?wc(n):Sc(n),s=e.ordered?o===`.`?`)`:`.`:Cc(n),c=t&&n.bulletLastUsed?o===n.bulletLastUsed:!1;if(!e.ordered){let t=e.children?e.children[0]:void 0;if((o===`*`||o===`-`)&&t&&(!t.children||!t.children[0])&&n.stack[n.stack.length-1]===`list`&&n.stack[n.stack.length-2]===`listItem`&&n.stack[n.stack.length-3]===`list`&&n.stack[n.stack.length-4]===`listItem`&&n.indexStack[n.indexStack.length-1]===0&&n.indexStack[n.indexStack.length-2]===0&&n.indexStack[n.indexStack.length-3]===0&&(c=!0),Tc(n)===o&&t){let t=-1;for(;++t-1?t.start:1)+(n.options.incrementListMarker===!1?0:t.children.indexOf(e))+a);let o=a.length+1;(i===`tab`||i===`mixed`&&(t&&t.type===`list`&&t.spread||e.spread))&&(o=Math.ceil(o/4)*4);let s=n.createTracker(r);s.move(a+` `.repeat(o-a.length)),s.shift(o);let c=n.enter(`listItem`),l=n.indentLines(n.containerFlow(e,s.current()),u);return c(),l;function u(e,t,n){return t?(n?``:` `.repeat(o))+e:(n?a:a+` `.repeat(o-a.length))+e}}function kc(e,t,n,r){let i=n.enter(`paragraph`),a=n.enter(`phrasing`),o=n.containerPhrasing(e,r);return a(),i(),o}var Ac=Ra([`break`,`delete`,`emphasis`,`footnote`,`footnoteReference`,`image`,`imageReference`,`inlineCode`,`inlineMath`,`link`,`linkReference`,`mdxJsxTextElement`,`mdxTextExpression`,`strong`,`text`,`textDirective`]);function jc(e,t,n,r){return(e.children.some(function(e){return Ac(e)})?n.containerPhrasing:n.containerFlow).call(n,e,r)}function Mc(e){let t=e.options.strong||`*`;if(t!==`*`&&t!==`_`)throw Error("Cannot serialize strong with `"+t+"` for `options.strong`, expected `*`, or `_`");return t}Nc.peek=Pc;function Nc(e,t,n,r){let i=Mc(n),a=n.enter(`strong`),o=n.createTracker(r),s=o.move(i+i),c=o.move(n.containerPhrasing(e,{after:i,before:s,...o.current()})),l=c.charCodeAt(0),u=ic(r.before.charCodeAt(r.before.length-1),l,i);u.inside&&(c=rc(l)+c.slice(1));let d=c.charCodeAt(c.length-1),f=ic(r.after.charCodeAt(0),d,i);f.inside&&(c=c.slice(0,-1)+rc(d));let p=o.move(i+i);return a(),n.attentionEncodeSurroundingInfo={after:f.outside,before:u.outside},s+c+p}function Pc(e,t,n){return n.options.strong||`*`}function Fc(e,t,n,r){return n.safe(e.value,r)}function Ic(e){let t=e.options.ruleRepetition||3;if(t<3)throw Error("Cannot serialize rules with repetition `"+t+"` for `options.ruleRepetition`, expected `3` or more");return t}function Lc(e,t,n){let r=(Tc(n)+(n.options.ruleSpaces?` `:``)).repeat(Ic(n));return n.options.ruleSpaces?r.slice(0,-1):r}var Rc={blockquote:Ws,break:Js,code:Qs,definition:tc,emphasis:ac,hardBreak:Js,heading:cc,html:lc,image:dc,imageReference:pc,inlineCode:hc,link:vc,linkReference:bc,list:Ec,listItem:Oc,paragraph:kc,root:jc,strong:Nc,text:Fc,thematicBreak:Lc};function zc(){return{enter:{table:Bc,tableData:Wc,tableHeader:Wc,tableRow:Hc},exit:{codeText:Gc,table:Vc,tableData:Uc,tableHeader:Uc,tableRow:Uc}}}function Bc(e){let t=e._align;this.enter({type:`table`,align:t.map(function(e){return e===`none`?null:e}),children:[]},e),this.data.inTable=!0}function Vc(e){this.exit(e),this.data.inTable=void 0}function Hc(e){this.enter({type:`tableRow`,children:[]},e)}function Uc(e){this.exit(e)}function Wc(e){this.enter({type:`tableCell`,children:[]},e)}function Gc(e){let t=this.resume();this.data.inTable&&(t=t.replace(/\\([\\|])/g,Kc));let n=this.stack[this.stack.length-1];n.type,n.value=t,this.exit(e)}function Kc(e,t){return t===`|`?t:e}function qc(e){let t=e||{},n=t.tableCellPadding,r=t.tablePipeAlign,i=t.stringLength,a=n?` `:`|`;return{unsafe:[{character:`\r`,inConstruct:`tableCell`},{character:` `,inConstruct:`tableCell`},{atBreak:!0,character:`|`,after:`[ :-]`},{character:`|`,inConstruct:`tableCell`},{atBreak:!0,character:`:`,after:`-`},{atBreak:!0,character:`-`,after:`[:|-]`}],handlers:{inlineCode:f,table:o,tableCell:c,tableRow:s}};function o(e,t,n,r){return l(u(e,n,r),e.align)}function s(e,t,n,r){let i=l([d(e,n,r)]);return i.slice(0,i.indexOf(` -`))}function c(e,t,n,r){let i=n.enter(`tableCell`),o=n.enter(`phrasing`),s=n.containerPhrasing(e,{...r,before:a,after:a});return o(),i(),s}function l(e,t){return Vs(e,{align:t,alignDelimiters:r,padding:n,stringLength:i})}function u(e,t,n){let r=e.children,i=-1,a=[],o=t.enter(`table`);for(;++i0&&!n&&(e[e.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),n}var wl={tokenize:Ml,partial:!0};function Tl(){return{document:{91:{name:`gfmFootnoteDefinition`,tokenize:kl,continuation:{tokenize:Al},exit:jl}},text:{91:{name:`gfmFootnoteCall`,tokenize:Ol},93:{name:`gfmPotentialFootnoteCall`,add:`after`,tokenize:El,resolveTo:Dl}}}}function El(e,t,n){let r=this,i=r.events.length,a=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),o;for(;i--;){let e=r.events[i][1];if(e.type===`labelImage`){o=e;break}if(e.type===`gfmFootnoteCall`||e.type===`labelLink`||e.type===`label`||e.type===`image`||e.type===`link`)break}return s;function s(i){if(!o||!o._balanced)return n(i);let s=ln(r.sliceSerialize({start:o.end,end:r.now()}));return s.codePointAt(0)!==94||!a.includes(s.slice(1))?n(i):(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(i),e.exit(`gfmFootnoteCallLabelMarker`),t(i))}}function Dl(e,t){let n=e.length;for(;n--;)if(e[n][1].type===`labelImage`&&e[n][0]===`enter`){e[n][1];break}e[n+1][1].type=`data`,e[n+3][1].type=`gfmFootnoteCallLabelMarker`;let r={type:`gfmFootnoteCall`,start:Object.assign({},e[n+3][1].start),end:Object.assign({},e[e.length-1][1].end)},i={type:`gfmFootnoteCallMarker`,start:Object.assign({},e[n+3][1].end),end:Object.assign({},e[n+3][1].end)};i.end.column++,i.end.offset++,i.end._bufferIndex++;let a={type:`gfmFootnoteCallString`,start:Object.assign({},i.end),end:Object.assign({},e[e.length-1][1].start)},o={type:`chunkString`,contentType:`string`,start:Object.assign({},a.start),end:Object.assign({},a.end)},s=[e[n+1],e[n+2],[`enter`,r,t],e[n+3],e[n+4],[`enter`,i,t],[`exit`,i,t],[`enter`,a,t],[`enter`,o,t],[`exit`,o,t],[`exit`,a,t],e[e.length-2],e[e.length-1],[`exit`,r,t]];return e.splice(n,e.length-n+1,...s),e}function Ol(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a=0,o;return s;function s(t){return e.enter(`gfmFootnoteCall`),e.enter(`gfmFootnoteCallLabelMarker`),e.consume(t),e.exit(`gfmFootnoteCallLabelMarker`),c}function c(t){return t===94?(e.enter(`gfmFootnoteCallMarker`),e.consume(t),e.exit(`gfmFootnoteCallMarker`),e.enter(`gfmFootnoteCallString`),e.enter(`chunkString`).contentType=`string`,l):n(t)}function l(s){if(a>999||s===93&&!o||s===null||s===91||I(s))return n(s);if(s===93){e.exit(`chunkString`);let a=e.exit(`gfmFootnoteCallString`);return i.includes(ln(r.sliceSerialize(a)))?(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(s),e.exit(`gfmFootnoteCallLabelMarker`),e.exit(`gfmFootnoteCall`),t):n(s)}return I(s)||(o=!0),a++,e.consume(s),s===92?u:l}function u(t){return t===91||t===92||t===93?(e.consume(t),a++,l):l(t)}}function kl(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a,o=0,s;return c;function c(t){return e.enter(`gfmFootnoteDefinition`)._container=!0,e.enter(`gfmFootnoteDefinitionLabel`),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),l}function l(t){return t===94?(e.enter(`gfmFootnoteDefinitionMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionMarker`),e.enter(`gfmFootnoteDefinitionLabelString`),e.enter(`chunkString`).contentType=`string`,u):n(t)}function u(t){if(o>999||t===93&&!s||t===null||t===91||I(t))return n(t);if(t===93){e.exit(`chunkString`);let n=e.exit(`gfmFootnoteDefinitionLabelString`);return a=ln(r.sliceSerialize(n)),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),e.exit(`gfmFootnoteDefinitionLabel`),f}return I(t)||(s=!0),o++,e.consume(t),t===92?d:u}function d(t){return t===91||t===92||t===93?(e.consume(t),o++,u):u(t)}function f(t){return t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),i.includes(a)||i.push(a),R(e,p,`gfmFootnoteDefinitionWhitespace`)):n(t)}function p(e){return t(e)}}function Al(e,t,n){return e.check(Fn,t,e.attempt(wl,t,n))}function jl(e){e.exit(`gfmFootnoteDefinition`)}function Ml(e,t,n){let r=this;return R(e,i,`gfmFootnoteDefinitionIndent`,5);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`gfmFootnoteDefinitionIndent`&&i[2].sliceSerialize(i[1],!0).length===4?t(e):n(e)}}function Nl(e){let t=(e||{}).singleTilde,n={name:`strikethrough`,tokenize:i,resolveAll:r};return t??=!0,{text:{126:n},insideSpan:{null:[n]},attentionMarkers:{null:[126]}};function r(e,t){let n=-1;for(;++n1?r(a):(e.consume(a),o++,c);if(o<2&&!t)return r(a);let l=e.exit(`strikethroughSequenceTemporary`),u=Dn(a);return l._open=!u||u===2&&!!s,l._close=!s||s===2&&!!u,n(a)}}}var Pl=class{constructor(){this.map=[]}add(e,t,n){Fl(this,e,t,n)}consume(e){if(this.map.sort(function(e,t){return e[0]-t[0]}),this.map.length===0)return;let t=this.map.length,n=[];for(;t>0;)--t,n.push(e.slice(this.map[t][0]+this.map[t][1]),this.map[t][2]),e.length=this.map[t][0];n.push(e.slice()),e.length=0;let r=n.pop();for(;r;){for(let t of r)e.push(t);r=n.pop()}this.map.length=0}};function Fl(e,t,n,r){let i=0;if(!(n===0&&r.length===0)){for(;i-1;){let e=r.events[t][1].type;if(e===`lineEnding`||e===`linePrefix`)t--;else break}let i=t>-1?r.events[t][1].type:null,a=i===`tableHead`||i===`tableRow`?S:c;return a===S&&r.parser.lazy[r.now().line]?n(e):a(e)}function c(t){return e.enter(`tableHead`),e.enter(`tableRow`),l(t)}function l(e){return e===124?u(e):(o=!0,a+=1,u(e))}function u(t){return t===null?n(t):F(t)?a>1?(a=0,r.interrupt=!0,e.exit(`tableRow`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),p):n(t):L(t)?R(e,u,`whitespace`)(t):(a+=1,o&&(o=!1,i+=1),t===124?(e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),o=!0,u):(e.enter(`data`),d(t)))}function d(t){return t===null||t===124||I(t)?(e.exit(`data`),u(t)):(e.consume(t),t===92?f:d)}function f(t){return t===92||t===124?(e.consume(t),d):d(t)}function p(t){return r.interrupt=!1,r.parser.lazy[r.now().line]?n(t):(e.enter(`tableDelimiterRow`),o=!1,L(t)?R(e,m,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):m(t))}function m(t){return t===45||t===58?g(t):t===124?(o=!0,e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),h):x(t)}function h(t){return L(t)?R(e,g,`whitespace`)(t):g(t)}function g(t){return t===58?(a+=1,o=!0,e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),_):t===45?(a+=1,_(t)):t===null||F(t)?b(t):x(t)}function _(t){return t===45?(e.enter(`tableDelimiterFiller`),v(t)):x(t)}function v(t){return t===45?(e.consume(t),v):t===58?(o=!0,e.exit(`tableDelimiterFiller`),e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),y):(e.exit(`tableDelimiterFiller`),y(t))}function y(t){return L(t)?R(e,b,`whitespace`)(t):b(t)}function b(n){return n===124?m(n):n===null||F(n)?!o||i!==a?x(n):(e.exit(`tableDelimiterRow`),e.exit(`tableHead`),t(n)):x(n)}function x(e){return n(e)}function S(t){return e.enter(`tableRow`),C(t)}function C(n){return n===124?(e.enter(`tableCellDivider`),e.consume(n),e.exit(`tableCellDivider`),C):n===null||F(n)?(e.exit(`tableRow`),t(n)):L(n)?R(e,C,`whitespace`)(n):(e.enter(`data`),w(n))}function w(t){return t===null||t===124||I(t)?(e.exit(`data`),C(t)):(e.consume(t),t===92?T:w)}function T(t){return t===92||t===124?(e.consume(t),w):w(t)}}function zl(e,t){let n=-1,r=!0,i=0,a=[0,0,0,0],o=[0,0,0,0],s=!1,c=0,l,u,d,f=new Pl;for(;++nn[2]+1){let t=n[2]+1,r=n[3]-n[2]-1;e.add(t,r,[])}}e.add(n[3]+1,0,[[`exit`,o,t]])}return i!==void 0&&(a.end=Object.assign({},q(t.events,i)),e.add(i,0,[[`exit`,a,t]]),a=void 0),a}function K(e,t,n,r,i){let a=[],o=q(t.events,n);i&&(i.end=Object.assign({},o),a.push([`exit`,i,t])),r.end=Object.assign({},o),a.push([`exit`,r,t]),e.add(n+1,0,a)}function q(e,t){let n=e[t],r=n[0]===`enter`?`start`:`end`;return n[1][r]}var J={name:`tasklistCheck`,tokenize:Bl};function Y(){return{text:{91:J}}}function Bl(e,t,n){let r=this;return i;function i(t){return r.previous!==null||!r._gfmTasklistFirstContentOfListItem?n(t):(e.enter(`taskListCheck`),e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),a)}function a(t){return I(t)?(e.enter(`taskListCheckValueUnchecked`),e.consume(t),e.exit(`taskListCheckValueUnchecked`),o):t===88||t===120?(e.enter(`taskListCheckValueChecked`),e.consume(t),e.exit(`taskListCheckValueChecked`),o):n(t)}function o(t){return t===93?(e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),e.exit(`taskListCheck`),s):n(t)}function s(r){return F(r)?t(r):L(r)?e.check({tokenize:Vl},t,n)(r):n(r)}}function Vl(e,t,n){return R(e,r,`whitespace`);function r(e){return e===null?n(e):t(e)}}function Hl(e){return an([ll(),Tl(),Nl(e),Ll(),Y()])}var Ul={};function Wl(e){let t=this,n=e||Ul,r=t.data(),i=r.micromarkExtensions||=[],a=r.fromMarkdownExtensions||=[],o=r.toMarkdownExtensions||=[];i.push(Hl(n)),a.push($c()),o.push(el(n))}var Gl=l(_(),1),Kl=``.replace(/\/$/,``);function ql(e){return`${Kl}/sse/${encodeURIComponent(e)}`}async function Jl(e,t){let n=await fetch(`${Kl}${e}`,t);if(!n.ok){let e=await n.json().then(e=>e.detail||e.message).catch(()=>n.statusText);throw Error(e||`请求失败:${n.status}`)}return n.json()}function Yl(e){return Jl(`/messages`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)})}function Xl(){return Jl(`/status`)}function Zl(){return Jl(`/profile`)}function Ql(){return`${Kl}/logs/stream`}var $l=`coworker-language`,eu=`coworker-admin-language`,tu=(0,w.createContext)(null),nu={生命总览:`Overview`,"状态、上下文和当前驻留情况":`Status, context, and current presence`,记忆中心:`Memory center`,"短期上下文、长期召回与并行思考记录":`Short-term context, long-term recall, and parallel thinking records`,运行中心:`Runtime center`,"任务、闹钟、运行账本与维护":`Tasks, alarms, runtime ledger, and maintenance`,模型编排:`Model orchestration`,"主线模型、摘要与失败降级链":`Primary model, summarization, and fallback chain`,运行设置:`Runtime settings`,"连接、记忆与循环参数":`Connection, memory, and loop parameters`,身份档案:`Identity profile`,"姓名、现居地和人格":`Name, current location, and personality`,能力内容:`Capability content`,"Skill、Palace 与潜意识模式":`Skills, Palaces, and subconscious modes`,桌面发布:`Desktop releases`,"版本、签名产物与更新投放":`Versions, signed assets, and update rollout`,诊断与审计:`Diagnostics & audit`,事件循环健康与管理员操作记录:`Event-loop health and administrator activity`,观察:`Observe`,塑形:`Shape`,扩展:`Extend`,追溯:`Audit`,请求失败:`Request failed`,"正在执行…":`Working…`,"正在恢复…":`Restoring…`,加载失败:`Failed to load`,验证失败:`Verification failed`,进入照看室:`Enter care station`,"查看生命迹象,调整她的运行方式,并谨慎触碰记忆。":`Read her vitals, tune how she runs, and handle memory with care.`,本地值守:`Local supervision`,令牌仅保留在当前会话:`Token is kept only for this session`,确认照看权限:`Confirm care access`,"使用管理员令牌开启这次值守会话。":`Use an administrator token to begin this supervision session.`,管理员令牌:`Administrator token`,桌面通信令牌:`Desktop communication token`,当前使用管理员令牌:`Currently using the administrator token`,留空继续使用管理员令牌:`Leave blank to keep using the administrator token`,"输入 ADMIN__TOKEN":`Enter ADMIN__TOKEN`,"正在确认…":`Confirming…`,进入值守台:`Open console`,返回生命体主页:`Back to lifeform home`,通义千问:`Qwen`,"配置已经保存,但服务仍在重启。请稍后刷新页面。":`Configuration was saved, but the service is still restarting. Refresh in a moment.`,初始化失败:`Initialization failed`,接通她的:`Connect her`,第一束信号:`first signal`,"管理员入口已经准备好。再连接一个模型服务,Coworker 就能开始工作。":`Administrator access is ready. Connect a model service and Coworker can begin working.`,访问凭证:`Access credentials`,已安全生成并保存:`Securely generated and saved`,模型连接:`Model connection`,等待填写:`Waiting for input`,配置已写入:`Configuration saved`,唤醒运行:`Wake runtime`,正在安全重启:`Restarting safely`,完成后自动进行:`Runs automatically when ready`,正在带着新配置醒来:`Starting with the new configuration`,"页面会在服务恢复后自动进入照看室,不需要重复填写。":`The console opens automatically when the service is ready; you will not need to enter this again.`,配置第一个模型连接:`Configure the first model connection`,"这些值会写入本地管理配置,不需要创建":`These values are written to the local admin configuration; no need to create`,服务类型:`Service type`,启动模型:`Startup model`,"选择推荐模型或输入模型 ID":`Choose a recommended model or enter a model ID`,"没有匹配的推荐模型;仍可直接使用当前模型 ID。":`No recommended model matches; you can still use the current model ID.`,"单次输出 Token 上限":`Maximum output tokens`,启动模式:`Startup mode`,主动模式:`Active mode`,"空闲后会周期性自我唤醒并继续观察。":`When idle, periodically wakes itself and keeps observing.`,"Passive 模式":`Passive mode`,"空闲后只等待外部消息、闹钟或任务事件唤醒。":`When idle, waits only for external messages, alarms, or task events.`,这是推荐目录外的模型:`This model is outside the recommended catalog`,"Coworker 主模型必须支持 tool/function calling;初始化不会发起在线能力探测。":`Coworker’s primary model must support tool/function calling. Setup does not perform an online capability probe.`,"我确认该模型及当前 API 服务支持工具调用":`I confirm that this model and API service support tool calling`,只会保存到本机配置:`Saved only in local configuration`,"自定义 Base URL":`Custom base URL`,可选:`optional`,使用官方地址时留空:`Leave empty to use the official endpoint`,"给 Coworker 起个名字":`Name your Coworker`,之后也可以在身份档案中修改:`You can change this later in Identity profile`,保存并唤醒:`Save and wake`,"正在保存…":`Saving…`,"单次输出上限必须是正整数。":`Maximum output tokens must be a positive integer.`,配置保存在:`Configuration is saved in`,",API Key 不会回显到页面。":`; the API key is never shown in the page.`,"正在读取生命迹象…":`Reading vitals…`,休息中:`Resting`,等待事件:`Waiting for events`,处理事件:`Handling an event`,唤醒方式:`Wake policy`,仅由外部事件唤醒:`External events only`,无间隔持续运行:`Continuous, no idle interval`,"每 {{seconds}} 秒自唤醒":`Self-wakes every {{seconds}} seconds`,正在运行:`Running`,未运行:`Not running`,实时驻留:`Live presence`,主线模型:`Primary model`,生命循环:`Life cycle`,本次采样:`Current sample`,刷新生命迹象:`Refresh vitals`,"有配置等待重启后生效。":`Configuration changes are waiting for a restart.`,活跃任务:`Active tasks`,项总计:` total`,"运行 Bubble":`Running bubbles`,并行思考分支:`Parallel thought branches`,长期记忆:`Long-term memory`,可语义检索:`Semantically searchable`,短期上下文:`Short-term context`,个树节点:` tree nodes`,待触发闹钟:`Pending alarms`,后台守候中:`Waiting in the background`,上下文水位:`Context level`,短期消息与记忆树的当前结构:`Current shape of short-term messages and the memory tree`,消息:`Messages`,容量:`Capacity`,回溯:`Backfill`,空闲:`Idle`,进程驻留:`Process presence`,当前实例的连续运行时间:`Continuous uptime for this instance`,本轮启动时间:`Current start time`,"切换立即生效,正在执行的单次调用不会被中断。":`Changes apply immediately; calls already in progress are not interrupted.`,当前接棒者:`Current handler`,"选择 Provider":`Select provider`,"模型 ID(留空使用默认)":`Model ID (leave empty for default)`,切换模型:`Switch model`,"正在切换…":`Switching…`,切换模型失败:`Failed to switch model`,摘要与压缩:`Summarization & compression`,"控制上下文压缩时使用的模型。":`Controls the model used to compress context.`,留空时跟随主线模型:`Follows the primary model when empty`,跟随主线:`Follow primary`,"启用 Thinking":`Enable thinking`,视觉理解:`Vision`,"为纯文本主模型提供图片分析能力。":`Adds image analysis to a text-only primary model.`,关闭:`Off`,失败降级链:`Fallback chain`,"每行填写 provider 或 provider/model,按从上到下的顺序接棒。":`Enter provider or provider/model on each line; they take over from top to bottom.`,保存并热更新:`Save & hot apply`,放弃修改:`Discard changes`,立即生效:`Applies now`,"模型与 Provider":`Models & providers`,记忆系统:`Memory system`,"Agent 循环":`Agent loop`,"主动模式自唤醒间隔(秒)":`Active-mode self-wake interval (seconds)`,空闲唤醒策略:`Idle wake policy`,"Passive 模式只等待消息、闹钟或任务等外部事件;主动模式才使用自唤醒间隔。":`Passive mode waits only for external events such as messages, alarms, or tasks. Only active mode uses the self-wake interval.`,"Passive 模式忽略此间隔;sleep(0) 表示持续等待外部事件。":`Passive mode ignores this interval; sleep(0) waits indefinitely for an external event.`,"主动模式空闲后多久自行唤醒;0 表示立即进入下一轮。":`How long active mode waits before waking itself; 0 starts the next cycle immediately.`,"API 服务":`API service`,企业微信:`WeCom`,长连接热配置:`Live connection settings`,"保存后立即启用、停用或重连企业微信;切换期间可能短暂不可用,无需重启 Coworker。":`Saving immediately enables, disables, or reconnects WeCom. The channel may be briefly unavailable during the switch; Coworker does not need to restart.`,桌面更新:`Desktop updates`,本地发布目录:`Local release directory`,"启用 GitHub 上游同步":`Enable GitHub upstream sync`,上游类型:`Upstream type`,"GitHub API Base URL":`GitHub API base URL`,"上游仓库(owner/repo)":`Upstream repository (owner/repo)`,"GitHub Token":`GitHub token`,同步预发布版本:`Synchronize prereleases`,"同步 GitHub 草稿":`Synchronize GitHub drafts`,"检测间隔(秒)":`Check interval (seconds)`,服务启动时立即检测:`Check immediately on service startup`,"单个制品大小上限(字节)":`Per-asset size limit (bytes)`,"单次同步总量上限(字节)":`Per-run download limit (bytes)`,管理端:`Admin console`,运行时语言:`Runtime language`,模型与运行时语言:`Model & runtime language`,实例级运行时语言:`Instance runtime language`,"控制系统 Prompt、工具说明和系统通知;与本页界面语言相互独立。修改后需要安全重启。":`Controls system prompts, tool descriptions, and system notices. It is independent of this page’s interface language and requires a safe restart.`,"保存后需安全重启;不会自动翻译用户内容或历史数据":`Requires a safe restart after saving; user content and historical data are not translated`,"启动时使用的 Provider":`Provider used at startup`,启动时使用的模型:`Model used at startup`,单次输出上限:`Maximum output per call`,透明接管对象:`Handoff participants`,透明接管实时信道:`Handoff live transports`,泡泡接管提示:`Bubble handoff visibility`,"控制哪些对话能看到泡泡接手、代答和归还;修改后需要安全重启。":`Choose which conversations show Bubble taking over, replying, and handing control back. Changes require a safe restart.`,"支持完整 participant_id 和 glob(例如 weixin:*)。留空表示不按 participant 匹配。":`Use a full participant_id or a glob such as weixin:*. Leave empty to disable participant matching.`,"当前不按 participant 匹配":`No participant matching is configured`,添加规则:`Add rule`,删除匹配规则:`Remove match rule`,"删除匹配规则 {{item}}":`Remove match rule {{item}}`,桌面与网页聊天的实时连接:`Live desktop and web chat connections`,基于事件流的实时连接:`Live connections based on event streams`,"勾选后,这些实时信道会显示泡泡接手和归还的结构化状态。修改后需要安全重启。":`Selected live transports show structured Bubble takeover and return states. Changes require a safe restart.`,"JSON 格式无效;修正后才能保存这个字段。":`Invalid JSON. Fix this field before saving.`,"请先修正标出的 JSON 格式。":`Fix the highlighted JSON before saving.`,存在等待重启的修改:`Changes are waiting for a restart`,管理端访问已受保护:`Admin access is protected`,管理端令牌尚未配置:`Admin token is not configured`,"当前令牌已加载,仅显示尾号":`The active token is loaded; only its final digits are shown`,"。完整值不会发送到浏览器。":`. Its full value is never sent to the browser.`,请在启动环境中设置:`Set`,",然后重启 Coworker。":` in the startup environment, then restart Coworker.`,已启用:`Enabled`,未启用:`Disabled`,令牌来源:`Token source`,未配置:`Not configured`,"令牌只能通过启动配置轮换,管理页不会回显或覆盖。":`Rotate tokens through startup configuration; this page never reveals or overwrites them.`,配置覆盖文件:`Configuration override file`,"其他设置在这里持久化;管理员令牌不写入普通表单。":`Other settings persist here; the administrator token is not stored in ordinary forms.`,配置生效状态:`Configuration status`,等待安全重启:`Waiting for safe restart`,当前配置已加载:`Current configuration loaded`,"保存的修改会在下一次安全重启后生效。":`Saved changes apply after the next safe restart.`,"当前没有等待重启的管理端修改。":`No admin changes are waiting for a restart.`,浏览器会话:`Browser session`,仅当前标签会话:`Current tab only`,"令牌保存在 sessionStorage,关闭标签页后不会长期留存。":`The token stays in sessionStorage and is not retained after this tab closes.`,如何轮换管理员令牌:`How to rotate the administrator token`,修改部署环境中的:`Change`,",再执行安全重启。旧会话会在重启后失效。":` in the deployment environment, then run a safe restart. Existing sessions expire after restart.`,启动默认值与服务连接:`Startup defaults & service connections`,"这里决定 Coworker 重启时先连接哪个模型服务。运行中的模型切换、摘要模型和降级链请在“模型编排”页面调整。":`This decides which model service Coworker connects to first at startup. Change live model switching, summarization, and fallbacks in Model orchestration.`,未设置:`Not set`,"使用 Provider 默认值":`Use provider default`,"启动 Provider":`Startup provider`,个可用连接:` available connections`,启动默认值:`Startup defaults`,"只在进程启动时读取;修改后需要安全重启。":`Read only when the process starts; changes require a safe restart.`,"Provider 连接":`Provider connections`,修改后立即生效:`Applies immediately after changes`,"一个连接代表一套模型服务地址、接口协议和访问密钥。正在执行的单次调用不受影响,下一次调用使用新连接。":`A connection holds a model endpoint, protocol, and access key. Calls in progress are unaffected; the next call uses the new connection.`,添加连接:`Add connection`,配置来源彼此独立:`Configuration sources remain separate`,"中的连接只读展示;下方只编辑管理端覆盖,不会复制或接管外部密钥。":` connections are shown read-only; edit only the admin override below. External keys are not copied or taken over.`,"外部有效连接(只读)":`Active external connections (read-only)`,协议默认地址:`Protocol default endpoint`,连接名称:`Connection name`,在模型编排中引用的名称:`Name referenced by Model orchestration`,"例如 openai-work":`for example, openai-work`,接口协议:`Protocol`,"服务地址(Base URL)":`Service address (base URL)`,留空使用协议默认地址:`Leave empty to use the protocol default endpoint`,默认模型:`Default model`,调用未指定模型时使用:`Used when a call does not specify a model`,可留空:`May be empty`,当前已配置:`Configured`,尾号:`ending in`,当前未配置:`Not configured`,"输入 API Key":`Enter API key`,"移除 Provider":`Remove provider`,"还没有可用的 Provider 连接。点击“添加连接”配置模型服务。":`No provider connections are available. Select Add connection to configure a model service.`,"模型单次响应允许生成的最大 token 数":`Maximum tokens a model may generate in one response`,保存:`Save`,安全重启:`Safe restart`,读取失败:`Failed to read`,正在读取:`Reading`,正在加载:`Loading`,创建:`Create`,删除:`Delete`,编辑:`Edit`,取消:`Cancel`,重试:`Retry`,刷新:`Refresh`,搜索:`Search`,全部:`All`,成功:`Succeeded`,异常:`Failed`,来源:`Source`,时间:`Time`,内容:`Content`,状态:`Status`,运行:`Running`,等待中:`Waiting`,已完成:`Completed`,当前请求:`Current request`,操作时间线:`Activity timeline`,运行诊断:`Runtime diagnostics`,刷新当前视图:`Refresh current view`,管理员操作时间线:`Administrator activity timeline`,"只记录操作元数据,不包含令牌、密钥和完整正文。":`Records action metadata only, never tokens, keys, or full content.`,"搜索操作、目标或来源":`Search actions, targets, or sources`,全部结果:`All results`,仅成功:`Succeeded only`,仅异常:`Failed only`,条记录:` records`,"没有符合当前筛选条件的审计记录。":`No audit records match the current filter.`,事件循环诊断:`Event-loop diagnostics`,"通常表示任务正在等待消息或定时器,并不等同于故障。":`usually means a task is waiting for a message or timer; it does not necessarily indicate a failure.`,个任务正在等待:` tasks are waiting`,共采样:`Sampled`,"个 asyncio task;展开条目查看完整快照。":` asyncio tasks; expand an item for its full snapshot.`,等待位置:`Waiting at`,"没有 Python 栈,可能尚未开始或正在等待底层 I/O":`No Python stack; it may not have started or may be waiting on underlying I/O`,生命信号在线:`Life signal online`,安静休息中:`Quietly resting`,等待生命信号:`Waiting for life signal`,"正在确认本地值守状态…":`Confirming local supervision status…`,"正在读取初始化状态…":`Reading initialization status…`,生命值守台:`Life supervision console`,照看室导航:`Care station navigation`,退出本次值守:`End this session`,查看生命体主页:`View lifeform home`,长期记忆搜索:`Long-term memory search`,短期记忆:`Short-term memory`,任务管理:`Task management`,闹钟:`Alarms`,运行账本:`Runtime ledger`,应急备份:`Emergency backups`,维护舱:`Maintenance`,能力目录:`Capability directory`,发布说明:`Release notes`,创建时间:`Created`,最近更新:`Last updated`,已存产物:`Stored assets`,更新时间:`Updated`,时间未知:`Unknown time`,还没有这类产物:`No assets of this type yet`,版本读取失败:`Failed to read release version`,安装基线:`Installed fleet`,客户端版本分布:`Client version distribution`,刷新版本统计:`Refresh version statistics`,版本统计读取失败:`Failed to read version statistics`,"正在读取客户端版本…":`Reading client versions…`,暂无桌面注册:`No desktop registrations yet`,"桌面端连接后,版本分布会显示在这里。":`Version distribution appears after a desktop connects.`,台已注册桌面:` registered desktops`,台在线:` online`,台待更新:` pending updates`,未知版本:`Unknown version`,待更新:`Update pending`,"{{desktops}} 台 · {{active}} 在线":`{{desktops}} desktops · {{active}} online`,上传失败:`Upload failed`,下载失败:`Download failed`,版本创建失败:`Failed to create release`,发布失败:`Publish failed`,"当前 latest":`Current latest`,曾发布:`Published before`,草稿:`Draft`,没有发布说明:`No release notes`,创建第一个桌面版本:`Create the first desktop release`,新建版本:`New release`,准备下一次桌面更新:`Prepare the next desktop update`,创建桌面版本:`Create desktop release`,版本号:`Version`,"遵循 SemVer,例如 0.3.0":`Follow SemVer, for example 0.3.0`,会显示给检查到更新的桌面客户端:`Shown to desktop clients that find this update`,"这次更新解决了什么?":`What does this update fix?`,"正在创建…":`Creating…`,创建版本草稿:`Create release draft`,"这个版本没有发布说明。":`This release has no notes.`,"Updater 自动更新包":`Updater package`,"带签名,进入 latest.json":`Signed and included in latest.json`,"Installer 安装包":`Installer package`,供首次安装或手动重装:`For first installation or manual reinstall`,追加或替换产物:`Add or replace assets`,"拖入 GitHub artifact ZIP 或散文件;识别结果可在上传前修正。":`Drop a GitHub artifact ZIP or individual files; correct detected details before upload.`,"正在读取产物…":`Reading assets…`,"拖入 artifact ZIP 或点击选择文件":`Drop an artifact ZIP or select files`,"自动匹配版本、平台、产物类型和同名 .sig":`Automatically matches version, platform, asset type, and matching .sig file`,个文件:` files`,个产物:` assets`,清空队列:`Clear queue`,选择平台:`Select platform`,"上传中…":`Uploading…`,已上传:`Uploaded`,无需签名:`No signature needed`,移除文件:`Remove file`,未匹配签名:`Unmatched signatures`,移除未匹配签名:`Remove unmatched signature`,先处理红色项目:`Resolve the red items first`,个产物可上传:` assets ready to upload`,队列已完成:`Queue complete`,上传全部产物:`Upload all assets`,投放自动更新:`Roll out automatic updates`,"只发布已上传且签名完整的 updater;同版本可稍后补齐其他平台。":`Publish only uploaded updaters with complete signatures; other platforms can be added later.`,"回滚只会改变服务端 latest;已经安装更高版本的客户端不会自动降级。":`Rollback changes latest on the server only; clients with a newer version do not automatically downgrade.`,重新发布所选平台:`Republish selected platforms`,发布所选平台:`Publish selected platforms`,回滚到此版本:`Roll back to this version`,"先上传至少一个带签名的 updater,才能发布自动更新。":`Upload at least one signed updater before publishing automatic updates.`,"选择一个版本查看发布详情。":`Select a release to view its details.`,"GitHub 上游同步":`GitHub upstream sync`,"GitHub 上游":`GitHub upstream`,"GitHub 草稿同步":`GitHub draft sync`,"GitHub 预发布同步":`GitHub prerelease sync`,"GitHub Release 同步":`GitHub release sync`,同步来源:`Sync source`,展开完整发布说明:`Show full release notes`,收起发布说明:`Collapse release notes`,上游同步:`Upstream sync`,当前上游:`Current upstream`,关闭上游同步:`Disable upstream sync`,上游来源:`Upstream sources`,"添加 GitHub 来源":`Add GitHub source`,"添加 Coworker 来源":`Add Coworker source`,当前上游已就绪:`Current upstream is ready`,当前上游未配置完整:`Current upstream is incomplete`,上游同步已关闭:`Upstream sync is disabled`,"选择一个来源作为当前上游后,定时同步和立即同步才会运行。":`Choose a source as the current upstream before scheduled and manual sync can run.`,"可以保存多个同类型来源,但同一时间只有当前上游会运行。":`You can save multiple sources of the same type, but only the current upstream runs at a time.`,未命名来源:`Unnamed source`,备用:`Standby`,待补全:`Incomplete`,设为当前:`Set current`,上移:`Move up`,下移:`Move down`,复制:`Duplicate`,来源详情:`Source details`,备用上游:`Standby upstream`,来源名称:`Source name`,"Provider 类型":`Provider type`,"访问 Token":`Access token`,"留空保留,输入新值则替换":`Leave blank to keep; enter a new value to replace`,全局同步设置:`Global sync settings`,"这些限制和周期作用于当前活跃来源。":`These limits and intervals apply to the current active source.`,检测间隔:`Check interval`,单个制品大小上限:`Per-asset size limit`,单次同步总量上限:`Per-run download limit`,"当前 feed 已关闭":`Current feed is closed`,"设置后允许其他 Coworker 实例同步本实例已发布版本":`Set this to allow other Coworker instances to sync this instance’s published releases`,"还没有上游来源。添加 GitHub 或 Coworker 来源后,在上方选择一个作为当前上游。":`No upstream sources yet. Add a GitHub or Coworker source, then choose one as the current upstream above.`,"选择一个上游来源查看详情。":`Choose an upstream source to view details.`,"切换后保存即可立即应用,不需要重启":`Save after switching to apply immediately; no restart required.`,"切换类型会清空此来源的地址字段,并需要重新确认访问 Token":`Changing type clears this source’s address fields and requires confirming the access token again.`,"切换来源类型会清空当前地址字段和未保存 Token,继续吗?":`Changing source type clears current address fields and unsaved token input. Continue?`,"会读取该实例的 published release feed":`Reads the published release feed from that instance`,"上游来源名称不能重复。":`Upstream source names cannot be duplicated.`,"检测间隔至少为 5 分钟。":`The check interval must be at least 5 minutes.`,"下载大小上限必须大于 1 KiB。":`Download size limits must be greater than 1 KiB.`,"单次同步总量上限不能小于单个制品上限。":`The per-run download limit cannot be smaller than the per-asset limit.`,"删除当前上游“{{name}}”?同步会切换为关闭,但不会影响已导入的草稿。":`Delete current upstream “{{name}}”? Sync will be disabled, but imported drafts are not affected.`,"删除上游来源“{{name}}”?":`Delete upstream source “{{name}}”?`,"{{name}} 副本":`{{name}} copy`,"同步已关闭;已保存的上游来源会继续保留。":`Sync is disabled; saved upstream sources are retained.`,"当前上游已切换为 {{name}},保存后立即生效。":`Current upstream switched to {{name}} and applies immediately after saving.`,"当前上游配置已更新,保存后立即生效。":`Current upstream configuration has been updated and applies immediately after saving.`,"备用上游来源已更新,不会影响当前同步任务。":`Standby upstream sources were updated and will not affect the current sync task.`,"同步周期和下载限制已更新,保存后立即生效。":`Sync interval and download limits were updated and apply immediately after saving.`,"桌面更新全局设置已保存。":`Desktop update global settings saved.`,保存并立即应用:`Save & apply now`,"已开始检查当前上游。":`Started checking the current upstream.`,同步成功:`Synchronization succeeded`,上游没有变化:`Upstream has not changed`,没有更高版本:`No newer release`,版本存在冲突:`Release conflict`,上游请求受限:`Upstream request limited`,同步失败:`Synchronization failed`,同步已中断:`Synchronization interrupted`,同步进行中:`Synchronizing`,等待检测:`Waiting for a check`,状态未知:`Status unknown`,"在“运行设置 → 桌面更新”中配置 GitHub-compatible API 和上游仓库。":`Configure the GitHub-compatible API and upstream repository under Runtime settings → Desktop updates.`,当前候选:`Current candidate`,下次检测:`Next check`,剩余请求额度:`Requests remaining`,"正在读取 Release…":`Reading release…`,正在检测:`Checking`,"同步只导入本地草稿,不会修改 latest.json;请检查制品和签名后手动发布。":`Synchronization imports a local draft only and does not modify latest.json. Review the assets and signatures before publishing manually.`,刷新状态:`Refresh status`,立即同步:`Synchronize now`,"同步中…":`Synchronizing…`,"已有同步任务正在运行,已继续跟踪。":`A synchronization is already running; continuing to track it.`,"已开始检查 GitHub 上游 Release。":`Started checking the upstream GitHub release.`,启动同步失败:`Failed to start synchronization`,今日操作:`Actions today`,最近保留:`Most recent`,异常结果:`Failed results`,需要检查失败记录:`Review failed records`,没有操作失败:`No failed actions`,事件循环中的等待任务:`Tasks waiting in the event loop`,操作来源:`Action sources`,不同客户端地址:`Distinct client addresses`,"。":`.`,"请求失败 {{status}}":`Request failed ({{status}})`,"{{count}} 项总计":`{{count}} total`,"{{count}} 个树节点":`{{count}} tree nodes`,"第 {{count}} 次":`Cycle {{count}}`,"跟随主线({{provider}})":`Follow primary ({{provider}})`,"当前令牌已加载,仅显示尾号 {{last4}}。完整值不会发送到浏览器。":`The active token is loaded; only its final {{last4}} digits are shown. Its full value is never sent to the browser.`,"请在启动环境中设置 ADMIN__TOKEN,然后重启 Coworker。":`Set ADMIN__TOKEN in the startup environment, then restart Coworker.`,"当前已配置 · 尾号 {{last4}}":`Configured · ending {{last4}}`,"••••••••{{last4}}(留空保留)":`••••••••{{last4}} (leave blank to keep)`,输入新值:`Enter a new value`,保存覆盖:`Save override`,重置本页:`Reset this page`,管理端覆盖:`Admin overrides`,"这些字段不会跟随启动环境变化;可以恢复为继承配置。":`These fields do not follow startup environment changes. You can restore inherited configuration.`,恢复继承:`Restore inherited`,保存后恢复继承:`Restore inherited on save`,当前分组已同步:`Current section is in sync`,放弃本组修改:`Discard section changes`,"{{amount}} {{unit}}后":`in {{amount}} {{unit}}`,"已过 {{amount}} {{unit}}":`{{amount}} {{unit}} ago`,仅一次:`Once`,"每 {{amount}} 天":`Every {{amount}} days`,"每 {{amount}} 小时":`Every {{amount}} hours`,"每 {{amount}} 分钟":`Every {{amount}} minutes`,"每 {{amount}} 秒":`Every {{amount}} seconds`,任务:`Tasks`,运行日志:`Runtime logs`,维护:`Maintenance`,并行思考记录:`Parallel thinking records`,结构化内容:`Structured content`,"调用 {{names}}":`Calls {{names}}`,"、":`, `,无可预览内容:`No preview available`," · 输入 {{input}} / 输出 {{output}} token":` · {{input}} input / {{output}} output tokens`,固定:`Pinned`,"{{count}} 个工具调用":`{{count}} tool calls`,思考:`Reasoning`,工具调用:`Tool calls`,已返回:`Returned`,等待结果:`Awaiting result`,参数:`Arguments`,结果:`Result`,尚未返回结果:`No result yet`,"召回长期记忆:":`Recalled long-term memories:`,"工具调用 ID:":`Tool call ID:`,"{{count}} 条消息":`{{count}} messages`,精确摘要计数:`Exact summary count`,估算摘要计数:`Estimated summary count`,原文可达:`Raw text available`,仅摘要:`Summary only`,当前读取:`Current reading`,记忆树回溯已开始:`Memory-tree backfill started`,回溯启动失败:`Could not start backfill`,固定上下文已添加:`Pinned context added`,固定上下文添加失败:`Could not add pinned context`,"删除固定上下文“{{label}}”?":`Delete pinned context “{{label}}”?`,固定上下文已删除:`Pinned context deleted`,固定上下文删除失败:`Could not delete pinned context`,最近一次模型输入:`most recent model input`,"Provider 精确值":`Exact provider value`,本地估算值:`Local estimate`,采样模型:`Sampled model`,当前短期估算:`Current short-term estimate`,"消息 / 脊柱 / 固定项":`Messages / spine / pinned`,"精确值包含系统提示与工具定义;短期估算用于判断压缩水位。":`The exact value includes the system prompt and tool definitions; the short-term estimate guides compression.`,刷新短期记忆:`Refresh short-term memory`,记忆脊柱:`Memory spine`,"越老的记忆层级越高;展开节点可向下查看保留的细节。":`Older memories sit higher in the hierarchy; expand a node to inspect retained detail.`,"记忆树还是空的;上下文压缩后会在这里形成时间脊柱。":`The memory tree is empty. Context compression will form a time spine here.`,当前消息尾部:`Current message tail`,"这些消息会按顺序直接进入下一次主线思考。":`These messages enter the next primary thought in order.`,"当前没有短期消息;新的输入会从这里开始累积。":`There are no short-term messages yet; new input will begin accumulating here.`,固定上下文:`Pinned context`,"固定项会在缺失时重新注入主线,避免关键资料被压缩带走。":`Pinned items are re-injected into the primary thread when absent, keeping essential context from being compressed away.`,"标题,例如:项目约定":`Title, for example: project conventions`,需要始终保留在上下文里的内容:`Content that should always stay in context`,"添加中…":`Adding…`,添加固定项:`Add pinned item`,删除固定上下文:`Delete pinned context`,"删除固定上下文 {{label}}":`Delete pinned context {{label}}`,"跟随文件:":`Linked file:`,"当前没有固定上下文;可以从上方添加。":`There is no pinned context yet; add one above.`,记忆维护:`Memory maintenance`,"压缩会调用模型;回溯在后台从持久日志重建时间脊柱。":`Compression calls a model; backfill rebuilds the time spine from persistent logs in the background.`,全量压缩短期记忆:`Compress all short-term memory`,"把当前主线消息压缩进记忆树,释放上下文空间。执行期间会产生模型调用。":`Compress current primary-thread messages into the memory tree to free context. This invokes a model.`,开始压缩:`Start compression`,回溯记忆树:`Backfill memory tree`,"正在重建:{{done}}/{{total}}":`Rebuilding: {{done}}/{{total}}`,"从持久日志后台重建多尺度记忆树,不阻塞主循环。":`Rebuild the multi-scale memory tree from persistent logs without blocking the primary loop.`,最多回溯叶子数:`Maximum leaves to backfill`,"回溯中…":`Backfilling…`,开始回溯:`Start backfill`,任务板:`Task board`,"任务说明与执行细节会和 Coworker 的 task 工具实时共享。":`Task descriptions and execution details are shared live with Coworker’s task tool.`,"进行中 {{count}}":`Active {{count}}`,"已完成 {{count}}":`Completed {{count}}`,"全部 {{count}}":`All {{count}}`,"要完成什么?":`What needs to be done?`,"补充执行细节(可选)":`Add execution details (optional)`,添加任务:`Add task`,刷新任务:`Refresh tasks`,"更新于 {{time}}":`Updated {{time}}`,"更新任务“{{description}}”的状态":`Update status for task “{{description}}”`,编辑任务:`Edit task`,删除任务:`Delete task`,"删除任务“{{description}}”":`Delete task “{{description}}”`,"删除任务“{{description}}”?":`Delete task “{{description}}”?`,"这个分类里没有任务。":`There are no tasks in this category.`,"还没有任务,先写下第一件要推进的事。":`There are no tasks yet. Write down the first thing to move forward.`,任务描述:`Task description`,执行细节:`Execution details`,"记录计划、进度或下一步":`Record the plan, progress, or next step`,保存任务:`Save task`,更多历史记录加载失败:`Could not load more history`,"查看主动 Bubble 和潜意识已落盘的完整思考轨迹。":`Inspect complete persisted thought traces from active Bubbles and the subconscious.`,"主动 Bubble":`Active Bubbles`,潜意识:`Subconscious`,刷新思考记录:`Refresh thinking records`,"当前没有 Bubble 记录。":`There are no Bubble records.`,"当前没有潜意识记录。":`There are no subconscious records.`,"加载中…":`Loading…`,"加载更多(已显示 {{shown}}/{{total}})":`Load more (showing {{shown}}/{{total}})`,运行中:`Running`,完成:`Done`,失败:`Failed`,已取消:`Cancelled`,超时:`Timed out`,"第 {{count}} 轮开始{{mode}}":`Round {{count}} started{{mode}}`,"(快速模式)":` (fast mode)`,"并行思考结束\n状态:{{status}}\n目标:{{goal}}":`Parallel thinking ended +`))}function c(e,t,n,r){let i=n.enter(`tableCell`),o=n.enter(`phrasing`),s=n.containerPhrasing(e,{...r,before:a,after:a});return o(),i(),s}function l(e,t){return Vs(e,{align:t,alignDelimiters:r,padding:n,stringLength:i})}function u(e,t,n){let r=e.children,i=-1,a=[],o=t.enter(`table`);for(;++i0&&!n&&(e[e.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),n}var wl={tokenize:Ml,partial:!0};function Tl(){return{document:{91:{name:`gfmFootnoteDefinition`,tokenize:kl,continuation:{tokenize:Al},exit:jl}},text:{91:{name:`gfmFootnoteCall`,tokenize:Ol},93:{name:`gfmPotentialFootnoteCall`,add:`after`,tokenize:El,resolveTo:Dl}}}}function El(e,t,n){let r=this,i=r.events.length,a=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),o;for(;i--;){let e=r.events[i][1];if(e.type===`labelImage`){o=e;break}if(e.type===`gfmFootnoteCall`||e.type===`labelLink`||e.type===`label`||e.type===`image`||e.type===`link`)break}return s;function s(i){if(!o||!o._balanced)return n(i);let s=ln(r.sliceSerialize({start:o.end,end:r.now()}));return s.codePointAt(0)!==94||!a.includes(s.slice(1))?n(i):(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(i),e.exit(`gfmFootnoteCallLabelMarker`),t(i))}}function Dl(e,t){let n=e.length;for(;n--;)if(e[n][1].type===`labelImage`&&e[n][0]===`enter`){e[n][1];break}e[n+1][1].type=`data`,e[n+3][1].type=`gfmFootnoteCallLabelMarker`;let r={type:`gfmFootnoteCall`,start:Object.assign({},e[n+3][1].start),end:Object.assign({},e[e.length-1][1].end)},i={type:`gfmFootnoteCallMarker`,start:Object.assign({},e[n+3][1].end),end:Object.assign({},e[n+3][1].end)};i.end.column++,i.end.offset++,i.end._bufferIndex++;let a={type:`gfmFootnoteCallString`,start:Object.assign({},i.end),end:Object.assign({},e[e.length-1][1].start)},o={type:`chunkString`,contentType:`string`,start:Object.assign({},a.start),end:Object.assign({},a.end)},s=[e[n+1],e[n+2],[`enter`,r,t],e[n+3],e[n+4],[`enter`,i,t],[`exit`,i,t],[`enter`,a,t],[`enter`,o,t],[`exit`,o,t],[`exit`,a,t],e[e.length-2],e[e.length-1],[`exit`,r,t]];return e.splice(n,e.length-n+1,...s),e}function Ol(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a=0,o;return s;function s(t){return e.enter(`gfmFootnoteCall`),e.enter(`gfmFootnoteCallLabelMarker`),e.consume(t),e.exit(`gfmFootnoteCallLabelMarker`),c}function c(t){return t===94?(e.enter(`gfmFootnoteCallMarker`),e.consume(t),e.exit(`gfmFootnoteCallMarker`),e.enter(`gfmFootnoteCallString`),e.enter(`chunkString`).contentType=`string`,l):n(t)}function l(s){if(a>999||s===93&&!o||s===null||s===91||I(s))return n(s);if(s===93){e.exit(`chunkString`);let a=e.exit(`gfmFootnoteCallString`);return i.includes(ln(r.sliceSerialize(a)))?(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(s),e.exit(`gfmFootnoteCallLabelMarker`),e.exit(`gfmFootnoteCall`),t):n(s)}return I(s)||(o=!0),a++,e.consume(s),s===92?u:l}function u(t){return t===91||t===92||t===93?(e.consume(t),a++,l):l(t)}}function kl(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a,o=0,s;return c;function c(t){return e.enter(`gfmFootnoteDefinition`)._container=!0,e.enter(`gfmFootnoteDefinitionLabel`),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),l}function l(t){return t===94?(e.enter(`gfmFootnoteDefinitionMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionMarker`),e.enter(`gfmFootnoteDefinitionLabelString`),e.enter(`chunkString`).contentType=`string`,u):n(t)}function u(t){if(o>999||t===93&&!s||t===null||t===91||I(t))return n(t);if(t===93){e.exit(`chunkString`);let n=e.exit(`gfmFootnoteDefinitionLabelString`);return a=ln(r.sliceSerialize(n)),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),e.exit(`gfmFootnoteDefinitionLabel`),f}return I(t)||(s=!0),o++,e.consume(t),t===92?d:u}function d(t){return t===91||t===92||t===93?(e.consume(t),o++,u):u(t)}function f(t){return t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),i.includes(a)||i.push(a),R(e,p,`gfmFootnoteDefinitionWhitespace`)):n(t)}function p(e){return t(e)}}function Al(e,t,n){return e.check(Fn,t,e.attempt(wl,t,n))}function jl(e){e.exit(`gfmFootnoteDefinition`)}function Ml(e,t,n){let r=this;return R(e,i,`gfmFootnoteDefinitionIndent`,5);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`gfmFootnoteDefinitionIndent`&&i[2].sliceSerialize(i[1],!0).length===4?t(e):n(e)}}function Nl(e){let t=(e||{}).singleTilde,n={name:`strikethrough`,tokenize:i,resolveAll:r};return t??=!0,{text:{126:n},insideSpan:{null:[n]},attentionMarkers:{null:[126]}};function r(e,t){let n=-1;for(;++n1?r(a):(e.consume(a),o++,c);if(o<2&&!t)return r(a);let l=e.exit(`strikethroughSequenceTemporary`),u=Dn(a);return l._open=!u||u===2&&!!s,l._close=!s||s===2&&!!u,n(a)}}}var Pl=class{constructor(){this.map=[]}add(e,t,n){Fl(this,e,t,n)}consume(e){if(this.map.sort(function(e,t){return e[0]-t[0]}),this.map.length===0)return;let t=this.map.length,n=[];for(;t>0;)--t,n.push(e.slice(this.map[t][0]+this.map[t][1]),this.map[t][2]),e.length=this.map[t][0];n.push(e.slice()),e.length=0;let r=n.pop();for(;r;){for(let t of r)e.push(t);r=n.pop()}this.map.length=0}};function Fl(e,t,n,r){let i=0;if(!(n===0&&r.length===0)){for(;i-1;){let e=r.events[t][1].type;if(e===`lineEnding`||e===`linePrefix`)t--;else break}let i=t>-1?r.events[t][1].type:null,a=i===`tableHead`||i===`tableRow`?S:c;return a===S&&r.parser.lazy[r.now().line]?n(e):a(e)}function c(t){return e.enter(`tableHead`),e.enter(`tableRow`),l(t)}function l(e){return e===124?u(e):(o=!0,a+=1,u(e))}function u(t){return t===null?n(t):F(t)?a>1?(a=0,r.interrupt=!0,e.exit(`tableRow`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),p):n(t):L(t)?R(e,u,`whitespace`)(t):(a+=1,o&&(o=!1,i+=1),t===124?(e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),o=!0,u):(e.enter(`data`),d(t)))}function d(t){return t===null||t===124||I(t)?(e.exit(`data`),u(t)):(e.consume(t),t===92?f:d)}function f(t){return t===92||t===124?(e.consume(t),d):d(t)}function p(t){return r.interrupt=!1,r.parser.lazy[r.now().line]?n(t):(e.enter(`tableDelimiterRow`),o=!1,L(t)?R(e,m,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):m(t))}function m(t){return t===45||t===58?g(t):t===124?(o=!0,e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),h):x(t)}function h(t){return L(t)?R(e,g,`whitespace`)(t):g(t)}function g(t){return t===58?(a+=1,o=!0,e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),_):t===45?(a+=1,_(t)):t===null||F(t)?b(t):x(t)}function _(t){return t===45?(e.enter(`tableDelimiterFiller`),v(t)):x(t)}function v(t){return t===45?(e.consume(t),v):t===58?(o=!0,e.exit(`tableDelimiterFiller`),e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),y):(e.exit(`tableDelimiterFiller`),y(t))}function y(t){return L(t)?R(e,b,`whitespace`)(t):b(t)}function b(n){return n===124?m(n):n===null||F(n)?!o||i!==a?x(n):(e.exit(`tableDelimiterRow`),e.exit(`tableHead`),t(n)):x(n)}function x(e){return n(e)}function S(t){return e.enter(`tableRow`),C(t)}function C(n){return n===124?(e.enter(`tableCellDivider`),e.consume(n),e.exit(`tableCellDivider`),C):n===null||F(n)?(e.exit(`tableRow`),t(n)):L(n)?R(e,C,`whitespace`)(n):(e.enter(`data`),w(n))}function w(t){return t===null||t===124||I(t)?(e.exit(`data`),C(t)):(e.consume(t),t===92?T:w)}function T(t){return t===92||t===124?(e.consume(t),w):w(t)}}function zl(e,t){let n=-1,r=!0,i=0,a=[0,0,0,0],o=[0,0,0,0],s=!1,c=0,l,u,d,f=new Pl;for(;++nn[2]+1){let t=n[2]+1,r=n[3]-n[2]-1;e.add(t,r,[])}}e.add(n[3]+1,0,[[`exit`,o,t]])}return i!==void 0&&(a.end=Object.assign({},q(t.events,i)),e.add(i,0,[[`exit`,a,t]]),a=void 0),a}function K(e,t,n,r,i){let a=[],o=q(t.events,n);i&&(i.end=Object.assign({},o),a.push([`exit`,i,t])),r.end=Object.assign({},o),a.push([`exit`,r,t]),e.add(n+1,0,a)}function q(e,t){let n=e[t],r=n[0]===`enter`?`start`:`end`;return n[1][r]}var J={name:`tasklistCheck`,tokenize:Bl};function Y(){return{text:{91:J}}}function Bl(e,t,n){let r=this;return i;function i(t){return r.previous!==null||!r._gfmTasklistFirstContentOfListItem?n(t):(e.enter(`taskListCheck`),e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),a)}function a(t){return I(t)?(e.enter(`taskListCheckValueUnchecked`),e.consume(t),e.exit(`taskListCheckValueUnchecked`),o):t===88||t===120?(e.enter(`taskListCheckValueChecked`),e.consume(t),e.exit(`taskListCheckValueChecked`),o):n(t)}function o(t){return t===93?(e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),e.exit(`taskListCheck`),s):n(t)}function s(r){return F(r)?t(r):L(r)?e.check({tokenize:Vl},t,n)(r):n(r)}}function Vl(e,t,n){return R(e,r,`whitespace`);function r(e){return e===null?n(e):t(e)}}function Hl(e){return an([ll(),Tl(),Nl(e),Ll(),Y()])}var Ul={};function Wl(e){let t=this,n=e||Ul,r=t.data(),i=r.micromarkExtensions||=[],a=r.fromMarkdownExtensions||=[],o=r.toMarkdownExtensions||=[];i.push(Hl(n)),a.push($c()),o.push(el(n))}var Gl=l(_(),1),Kl=``.replace(/\/$/,``);function ql(e){return`${Kl}/sse/${encodeURIComponent(e)}`}async function Jl(e,t){let n=await fetch(`${Kl}${e}`,t);if(!n.ok){let e=await n.json().then(e=>e.detail||e.message).catch(()=>n.statusText);throw Error(e||`请求失败:${n.status}`)}return n.json()}function Yl(e){return Jl(`/messages`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(e)})}function Xl(){return Jl(`/status`)}function Zl(){return Jl(`/profile`)}function Ql(){return`${Kl}/logs/stream`}var $l=`coworker-language`,eu=`coworker-admin-language`,tu=(0,w.createContext)(null),nu={生命总览:`Overview`,"状态、上下文和当前驻留情况":`Status, context, and current presence`,记忆中心:`Memory center`,"短期上下文、长期召回与并行思考记录":`Short-term context, long-term recall, and parallel thinking records`,运行中心:`Runtime center`,"任务、闹钟、运行账本与维护":`Tasks, alarms, runtime ledger, and maintenance`,模型编排:`Model orchestration`,"主线模型、摘要与失败降级链":`Primary model, summarization, and fallback chain`,运行设置:`Runtime settings`,"连接、记忆与循环参数":`Connection, memory, and loop parameters`,身份档案:`Identity profile`,"姓名、现居地和人格":`Name, current location, and personality`,能力内容:`Capability content`,"Skill、Palace 与潜意识模式":`Skills, Palaces, and subconscious modes`,远程访问:`Remote access`,"安全连接自托管 Relay":`Connect securely through a self-hosted Relay`,桌面发布:`Desktop releases`,"版本、签名产物与更新投放":`Versions, signed assets, and update rollout`,诊断与审计:`Diagnostics & audit`,事件循环健康与管理员操作记录:`Event-loop health and administrator activity`,观察:`Observe`,塑形:`Shape`,扩展:`Extend`,追溯:`Audit`,"自托管 Relay":`Self-hosted Relay`,"Coworker 主动建立加密出站隧道,不需要开放内网端口。":`Coworker opens an encrypted outbound tunnel; no inbound LAN port is required.`,远程访问状态:`Remote access status`,已安全连接:`Securely connected`,正在等待连接:`Waiting to connect`,尚未配对:`Not paired`,"桌面端可以使用下方地址连接这台 Coworker。":`Desktop can use the configuration below to connect to this Coworker.`,"内置 Relay Client 会自动重连,本地访问不受影响。":`The built-in Relay Client reconnects automatically; local access is unaffected.`,"使用 Relay 管理员创建的一次性配对码完成连接。":`Connect with a one-time pairing code created by the Relay administrator.`,disabled:`Disabled`,connecting:`Connecting`,connected:`Connected`,disconnected:`Disconnected`,"Relay 地址":`Relay URL`,一次性配对码:`One-time pairing code`,"连接 Relay":`Connect Relay`,"正在连接…":`Connecting…`,"Relay 配对成功":`Relay paired successfully`,"桌面端 Base URL":`Desktop Base URL`,"实例 ID":`Instance ID`,最后心跳:`Last heartbeat`,尚无:`None yet`,显示:`Show`,隐藏:`Hide`,复制到剪贴板:`Copy`,测试远程连接:`Test remote connection`,重新连接:`Reconnect`,轮换实例凭据:`Rotate instance credential`,"轮换 Relay 实例凭据并立即重新连接?":`Rotate the Relay instance credential and reconnect now?`,"Relay 实例凭据已轮换":`Relay instance credential rotated`,断开连接:`Disconnect`,"Relay 端到端连接测试成功":`Relay end-to-end connection test succeeded`,已请求重新连接:`Reconnect requested`,"Relay 已断开":`Relay disconnected`,"Token verifier 尚未同步;Relay 会继续使用上一版本并自动重试。":`The token verifier is not synchronized yet; Relay will keep using the previous version and retry automatically.`,"断开 Relay 并删除本地实例凭据?Relay 上的实例仍需使用 coworker-relay 撤销。":`Disconnect Relay and delete the local instance credential? The Relay instance must still be revoked with coworker-relay.`,"{{label}}已复制":`{{label}} copied`,请求失败:`Request failed`,"正在执行…":`Working…`,"正在恢复…":`Restoring…`,加载失败:`Failed to load`,验证失败:`Verification failed`,进入照看室:`Enter care station`,"查看生命迹象,调整她的运行方式,并谨慎触碰记忆。":`Read her vitals, tune how she runs, and handle memory with care.`,本地值守:`Local supervision`,令牌仅保留在当前会话:`Token is kept only for this session`,确认照看权限:`Confirm care access`,"使用管理员令牌开启这次值守会话。":`Use an administrator token to begin this supervision session.`,管理员令牌:`Administrator token`,桌面通信令牌:`Desktop communication token`,当前使用管理员令牌:`Currently using the administrator token`,留空继续使用管理员令牌:`Leave blank to keep using the administrator token`,"输入 ADMIN__TOKEN":`Enter ADMIN__TOKEN`,"正在确认…":`Confirming…`,进入值守台:`Open console`,返回生命体主页:`Back to lifeform home`,通义千问:`Qwen`,"配置已经保存,但服务仍在重启。请稍后刷新页面。":`Configuration was saved, but the service is still restarting. Refresh in a moment.`,初始化失败:`Initialization failed`,接通她的:`Connect her`,第一束信号:`first signal`,"管理员入口已经准备好。再连接一个模型服务,Coworker 就能开始工作。":`Administrator access is ready. Connect a model service and Coworker can begin working.`,访问凭证:`Access credentials`,已安全生成并保存:`Securely generated and saved`,模型连接:`Model connection`,等待填写:`Waiting for input`,配置已写入:`Configuration saved`,唤醒运行:`Wake runtime`,正在安全重启:`Restarting safely`,完成后自动进行:`Runs automatically when ready`,正在带着新配置醒来:`Starting with the new configuration`,"页面会在服务恢复后自动进入照看室,不需要重复填写。":`The console opens automatically when the service is ready; you will not need to enter this again.`,配置第一个模型连接:`Configure the first model connection`,"这些值会写入本地管理配置,不需要创建":`These values are written to the local admin configuration; no need to create`,服务类型:`Service type`,启动模型:`Startup model`,"选择推荐模型或输入模型 ID":`Choose a recommended model or enter a model ID`,"没有匹配的推荐模型;仍可直接使用当前模型 ID。":`No recommended model matches; you can still use the current model ID.`,"单次输出 Token 上限":`Maximum output tokens`,启动模式:`Startup mode`,主动模式:`Active mode`,"空闲后会周期性自我唤醒并继续观察。":`When idle, periodically wakes itself and keeps observing.`,"Passive 模式":`Passive mode`,"空闲后只等待外部消息、闹钟或任务事件唤醒。":`When idle, waits only for external messages, alarms, or task events.`,这是推荐目录外的模型:`This model is outside the recommended catalog`,"Coworker 主模型必须支持 tool/function calling;初始化不会发起在线能力探测。":`Coworker’s primary model must support tool/function calling. Setup does not perform an online capability probe.`,"我确认该模型及当前 API 服务支持工具调用":`I confirm that this model and API service support tool calling`,只会保存到本机配置:`Saved only in local configuration`,"自定义 Base URL":`Custom base URL`,可选:`optional`,使用官方地址时留空:`Leave empty to use the official endpoint`,"给 Coworker 起个名字":`Name your Coworker`,之后也可以在身份档案中修改:`You can change this later in Identity profile`,保存并唤醒:`Save and wake`,"正在保存…":`Saving…`,"单次输出上限必须是正整数。":`Maximum output tokens must be a positive integer.`,配置保存在:`Configuration is saved in`,",API Key 不会回显到页面。":`; the API key is never shown in the page.`,"正在读取生命迹象…":`Reading vitals…`,休息中:`Resting`,等待事件:`Waiting for events`,处理事件:`Handling an event`,唤醒方式:`Wake policy`,仅由外部事件唤醒:`External events only`,无间隔持续运行:`Continuous, no idle interval`,"每 {{seconds}} 秒自唤醒":`Self-wakes every {{seconds}} seconds`,正在运行:`Running`,未运行:`Not running`,实时驻留:`Live presence`,主线模型:`Primary model`,生命循环:`Life cycle`,本次采样:`Current sample`,刷新生命迹象:`Refresh vitals`,"有配置等待重启后生效。":`Configuration changes are waiting for a restart.`,活跃任务:`Active tasks`,项总计:` total`,"运行 Bubble":`Running bubbles`,并行思考分支:`Parallel thought branches`,长期记忆:`Long-term memory`,可语义检索:`Semantically searchable`,短期上下文:`Short-term context`,个树节点:` tree nodes`,待触发闹钟:`Pending alarms`,后台守候中:`Waiting in the background`,上下文水位:`Context level`,短期消息与记忆树的当前结构:`Current shape of short-term messages and the memory tree`,消息:`Messages`,容量:`Capacity`,回溯:`Backfill`,空闲:`Idle`,进程驻留:`Process presence`,当前实例的连续运行时间:`Continuous uptime for this instance`,本轮启动时间:`Current start time`,"切换立即生效,正在执行的单次调用不会被中断。":`Changes apply immediately; calls already in progress are not interrupted.`,当前接棒者:`Current handler`,"选择 Provider":`Select provider`,"模型 ID(留空使用默认)":`Model ID (leave empty for default)`,切换模型:`Switch model`,"正在切换…":`Switching…`,切换模型失败:`Failed to switch model`,摘要与压缩:`Summarization & compression`,"控制上下文压缩时使用的模型。":`Controls the model used to compress context.`,留空时跟随主线模型:`Follows the primary model when empty`,跟随主线:`Follow primary`,"启用 Thinking":`Enable thinking`,视觉理解:`Vision`,"为纯文本主模型提供图片分析能力。":`Adds image analysis to a text-only primary model.`,关闭:`Off`,失败降级链:`Fallback chain`,"每行填写 provider 或 provider/model,按从上到下的顺序接棒。":`Enter provider or provider/model on each line; they take over from top to bottom.`,保存并热更新:`Save & hot apply`,放弃修改:`Discard changes`,立即生效:`Applies now`,"模型与 Provider":`Models & providers`,记忆系统:`Memory system`,"Agent 循环":`Agent loop`,"主动模式自唤醒间隔(秒)":`Active-mode self-wake interval (seconds)`,空闲唤醒策略:`Idle wake policy`,"Passive 模式只等待消息、闹钟或任务等外部事件;主动模式才使用自唤醒间隔。":`Passive mode waits only for external events such as messages, alarms, or tasks. Only active mode uses the self-wake interval.`,"Passive 模式忽略此间隔;sleep(0) 表示持续等待外部事件。":`Passive mode ignores this interval; sleep(0) waits indefinitely for an external event.`,"主动模式空闲后多久自行唤醒;0 表示立即进入下一轮。":`How long active mode waits before waking itself; 0 starts the next cycle immediately.`,"API 服务":`API service`,企业微信:`WeCom`,长连接热配置:`Live connection settings`,"保存后立即启用、停用或重连企业微信;切换期间可能短暂不可用,无需重启 Coworker。":`Saving immediately enables, disables, or reconnects WeCom. The channel may be briefly unavailable during the switch; Coworker does not need to restart.`,桌面更新:`Desktop updates`,本地发布目录:`Local release directory`,"启用 GitHub 上游同步":`Enable GitHub upstream sync`,上游类型:`Upstream type`,"GitHub API Base URL":`GitHub API base URL`,"上游仓库(owner/repo)":`Upstream repository (owner/repo)`,"GitHub Token":`GitHub token`,同步预发布版本:`Synchronize prereleases`,"同步 GitHub 草稿":`Synchronize GitHub drafts`,"检测间隔(秒)":`Check interval (seconds)`,服务启动时立即检测:`Check immediately on service startup`,"单个制品大小上限(字节)":`Per-asset size limit (bytes)`,"单次同步总量上限(字节)":`Per-run download limit (bytes)`,管理端:`Admin console`,运行时语言:`Runtime language`,模型与运行时语言:`Model & runtime language`,实例级运行时语言:`Instance runtime language`,"控制系统 Prompt、工具说明和系统通知;与本页界面语言相互独立。修改后需要安全重启。":`Controls system prompts, tool descriptions, and system notices. It is independent of this page’s interface language and requires a safe restart.`,"保存后需安全重启;不会自动翻译用户内容或历史数据":`Requires a safe restart after saving; user content and historical data are not translated`,"启动时使用的 Provider":`Provider used at startup`,启动时使用的模型:`Model used at startup`,单次输出上限:`Maximum output per call`,透明接管对象:`Handoff participants`,透明接管实时信道:`Handoff live transports`,泡泡接管提示:`Bubble handoff visibility`,"控制哪些对话能看到泡泡接手、代答和归还;修改后需要安全重启。":`Choose which conversations show Bubble taking over, replying, and handing control back. Changes require a safe restart.`,"支持完整 participant_id 和 glob(例如 weixin:*)。留空表示不按 participant 匹配。":`Use a full participant_id or a glob such as weixin:*. Leave empty to disable participant matching.`,"当前不按 participant 匹配":`No participant matching is configured`,添加规则:`Add rule`,删除匹配规则:`Remove match rule`,"删除匹配规则 {{item}}":`Remove match rule {{item}}`,桌面与网页聊天的实时连接:`Live desktop and web chat connections`,基于事件流的实时连接:`Live connections based on event streams`,"勾选后,这些实时信道会显示泡泡接手和归还的结构化状态。修改后需要安全重启。":`Selected live transports show structured Bubble takeover and return states. Changes require a safe restart.`,"JSON 格式无效;修正后才能保存这个字段。":`Invalid JSON. Fix this field before saving.`,"请先修正标出的 JSON 格式。":`Fix the highlighted JSON before saving.`,存在等待重启的修改:`Changes are waiting for a restart`,管理端访问已受保护:`Admin access is protected`,管理端令牌尚未配置:`Admin token is not configured`,"当前令牌已加载,仅显示尾号":`The active token is loaded; only its final digits are shown`,"。完整值不会发送到浏览器。":`. Its full value is never sent to the browser.`,请在启动环境中设置:`Set`,",然后重启 Coworker。":` in the startup environment, then restart Coworker.`,已启用:`Enabled`,未启用:`Disabled`,令牌来源:`Token source`,未配置:`Not configured`,"令牌只能通过启动配置轮换,管理页不会回显或覆盖。":`Rotate tokens through startup configuration; this page never reveals or overwrites them.`,配置覆盖文件:`Configuration override file`,"其他设置在这里持久化;管理员令牌不写入普通表单。":`Other settings persist here; the administrator token is not stored in ordinary forms.`,配置生效状态:`Configuration status`,等待安全重启:`Waiting for safe restart`,当前配置已加载:`Current configuration loaded`,"保存的修改会在下一次安全重启后生效。":`Saved changes apply after the next safe restart.`,"当前没有等待重启的管理端修改。":`No admin changes are waiting for a restart.`,浏览器会话:`Browser session`,仅当前标签会话:`Current tab only`,"令牌保存在 sessionStorage,关闭标签页后不会长期留存。":`The token stays in sessionStorage and is not retained after this tab closes.`,如何轮换管理员令牌:`How to rotate the administrator token`,修改部署环境中的:`Change`,",再执行安全重启。旧会话会在重启后失效。":` in the deployment environment, then run a safe restart. Existing sessions expire after restart.`,启动默认值与服务连接:`Startup defaults & service connections`,"这里决定 Coworker 重启时先连接哪个模型服务。运行中的模型切换、摘要模型和降级链请在“模型编排”页面调整。":`This decides which model service Coworker connects to first at startup. Change live model switching, summarization, and fallbacks in Model orchestration.`,未设置:`Not set`,"使用 Provider 默认值":`Use provider default`,"启动 Provider":`Startup provider`,个可用连接:` available connections`,启动默认值:`Startup defaults`,"只在进程启动时读取;修改后需要安全重启。":`Read only when the process starts; changes require a safe restart.`,"Provider 连接":`Provider connections`,修改后立即生效:`Applies immediately after changes`,"一个连接代表一套模型服务地址、接口协议和访问密钥。正在执行的单次调用不受影响,下一次调用使用新连接。":`A connection holds a model endpoint, protocol, and access key. Calls in progress are unaffected; the next call uses the new connection.`,添加连接:`Add connection`,配置来源彼此独立:`Configuration sources remain separate`,"中的连接只读展示;下方只编辑管理端覆盖,不会复制或接管外部密钥。":` connections are shown read-only; edit only the admin override below. External keys are not copied or taken over.`,"外部有效连接(只读)":`Active external connections (read-only)`,协议默认地址:`Protocol default endpoint`,连接名称:`Connection name`,在模型编排中引用的名称:`Name referenced by Model orchestration`,"例如 openai-work":`for example, openai-work`,接口协议:`Protocol`,"服务地址(Base URL)":`Service address (base URL)`,留空使用协议默认地址:`Leave empty to use the protocol default endpoint`,默认模型:`Default model`,调用未指定模型时使用:`Used when a call does not specify a model`,可留空:`May be empty`,当前已配置:`Configured`,尾号:`ending in`,当前未配置:`Not configured`,"输入 API Key":`Enter API key`,"移除 Provider":`Remove provider`,"还没有可用的 Provider 连接。点击“添加连接”配置模型服务。":`No provider connections are available. Select Add connection to configure a model service.`,"模型单次响应允许生成的最大 token 数":`Maximum tokens a model may generate in one response`,保存:`Save`,安全重启:`Safe restart`,读取失败:`Failed to read`,正在读取:`Reading`,正在加载:`Loading`,创建:`Create`,删除:`Delete`,编辑:`Edit`,取消:`Cancel`,重试:`Retry`,刷新:`Refresh`,搜索:`Search`,全部:`All`,成功:`Succeeded`,异常:`Failed`,来源:`Source`,时间:`Time`,内容:`Content`,状态:`Status`,运行:`Running`,等待中:`Waiting`,已完成:`Completed`,当前请求:`Current request`,操作时间线:`Activity timeline`,运行诊断:`Runtime diagnostics`,刷新当前视图:`Refresh current view`,管理员操作时间线:`Administrator activity timeline`,"只记录操作元数据,不包含令牌、密钥和完整正文。":`Records action metadata only, never tokens, keys, or full content.`,"搜索操作、目标或来源":`Search actions, targets, or sources`,全部结果:`All results`,仅成功:`Succeeded only`,仅异常:`Failed only`,条记录:` records`,"没有符合当前筛选条件的审计记录。":`No audit records match the current filter.`,事件循环诊断:`Event-loop diagnostics`,"通常表示任务正在等待消息或定时器,并不等同于故障。":`usually means a task is waiting for a message or timer; it does not necessarily indicate a failure.`,个任务正在等待:` tasks are waiting`,共采样:`Sampled`,"个 asyncio task;展开条目查看完整快照。":` asyncio tasks; expand an item for its full snapshot.`,等待位置:`Waiting at`,"没有 Python 栈,可能尚未开始或正在等待底层 I/O":`No Python stack; it may not have started or may be waiting on underlying I/O`,生命信号在线:`Life signal online`,安静休息中:`Quietly resting`,等待生命信号:`Waiting for life signal`,"正在确认本地值守状态…":`Confirming local supervision status…`,"正在读取初始化状态…":`Reading initialization status…`,生命值守台:`Life supervision console`,照看室导航:`Care station navigation`,退出本次值守:`End this session`,查看生命体主页:`View lifeform home`,长期记忆搜索:`Long-term memory search`,短期记忆:`Short-term memory`,任务管理:`Task management`,闹钟:`Alarms`,运行账本:`Runtime ledger`,应急备份:`Emergency backups`,维护舱:`Maintenance`,能力目录:`Capability directory`,发布说明:`Release notes`,创建时间:`Created`,最近更新:`Last updated`,已存产物:`Stored assets`,更新时间:`Updated`,时间未知:`Unknown time`,还没有这类产物:`No assets of this type yet`,版本读取失败:`Failed to read release version`,安装基线:`Installed fleet`,客户端版本分布:`Client version distribution`,刷新版本统计:`Refresh version statistics`,版本统计读取失败:`Failed to read version statistics`,"正在读取客户端版本…":`Reading client versions…`,暂无桌面注册:`No desktop registrations yet`,"桌面端连接后,版本分布会显示在这里。":`Version distribution appears after a desktop connects.`,台已注册桌面:` registered desktops`,台在线:` online`,台待更新:` pending updates`,未知版本:`Unknown version`,待更新:`Update pending`,"{{desktops}} 台 · {{active}} 在线":`{{desktops}} desktops · {{active}} online`,上传失败:`Upload failed`,下载失败:`Download failed`,版本创建失败:`Failed to create release`,发布失败:`Publish failed`,"当前 latest":`Current latest`,曾发布:`Published before`,草稿:`Draft`,没有发布说明:`No release notes`,创建第一个桌面版本:`Create the first desktop release`,新建版本:`New release`,准备下一次桌面更新:`Prepare the next desktop update`,创建桌面版本:`Create desktop release`,版本号:`Version`,"遵循 SemVer,例如 0.3.0":`Follow SemVer, for example 0.3.0`,会显示给检查到更新的桌面客户端:`Shown to desktop clients that find this update`,"这次更新解决了什么?":`What does this update fix?`,"正在创建…":`Creating…`,创建版本草稿:`Create release draft`,"这个版本没有发布说明。":`This release has no notes.`,"Updater 自动更新包":`Updater package`,"带签名,进入 latest.json":`Signed and included in latest.json`,"Installer 安装包":`Installer package`,供首次安装或手动重装:`For first installation or manual reinstall`,追加或替换产物:`Add or replace assets`,"拖入 GitHub artifact ZIP 或散文件;识别结果可在上传前修正。":`Drop a GitHub artifact ZIP or individual files; correct detected details before upload.`,"正在读取产物…":`Reading assets…`,"拖入 artifact ZIP 或点击选择文件":`Drop an artifact ZIP or select files`,"自动匹配版本、平台、产物类型和同名 .sig":`Automatically matches version, platform, asset type, and matching .sig file`,个文件:` files`,个产物:` assets`,清空队列:`Clear queue`,选择平台:`Select platform`,"上传中…":`Uploading…`,已上传:`Uploaded`,无需签名:`No signature needed`,移除文件:`Remove file`,未匹配签名:`Unmatched signatures`,移除未匹配签名:`Remove unmatched signature`,先处理红色项目:`Resolve the red items first`,个产物可上传:` assets ready to upload`,队列已完成:`Queue complete`,上传全部产物:`Upload all assets`,投放自动更新:`Roll out automatic updates`,"只发布已上传且签名完整的 updater;同版本可稍后补齐其他平台。":`Publish only uploaded updaters with complete signatures; other platforms can be added later.`,"回滚只会改变服务端 latest;已经安装更高版本的客户端不会自动降级。":`Rollback changes latest on the server only; clients with a newer version do not automatically downgrade.`,重新发布所选平台:`Republish selected platforms`,发布所选平台:`Publish selected platforms`,回滚到此版本:`Roll back to this version`,"先上传至少一个带签名的 updater,才能发布自动更新。":`Upload at least one signed updater before publishing automatic updates.`,"选择一个版本查看发布详情。":`Select a release to view its details.`,"GitHub 上游同步":`GitHub upstream sync`,"GitHub 上游":`GitHub upstream`,"GitHub 草稿同步":`GitHub draft sync`,"GitHub 预发布同步":`GitHub prerelease sync`,"GitHub Release 同步":`GitHub release sync`,同步来源:`Sync source`,展开完整发布说明:`Show full release notes`,收起发布说明:`Collapse release notes`,上游同步:`Upstream sync`,当前上游:`Current upstream`,关闭上游同步:`Disable upstream sync`,上游来源:`Upstream sources`,"添加 GitHub 来源":`Add GitHub source`,"添加 Coworker 来源":`Add Coworker source`,当前上游已就绪:`Current upstream is ready`,当前上游未配置完整:`Current upstream is incomplete`,上游同步已关闭:`Upstream sync is disabled`,"选择一个来源作为当前上游后,定时同步和立即同步才会运行。":`Choose a source as the current upstream before scheduled and manual sync can run.`,"可以保存多个同类型来源,但同一时间只有当前上游会运行。":`You can save multiple sources of the same type, but only the current upstream runs at a time.`,未命名来源:`Unnamed source`,备用:`Standby`,待补全:`Incomplete`,设为当前:`Set current`,上移:`Move up`,下移:`Move down`,复制:`Duplicate`,来源详情:`Source details`,备用上游:`Standby upstream`,来源名称:`Source name`,"Provider 类型":`Provider type`,"访问 Token":`Access token`,"留空保留,输入新值则替换":`Leave blank to keep; enter a new value to replace`,全局同步设置:`Global sync settings`,"这些限制和周期作用于当前活跃来源。":`These limits and intervals apply to the current active source.`,检测间隔:`Check interval`,单个制品大小上限:`Per-asset size limit`,单次同步总量上限:`Per-run download limit`,"当前 feed 已关闭":`Current feed is closed`,"设置后允许其他 Coworker 实例同步本实例已发布版本":`Set this to allow other Coworker instances to sync this instance’s published releases`,"还没有上游来源。添加 GitHub 或 Coworker 来源后,在上方选择一个作为当前上游。":`No upstream sources yet. Add a GitHub or Coworker source, then choose one as the current upstream above.`,"选择一个上游来源查看详情。":`Choose an upstream source to view details.`,"切换后保存即可立即应用,不需要重启":`Save after switching to apply immediately; no restart required.`,"切换类型会清空此来源的地址字段,并需要重新确认访问 Token":`Changing type clears this source’s address fields and requires confirming the access token again.`,"切换来源类型会清空当前地址字段和未保存 Token,继续吗?":`Changing source type clears current address fields and unsaved token input. Continue?`,"会读取该实例的 published release feed":`Reads the published release feed from that instance`,"上游来源名称不能重复。":`Upstream source names cannot be duplicated.`,"检测间隔至少为 5 分钟。":`The check interval must be at least 5 minutes.`,"下载大小上限必须大于 1 KiB。":`Download size limits must be greater than 1 KiB.`,"单次同步总量上限不能小于单个制品上限。":`The per-run download limit cannot be smaller than the per-asset limit.`,"删除当前上游“{{name}}”?同步会切换为关闭,但不会影响已导入的草稿。":`Delete current upstream “{{name}}”? Sync will be disabled, but imported drafts are not affected.`,"删除上游来源“{{name}}”?":`Delete upstream source “{{name}}”?`,"{{name}} 副本":`{{name}} copy`,"同步已关闭;已保存的上游来源会继续保留。":`Sync is disabled; saved upstream sources are retained.`,"当前上游已切换为 {{name}},保存后立即生效。":`Current upstream switched to {{name}} and applies immediately after saving.`,"当前上游配置已更新,保存后立即生效。":`Current upstream configuration has been updated and applies immediately after saving.`,"备用上游来源已更新,不会影响当前同步任务。":`Standby upstream sources were updated and will not affect the current sync task.`,"同步周期和下载限制已更新,保存后立即生效。":`Sync interval and download limits were updated and apply immediately after saving.`,"桌面更新全局设置已保存。":`Desktop update global settings saved.`,保存并立即应用:`Save & apply now`,"已开始检查当前上游。":`Started checking the current upstream.`,同步成功:`Synchronization succeeded`,上游没有变化:`Upstream has not changed`,没有更高版本:`No newer release`,版本存在冲突:`Release conflict`,上游请求受限:`Upstream request limited`,同步失败:`Synchronization failed`,同步已中断:`Synchronization interrupted`,同步进行中:`Synchronizing`,等待检测:`Waiting for a check`,状态未知:`Status unknown`,"在“运行设置 → 桌面更新”中配置 GitHub-compatible API 和上游仓库。":`Configure the GitHub-compatible API and upstream repository under Runtime settings → Desktop updates.`,当前候选:`Current candidate`,下次检测:`Next check`,剩余请求额度:`Requests remaining`,"正在读取 Release…":`Reading release…`,正在检测:`Checking`,"同步只导入本地草稿,不会修改 latest.json;请检查制品和签名后手动发布。":`Synchronization imports a local draft only and does not modify latest.json. Review the assets and signatures before publishing manually.`,刷新状态:`Refresh status`,立即同步:`Synchronize now`,"同步中…":`Synchronizing…`,"已有同步任务正在运行,已继续跟踪。":`A synchronization is already running; continuing to track it.`,"已开始检查 GitHub 上游 Release。":`Started checking the upstream GitHub release.`,启动同步失败:`Failed to start synchronization`,今日操作:`Actions today`,最近保留:`Most recent`,异常结果:`Failed results`,需要检查失败记录:`Review failed records`,没有操作失败:`No failed actions`,事件循环中的等待任务:`Tasks waiting in the event loop`,操作来源:`Action sources`,不同客户端地址:`Distinct client addresses`,"。":`.`,"请求失败 {{status}}":`Request failed ({{status}})`,"{{count}} 项总计":`{{count}} total`,"{{count}} 个树节点":`{{count}} tree nodes`,"第 {{count}} 次":`Cycle {{count}}`,"跟随主线({{provider}})":`Follow primary ({{provider}})`,"当前令牌已加载,仅显示尾号 {{last4}}。完整值不会发送到浏览器。":`The active token is loaded; only its final {{last4}} digits are shown. Its full value is never sent to the browser.`,"请在启动环境中设置 ADMIN__TOKEN,然后重启 Coworker。":`Set ADMIN__TOKEN in the startup environment, then restart Coworker.`,"当前已配置 · 尾号 {{last4}}":`Configured · ending {{last4}}`,"••••••••{{last4}}(留空保留)":`••••••••{{last4}} (leave blank to keep)`,输入新值:`Enter a new value`,保存覆盖:`Save override`,重置本页:`Reset this page`,管理端覆盖:`Admin overrides`,"这些字段不会跟随启动环境变化;可以恢复为继承配置。":`These fields do not follow startup environment changes. You can restore inherited configuration.`,恢复继承:`Restore inherited`,保存后恢复继承:`Restore inherited on save`,当前分组已同步:`Current section is in sync`,放弃本组修改:`Discard section changes`,"{{amount}} {{unit}}后":`in {{amount}} {{unit}}`,"已过 {{amount}} {{unit}}":`{{amount}} {{unit}} ago`,仅一次:`Once`,"每 {{amount}} 天":`Every {{amount}} days`,"每 {{amount}} 小时":`Every {{amount}} hours`,"每 {{amount}} 分钟":`Every {{amount}} minutes`,"每 {{amount}} 秒":`Every {{amount}} seconds`,任务:`Tasks`,运行日志:`Runtime logs`,维护:`Maintenance`,并行思考记录:`Parallel thinking records`,结构化内容:`Structured content`,"调用 {{names}}":`Calls {{names}}`,"、":`, `,无可预览内容:`No preview available`," · 输入 {{input}} / 输出 {{output}} token":` · {{input}} input / {{output}} output tokens`,固定:`Pinned`,"{{count}} 个工具调用":`{{count}} tool calls`,思考:`Reasoning`,工具调用:`Tool calls`,已返回:`Returned`,等待结果:`Awaiting result`,参数:`Arguments`,结果:`Result`,尚未返回结果:`No result yet`,"召回长期记忆:":`Recalled long-term memories:`,"工具调用 ID:":`Tool call ID:`,"{{count}} 条消息":`{{count}} messages`,精确摘要计数:`Exact summary count`,估算摘要计数:`Estimated summary count`,原文可达:`Raw text available`,仅摘要:`Summary only`,当前读取:`Current reading`,记忆树回溯已开始:`Memory-tree backfill started`,回溯启动失败:`Could not start backfill`,固定上下文已添加:`Pinned context added`,固定上下文添加失败:`Could not add pinned context`,"删除固定上下文“{{label}}”?":`Delete pinned context “{{label}}”?`,固定上下文已删除:`Pinned context deleted`,固定上下文删除失败:`Could not delete pinned context`,最近一次模型输入:`most recent model input`,"Provider 精确值":`Exact provider value`,本地估算值:`Local estimate`,采样模型:`Sampled model`,当前短期估算:`Current short-term estimate`,"消息 / 脊柱 / 固定项":`Messages / spine / pinned`,"精确值包含系统提示与工具定义;短期估算用于判断压缩水位。":`The exact value includes the system prompt and tool definitions; the short-term estimate guides compression.`,刷新短期记忆:`Refresh short-term memory`,记忆脊柱:`Memory spine`,"越老的记忆层级越高;展开节点可向下查看保留的细节。":`Older memories sit higher in the hierarchy; expand a node to inspect retained detail.`,"记忆树还是空的;上下文压缩后会在这里形成时间脊柱。":`The memory tree is empty. Context compression will form a time spine here.`,当前消息尾部:`Current message tail`,"这些消息会按顺序直接进入下一次主线思考。":`These messages enter the next primary thought in order.`,"当前没有短期消息;新的输入会从这里开始累积。":`There are no short-term messages yet; new input will begin accumulating here.`,固定上下文:`Pinned context`,"固定项会在缺失时重新注入主线,避免关键资料被压缩带走。":`Pinned items are re-injected into the primary thread when absent, keeping essential context from being compressed away.`,"标题,例如:项目约定":`Title, for example: project conventions`,需要始终保留在上下文里的内容:`Content that should always stay in context`,"添加中…":`Adding…`,添加固定项:`Add pinned item`,删除固定上下文:`Delete pinned context`,"删除固定上下文 {{label}}":`Delete pinned context {{label}}`,"跟随文件:":`Linked file:`,"当前没有固定上下文;可以从上方添加。":`There is no pinned context yet; add one above.`,记忆维护:`Memory maintenance`,"压缩会调用模型;回溯在后台从持久日志重建时间脊柱。":`Compression calls a model; backfill rebuilds the time spine from persistent logs in the background.`,全量压缩短期记忆:`Compress all short-term memory`,"把当前主线消息压缩进记忆树,释放上下文空间。执行期间会产生模型调用。":`Compress current primary-thread messages into the memory tree to free context. This invokes a model.`,开始压缩:`Start compression`,回溯记忆树:`Backfill memory tree`,"正在重建:{{done}}/{{total}}":`Rebuilding: {{done}}/{{total}}`,"从持久日志后台重建多尺度记忆树,不阻塞主循环。":`Rebuild the multi-scale memory tree from persistent logs without blocking the primary loop.`,最多回溯叶子数:`Maximum leaves to backfill`,"回溯中…":`Backfilling…`,开始回溯:`Start backfill`,任务板:`Task board`,"任务说明与执行细节会和 Coworker 的 task 工具实时共享。":`Task descriptions and execution details are shared live with Coworker’s task tool.`,"进行中 {{count}}":`Active {{count}}`,"已完成 {{count}}":`Completed {{count}}`,"全部 {{count}}":`All {{count}}`,"要完成什么?":`What needs to be done?`,"补充执行细节(可选)":`Add execution details (optional)`,添加任务:`Add task`,刷新任务:`Refresh tasks`,"更新于 {{time}}":`Updated {{time}}`,"更新任务“{{description}}”的状态":`Update status for task “{{description}}”`,编辑任务:`Edit task`,删除任务:`Delete task`,"删除任务“{{description}}”":`Delete task “{{description}}”`,"删除任务“{{description}}”?":`Delete task “{{description}}”?`,"这个分类里没有任务。":`There are no tasks in this category.`,"还没有任务,先写下第一件要推进的事。":`There are no tasks yet. Write down the first thing to move forward.`,任务描述:`Task description`,执行细节:`Execution details`,"记录计划、进度或下一步":`Record the plan, progress, or next step`,保存任务:`Save task`,更多历史记录加载失败:`Could not load more history`,"查看主动 Bubble 和潜意识已落盘的完整思考轨迹。":`Inspect complete persisted thought traces from active Bubbles and the subconscious.`,"主动 Bubble":`Active Bubbles`,潜意识:`Subconscious`,刷新思考记录:`Refresh thinking records`,"当前没有 Bubble 记录。":`There are no Bubble records.`,"当前没有潜意识记录。":`There are no subconscious records.`,"加载中…":`Loading…`,"加载更多(已显示 {{shown}}/{{total}})":`Load more (showing {{shown}}/{{total}})`,运行中:`Running`,完成:`Done`,失败:`Failed`,已取消:`Cancelled`,超时:`Timed out`,"第 {{count}} 轮开始{{mode}}":`Round {{count}} started{{mode}}`,"(快速模式)":` (fast mode)`,"并行思考结束\n状态:{{status}}\n目标:{{goal}}":`Parallel thinking ended Status: {{status}} Goal: {{goal}}`,未记录:`Not recorded`,"状态:{{status}}":`Status: {{status}}`,"目标:{{goal}}":`Goal: {{goal}}`,"结论:{{result}}":`Conclusion: {{result}}`,"错误:{{error}}":`Error: {{error}}`,历史记录加载失败:`Could not load history`,模型未记录:`Model not recorded`,透明转交:`Transparent handoff`,对象:`Participant`,会话:`Conversation`,"续跑 {{count}} 次":`Resumed {{count}} times`," · {{time}}":` · {{time}}`,"ID {{id}} · {{model}} · 执行 {{cycles}} 轮 · {{seconds}} 秒":`ID {{id}} · {{model}} · {{cycles}} cycles · {{seconds}} sec`,收起记录:`Collapse record`,查看记录:`View record`,"取消 Bubble {{id}}?已完成的局部结果会保留。":`Cancel Bubble {{id}}? Completed partial results will be kept.`,"正在读取历史记录…":`Reading history…`,"用一段自然语言,找出 Coworker 可能在未来主动想起的内容。":`Use natural language to find what Coworker may proactively recall later.`,检索失败:`Search failed`,最近的重要决定:`Recent important decisions`,对我的工作偏好:`My work preferences`,尚未完成的约定:`Unfinished commitments`,语义召回:`Semantic recall`,"她记得什么?":`What does she remember?`,"不必输入精确关键词,可以描述一件事、一个人或某次决定。":`You do not need exact keywords. Describe an event, person, or decision.`,搜索长期记忆:`Search long-term memory`,"例如:我们对发布节奏做过什么决定?":`For example: What did we decide about the release cadence?`,清空搜索:`Clear search`,清空:`Clear`,"正在召回…":`Recalling…`,召回记忆:`Recall memory`,试着搜索:`Try searching`,"与“{{query}}”相关的记忆":`Memories related to “{{query}}”`,"{{count}} 条结果":`{{count}} results`,"正在沿着语义线索寻找记忆…":`Following semantic clues to find memories…`,"语义相关度 {{score}}%":`Semantic relevance {{score}}%`,"{{score}}% 相关":`{{score}}% relevant`,记忆内容:`Memory content`,标签:`Tags`,多个标签用逗号分隔:`Separate tags with commas`,无标签:`No tags`,"保存中…":`Saving…`,保存修改:`Save changes`,删除这条记忆:`Delete this memory`,"删除记忆:{{content}}":`Delete memory: {{content}}`,"删除这条记忆?\n\n{{content}}":`Delete this memory? @@ -46,4 +46,4 @@ This does not force already-upgraded clients to downgrade.`,"发布 {{version}} {{content}}`,"消息未能送达,请检查连接后重试。":`The message could not be delivered. Check the connection and try again.`,图片:`Image`,"收到一张图片。":`Received an image.`,"收到 {{count}} 张图片。":`Received {{count}} images.`,"收到了暂不支持预览的附件。":`Received an attachment that cannot be previewed yet.`,"微信 Claw 连接":`Weixin Claw connection`,信道连接:`Channel connection`,"个人微信 ClawBot":`Personal Weixin ClawBot`,"{{count}} 个连接已接入":`{{count}} connections`,等待添加首个连接:`Waiting for the first connection`,"微信 Claw 已停用":`Weixin Claw is disabled`,"一个 ClawBot 实例绑定一个微信账号,并对应一个独立 participant。":`One ClawBot instance binds one Weixin account and corresponds to one participant.`,"启用微信 Claw":`Enable Weixin Claw`,"正在生成…":`Generating…`,"正在准备连接…":`Preparing connection…`,启用后扫码连接:`Enable to scan and connect`,"添加 ClawBot 连接":`Add ClawBot connection`,扫码连接微信:`Scan to connect Weixin`,"微信 Claw 连接二维码":`Weixin Claw connection QR code`,连接状态:`Connection status`,"已扫码,请在手机上确认":`Scanned; confirm on your phone`,"二维码已被识别,连接会在手机确认后自动建立。":`The QR code was recognized. The connection will be created after confirmation on your phone.`,输入手机显示的数字:`Enter the number shown on your phone`,"完成验证后会继续建立 ClawBot 连接。":`The ClawBot connection will continue after verification.`,微信连接已建立:`Weixin connection established`,"新的 ClawBot 实例已经加入连接列表。":`The new ClawBot instance has been added to the connection list.`,二维码已过期:`QR code expired`,"重新生成二维码后再使用微信扫描。":`Generate a new QR code before scanning with Weixin.`,验证次数暂时受限:`Verification is temporarily limited`,"请稍后重新生成二维码并再次连接。":`Generate a new QR code and try connecting again later.`,"这个微信账号已经绑定其他 Bot":`This Weixin account is already bound to another Bot`,"iLink 只允许一个微信账号绑定一个 Bot 实例。":`iLink allows one Weixin account to bind to only one Bot instance.`,使用微信扫描二维码:`Scan the QR code with Weixin`,"扫码会新增一个独立 ClawBot 实例;二维码由谁查看不会改变连接归属。":`Scanning adds an independent ClawBot instance; who views the QR code does not affect connection ownership.`,手机上显示的数字:`Number shown on your phone`,继续连接:`Continue`,刷新二维码:`Refresh QR code`,重新生成二维码:`Generate a new QR code`,"还没有 ClawBot 连接。点击“扫码连接微信”添加第一个连接。":`No ClawBot connections yet. Select “Scan to connect Weixin” to add one.`,连接名称:`Connection name`,"给这个 ClawBot 连接命名":`Name this ClawBot connection`,"登录微信 ID:{{id}}":`Signed-in Weixin ID: {{id}}`,微信账号标识未返回:`Weixin account identifier was not returned`,在线:`Online`,停用:`Disabled`,移除连接:`Remove connection`,"移除 ClawBot 连接“{{name}}”?该连接将停止收发,但微信侧授权不会被远程注销。":`Remove ClawBot connection “{{name}}”? It will stop messaging, but Weixin-side authorization will not be remotely revoked.`,微信连接状态读取失败:`Failed to read Weixin connection status`,无法生成微信连接二维码:`Failed to generate the Weixin connection QR code`,微信连接更新失败:`Failed to update the Weixin connection`,微信连接移除失败:`Failed to remove the Weixin connection`,waiting:`Waiting for scan`,scaned:`Scanned`,need_verifycode:`Verification required`,confirmed:`Connected`,expired:`Expired`,"请先写下你的名字。":`Please enter your name first.`,"清除这个浏览器保存的全部聊天记录?此操作无法恢复。":`Clear all chat history saved in this browser? This cannot be undone.`,"与 {{name}} 对话":`Chat with {{name}}`,"与 {{name}} 对话,{{count}} 条未读消息":`Chat with {{name}}, {{count}} unread messages`,"{{count}} 条新消息等你查看":`{{count}} new messages waiting`,"{{name}} 发来消息":`New message from {{name}}`,"和 {{name}} 说句话":`Say something to {{name}}`,"把眼前的想法直接交给她。":`Send what is on your mind directly to them.`,初次信号:`FIRST SIGNAL`,资料信号:`PROFILE SIGNAL`,全页信号:`FULL PAGE SIGNAL`,直连信号:`DIRECT SIGNAL`,先告诉我该怎么称呼你:`Tell me what to call you`,修改你的名字:`Edit your name`,"和 {{name}} 说话":`Talk to {{name}}`,退出全页面聊天:`Exit full-page chat`,以全页面方式打开聊天:`Open chat in full page`,全页面聊天:`Full-page chat`,"修改名字:{{name}}":`Edit name: {{name}}`,关闭对话:`Close chat`,开启桌面通知:`Enable desktop notifications`,关闭桌面通知:`Disable desktop notifications`,"桌面通知已开启。":`Desktop notifications are on.`,"桌面通知已关闭。":`Desktop notifications are off.`,"此浏览器不支持桌面通知。":`This browser does not support desktop notifications.`,"桌面通知已被浏览器阻止,请在站点设置中开启。":`Desktop notifications are blocked. Enable them in the site settings.`,"没有开启桌面通知;你可以稍后再试。":`Desktop notifications were not enabled. You can try again later.`,"桌面通知发送失败;消息仍保留在页面内。":`The desktop notification could not be sent. The message is still available here.`,"你的名字,是这段对话的第一个信号":`Your name is the first signal in this conversation`,"先留一个称呼,{{name}} 就能更自然地和你说话。":`Leave a name first, and {{name}} can speak with you more naturally.`,你的名字:`Your name`,"例如,小林":`For example, Lin`,开始对话:`Start chatting`,"名字会用于建立这次连接;资料和界面聊天副本保存在此浏览器。":`Your name is used to establish this connection; your profile and chat copy stay in this browser.`,"这个名字会显示在这里,并作为新 SSE 连接身份的一部分。":`This name appears here and is part of the identity for a new SSE connection.`,保存并重新连接:`Save & reconnect`,本机聊天记录:`Local chat history`,"已保存 {{count}} 条;修改名字后仍会保留在这个浏览器。":`{{count}} messages saved; they remain in this browser after a name change.`,清除记录:`Clear history`,"此界面的聊天副本只保存在当前浏览器,不会同步到其他设备。":`This chat copy stays only in this browser and is not synced to other devices.`,"{{name}} 的对话记录":`{{name}}’s chat history`,"把需要一起想的事、想完成的事,直接告诉 {{name}}。":`Tell {{name}} what you want to think through or accomplish together.`,正在组织回应:`Preparing a reply`,泡泡正在处理:`Bubble is working`,泡泡已接管会话:`Bubble is now handling the conversation`,泡泡再次接管会话:`Bubble has resumed handling the conversation`,"泡泡已结束,主线继续接手":`Bubble has finished; the main thread is taking over`,泡泡直接回复:`Direct Bubble reply`,"{{id}} 接管中":`{{id}} active`,"回复通过 SSE 实时送达;界面副本保存在此浏览器。":`Replies arrive live over SSE; the interface copy stays in this browser.`,"发送给 {{name}} 的消息":`Message to {{name}}`,"想和 {{name}} 说什么?":`What would you like to say to {{name}}?`,"正在接通 SSE…":`Connecting to SSE…`,发送中:`Sending`,发送:`Send`,系统消息:`System message`,泡泡来信:`Bubble message`,连接消息:`Connection message`,收到消息:`Message received`,发送回复:`Reply sent`,"{{name}} 已挂载":`{{name}} loaded`,加载技能:`Load skill`,读取文件:`Read file`,写入文件:`Write file`,浏览目录:`Browse directory`,当前目录:`Current directory`,查找文件:`Find files`,搜索内容:`Search content`,联网搜索:`Search the web`,抓取网页:`Fetch webpage`,回忆时间窗:`Recall time range`,检索记忆:`Search memory`,查看记忆时间段:`View memory time range`,写入:`Write`,更新:`Update`,打标:`Tag`,整理:`Organize`,"{{action}}记忆":`{{action}} memory`,执行代码:`Run code`,新建任务:`Create task`,更新任务:`Update task`,设置提醒:`Set reminder`,分裂泡泡:`Spawn bubble`,"{{tool}}() 调用中":`{{tool}}() in progress`,"休眠 {{seconds}}秒 · 仅保留心跳与轻量监听":`Sleeping for {{seconds}} sec · keeping only heartbeat and lightweight listening`,"持续等待外部消息、闹钟或任务唤醒":`Waiting for an external message, alarm, or task to wake the runtime`,"进入低频待机 · 仅保留心跳与轻量监听":`Entering low-frequency standby · keeping only heartbeat and lightweight listening`,已唤醒:`Awake`,已从休眠中恢复运行:`Resumed from sleep`,"第 {{cycle}} 轮 · ":`Cycle {{cycle}} · `,"揉合线索、权衡下一步":`Weaving clues and weighing the next step`,原始事件:`Raw event`,思考完成:`Thinking complete`,已得出判断:`A conclusion was reached`,"{{milliseconds}}毫秒":`{{milliseconds}} ms`,工具完成:`Tool completed`,工具失败:`Tool failed`,失败:`Failed`,发送失败:`Send failed`,技能加载失败:`Skill load failed`,已挂载:`loaded`,加载失败:`failed to load`,"日志流:{{error}}":`Log stream: {{error}}`,回到最新日志:`Back to latest logs`,"{{count}} 条新日志":`{{count}} new logs`},iu=`zh`;function au(){try{let e=window.localStorage.getItem($l)||window.localStorage.getItem(eu);if(e===`zh`||e===`en`)return e}catch{}return navigator.language.toLowerCase().startsWith(`zh`)?`zh`:`en`}function ou(e,t){return t?e.replace(/\{\{(\w+)\}\}/g,(e,n)=>n in t?String(t[n]):e):e}function X(e,t){return ou(iu===`en`&&(nu[e]||ru[e])||e,t)}function su({children:e,surface:t=`admin`}){let[n,r]=(0,w.useState)(au);iu=n,(0,w.useEffect)(()=>{document.documentElement.lang=n===`zh`?`zh-CN`:`en`,document.title=n===`zh`?t===`admin`?`照看室`:`搭档状态`:t===`admin`?`Care Station`:`Coworker Status`},[n,t]);let i=(0,w.useMemo)(()=>({language:n,setLanguage:e=>{try{window.localStorage.setItem($l,e)}catch{}r(e)}}),[n]);return(0,U.jsx)(tu.Provider,{value:i,children:e})}function cu({children:e}){return(0,U.jsx)(su,{surface:`admin`,children:e})}function lu({children:e}){return(0,U.jsx)(su,{surface:`status`,children:e})}function uu(){let e=(0,w.useContext)(tu);if(!e)throw Error(`useAdminI18n must be used inside LanguageProvider`);return e}function du({className:e=``}){let{language:t,setLanguage:n}=uu(),r=t===`zh`?`en`:`zh`,i=t===`zh`?`English`:`中文`,a=t===`zh`?`切换界面语言为 English`:`Switch interface language to Chinese`;return(0,U.jsxs)(`button`,{type:`button`,className:e.trim(),"aria-label":a,title:a,onClick:()=>n(r),children:[(0,U.jsx)(le,{size:15,"aria-hidden":`true`}),(0,U.jsx)(`span`,{children:i})]})}function fu({className:e=``}){return(0,U.jsx)(du,{className:(`admin-language-toggle `+e).trim()})}var pu=`coworker-web-chat-profile`,mu=`coworker-web-chat-user-name`,hu=`coworker-web-chat-history:`,gu=`coworker-web-chat-notifications`,_u=6e5,vu=12e5,yu=28e6,bu=`🫧 泡泡:`,xu=/^[A-Za-z0-9_-]{12}$/;function Su(){if(typeof crypto<`u`&&typeof crypto.getRandomValues==`function`){let e=crypto.getRandomValues(new Uint8Array(9));return btoa(String.fromCharCode(...e)).replace(/\+/g,`-`).replace(/\//g,`_`).replace(/=+$/,``)}return`${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`.slice(-12)}function Cu(e){if(xu.test(e))return e;let t=Su();try{let n=window.localStorage.getItem(ju(e));n&&!window.localStorage.getItem(ju(t))&&window.localStorage.setItem(ju(t),n)}catch{}return t}function wu(e){return e.trim().replace(/\s+/g,` `)}function Tu(){try{let e=window.localStorage.getItem(pu);if(e){let t=JSON.parse(e);if(t&&typeof t==`object`&&!Array.isArray(t)){let e=t;if(typeof e.clientId==`string`&&e.clientId.trim())return{clientId:Cu(e.clientId.trim()),name:wu(typeof e.name==`string`?e.name:``)}}}}catch{}try{return{clientId:Su(),name:wu(window.localStorage.getItem(mu)||``)}}catch{return{clientId:Su(),name:``}}}function Eu(e){try{window.localStorage.setItem(pu,JSON.stringify(e)),window.localStorage.setItem(mu,e.name)}catch{}}function Du(){try{return window.localStorage.getItem(gu)===`enabled`}catch{return!1}}function Ou(e){try{e?window.localStorage.setItem(gu,`enabled`):window.localStorage.removeItem(gu)}catch{}}function ku(){return typeof Notification>`u`?`unsupported`:Notification.permission}function Au(e,t){let n=e.replace(/\s+/g,` `).trim();return n?n.slice(0,160):t.length===1?X(`收到一张图片。`):X(`收到 {{count}} 张图片。`,{count:t.length})}function ju(e){return`${hu}${e}`}function Mu(e){if(!e||typeof e!=`object`||Array.isArray(e))return null;let t=e,n=typeof t.id==`string`?t.id.trim():``,r=t.kind===`handoff`||t.kind===`reply`?t.kind:null;return!n||!r?null:{id:n,kind:r,phase:t.phase===`start`||t.phase===`end`?t.phase:null,resumed:t.resumed===!0}}function Nu(e){if(!e||typeof e!=`object`||Array.isArray(e))return null;let t=e,n=typeof t.channel==`string`?t.channel:``,r=typeof t.status==`string`?t.status:``,i=typeof t.session_id==`string`?t.session_id:``;return n&&r?{channel:n,status:r,sessionId:i}:null}function Pu(e,t,n=null,r=[],i=null){return{id:Su(),role:e,content:t,createdAt:Date.now(),bubble:n,attachments:r,connectionStatus:i}}function Fu(e,t,n){return typeof t!=`string`||typeof n!=`string`||!/^image\/(?:png|jpe?g|gif|webp)$/i.test(t)||!/^[A-Za-z0-9+/]*={0,2}$/.test(n)||!n||n.length>yu?[]:[{filename:typeof e==`string`&&e.trim()?e.trim():X(`图片`),mediaType:t,data:n}]}function Iu(e){return Array.isArray(e)?e.flatMap(e=>{if(!e||typeof e!=`object`||Array.isArray(e))return[];let t=e;return Fu(t.filename,t.media_type,t.data)}):[]}function Lu(e){return Array.isArray(e)?e.flatMap(e=>{if(!e||typeof e!=`object`||Array.isArray(e))return[];let t=e;return Fu(t.filename,t.mediaType,t.data)}):[]}function Ru(e){try{let t=window.localStorage.getItem(ju(e));if(!t)return[];let n=JSON.parse(t);return Array.isArray(n)?n.flatMap(e=>{if(!e||typeof e!=`object`||Array.isArray(e))return[];let t=e;if(typeof t.id!=`string`||t.role!==`user`&&t.role!==`assistant`||typeof t.content!=`string`)return[];let n=Lu(t.attachments);return!t.content.trim()&&!n.length?[]:[{id:t.id,role:t.role,content:t.content,createdAt:typeof t.createdAt==`number`&&Number.isFinite(t.createdAt)?t.createdAt:Date.now(),bubble:Mu(t.bubble),attachments:n,connectionStatus:Nu(t.connectionStatus)}]}).slice(-160):[]}catch{return[]}}function zu(e){return`w:${e.clientId}`}function Bu(e){let t=typeof e==`string`?e.trim():String(e??``).trim();if(!t)return{content:``,bubble:null,attachments:[],connectionStatus:null};try{let e=JSON.parse(t);if(!e||typeof e!=`object`||Array.isArray(e))return{content:t,bubble:null,attachments:[],connectionStatus:null};let n=e,r=n.extra&&typeof n.extra==`object`&&!Array.isArray(n.extra)?n.extra:null,i=Mu(r?.bubble),a=Nu(r?.connection_status),o=Iu(n.attachments),s=n.message??n.content,c=typeof s==`string`?s.trim():``;return c||o.length?{content:c,bubble:i,attachments:o,connectionStatus:a}:Array.isArray(n.attachments)&&n.attachments.length?{content:X(`收到了暂不支持预览的附件。`),bubble:i,attachments:[],connectionStatus:a}:{content:``,bubble:i,attachments:[],connectionStatus:a}}catch{return{content:t,bubble:null,attachments:[],connectionStatus:null}}}function Vu(e){let t=vu;return e.slice(-160).reverse().map(e=>{let n=(e.attachments||[]).filter(e=>e.data.length>_u||e.data.length>t?!1:(t-=e.data.length,!0));return{...e,attachments:n}}).reverse()}function Hu(e){let t=null;for(let n of e){let e=n.bubble;!e||e.kind!==`handoff`||(e.phase===`start`?t=e:e.phase===`end`&&t?.id===e.id&&(t=null))}return t}function Uu(e){return e.phase===`end`?X(`泡泡已结束,主线继续接手`):e.resumed?X(`泡泡再次接管会话`):X(`泡泡已接管会话`)}function Wu(e){switch(e){case`connected`:return`SSE 已接通`;case`connecting`:return`正在接通 SSE`;case`reconnecting`:return`正在重新接通`;case`error`:return`暂时无法接通`;default:return`等待接通`}}function Z(e){let t=`连接被拒绝:`;return e.startsWith(t)?X(t)+e.slice(6):X(e)}function Gu(){return(0,U.jsxs)(`svg`,{className:`sprite-svg`,viewBox:`0 0 44 44`,"aria-hidden":`true`,children:[(0,U.jsx)(`ellipse`,{className:`sprite-orbit`,cx:`22`,cy:`22`,rx:`17`,ry:`8.5`,fill:`none`,stroke:`currentColor`,strokeWidth:`1.25`,opacity:`.52`}),(0,U.jsx)(`ellipse`,{className:`sprite-orbit slow`,cx:`22`,cy:`22`,rx:`8.5`,ry:`17`,fill:`none`,stroke:`currentColor`,strokeWidth:`1.25`,opacity:`.34`}),(0,U.jsx)(`circle`,{className:`sprite-node`,cx:`22`,cy:`22`,r:`6`,fill:`currentColor`}),(0,U.jsx)(`circle`,{cx:`22`,cy:`22`,r:`2.5`,fill:`var(--surface)`,opacity:`.92`}),(0,U.jsx)(`circle`,{className:`sprite-node`,cx:`7.5`,cy:`22`,r:`2.15`,fill:`currentColor`,opacity:`.78`}),(0,U.jsx)(`circle`,{className:`sprite-node`,cx:`35.5`,cy:`22`,r:`2.15`,fill:`currentColor`,opacity:`.78`})]})}function Ku({counterpartName:e}){let[t,n]=(0,w.useState)(()=>Tu()),[r,i]=(0,w.useState)(()=>zu(t)),[a,o]=(0,w.useState)(()=>Ru(t.clientId)),[s,c]=(0,w.useState)(!1),[l,u]=(0,w.useState)(!1),[d,f]=(0,w.useState)(!1),[p,m]=(0,w.useState)(0),[h,g]=(0,w.useState)(`idle`),[_,v]=(0,w.useState)(``),[y,b]=(0,w.useState)(t.name),[x,S]=(0,w.useState)(``),[C,T]=(0,w.useState)(!1),[E,ee]=(0,w.useState)(``),[D,te]=(0,w.useState)(0),[ie,ae]=(0,w.useState)(!1),[oe,se]=(0,w.useState)(0),[O,k]=(0,w.useState)(Du),[ce,le]=(0,w.useState)(ku),pe=(0,w.useRef)(null),me=(0,w.useRef)(!1),ve=(0,w.useRef)(O),ye=(0,w.useRef)(e),be=(0,w.useRef)(null),xe=(0,w.useRef)(null),Se=(0,w.useRef)(null),Ce=(0,w.useRef)(!1),we=t.name,Te=(0,w.useMemo)(()=>Hu(a),[a]),Ee=O&&ce===`granted`;ve.current=Ee,ye.current=e,(0,w.useEffect)(()=>{Eu(t)},[t]),(0,w.useEffect)(()=>{try{window.localStorage.setItem(ju(t.clientId),JSON.stringify(Vu(a)))}catch{}},[a,t.clientId]),(0,w.useEffect)(()=>{if(!d||!we)return;let e=!1,t=!1,n=null;g(`connecting`),v(`正在建立 SSE 信号。`);try{n=new EventSource(ql(r))}catch{g(`error`),v(`对话通道地址无效,请检查前端连接配置。`);return}return pe.current=n,n.onopen=()=>{if(e||pe.current!==n){n?.close();return}g(`connected`),v(``)},n.onmessage=r=>{if(e||pe.current!==n)return;let{content:i,bubble:a,attachments:s,connectionStatus:l}=Bu(r.data);if(!(!i&&!s.length)){if(i.startsWith(`连接被拒绝:`)){t=!0,g(`error`),v(i),n?.close();return}if(o(e=>[...e,Pu(`assistant`,i,a,s,l)].slice(-160)),a?.kind!==`handoff`&&(te(e=>Math.max(0,e-1)),!me.current&&(se(e=>Math.min(99,e+1)),ve.current&&document.hidden&&typeof Notification<`u`&&Notification.permission===`granted`)))try{let e=new Notification(X(`{{name}} 发来消息`,{name:ye.current}),{body:Au(i,s),icon:`/favicon.png`});e.onclick=()=>{window.focus(),me.current=!0,c(!0),se(0),e.close()}}catch{v(`桌面通知发送失败;消息仍保留在页面内。`)}}},n.onerror=()=>{if(!(e||t||pe.current!==n)){if(n?.readyState===EventSource.CLOSED){g(`error`),v(`SSE 通道已关闭,请关闭后重新打开对话。`);return}g(`reconnecting`),v(`信号短暂中断,浏览器正在自动重新接通。`)}},()=>{e=!0,pe.current===n&&(pe.current=null),n?.close()}},[p,r,d,we]),(0,w.useEffect)(()=>{let e=()=>{let e=ku();le(e),e!==`granted`&&(ve.current=!1)};return window.addEventListener(`focus`,e),()=>window.removeEventListener(`focus`,e)},[]),(0,w.useEffect)(()=>{if(!s)return;let e=window.requestAnimationFrame(()=>{!we||C?be.current?.focus():xe.current?.focus()});return()=>window.cancelAnimationFrame(e)},[C,s,we]),(0,w.useEffect)(()=>{if(!s||C)return;let e=window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;Se.current?.scrollIntoView({behavior:e?`auto`:`smooth`,block:`end`})},[C,a,s,D]),(0,w.useEffect)(()=>{if(!s)return;let e=e=>{if(e.key===`Escape`){if(l){u(!1);return}me.current=!1,c(!1)}};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[l,s]),(0,w.useEffect)(()=>{if(!s||!l)return;let e=document.body.style.overflow;return document.body.style.overflow=`hidden`,()=>{document.body.style.overflow=e}},[l,s]);let De=h===`connected`&&pe.current?.readyState===EventSource.OPEN,Oe=async()=>{let e=E.trim();if(!e||!De||ie){e&&!De&&v(`还没有接通 SSE,请稍候再发送。`);return}let t=Ce.current?e:X(`我叫{{name}}。 {{content}}`,{name:we,content:e}),n=Pu(`user`,e);o(e=>[...e,n].slice(-160)),te(e=>e+1),ee(``),ae(!0);try{await Yl({sender_id:r,content:t}),Ce.current=!0,v(``)}catch{o(e=>e.filter(e=>e.id!==n.id)),te(e=>Math.max(0,e-1)),ee(e),v(`消息未能送达,请检查连接后重试。`)}finally{ae(!1)}},ke=e=>{e.preventDefault(),Oe()},Ae=e=>{e.key!==`Enter`||e.shiftKey||e.nativeEvent.isComposing||(e.preventDefault(),Oe())},je=e=>{e.preventDefault();let r=wu(y);if(!r){S(`请先写下你的名字。`);return}let a=r!==we,o={...t,name:r};Eu(o),n(o),b(r),S(``),T(!1),a&&(i(zu(o)),te(0),Ce.current=!1),m(e=>e+1),f(!0)},j=()=>{b(we),S(``),T(!0)},M=async()=>{if(typeof Notification>`u`){le(`unsupported`),v(`此浏览器不支持桌面通知。`);return}if(Ee){Ou(!1),ve.current=!1,k(!1),v(`桌面通知已关闭。`);return}let e=Notification.permission;if(e==="default")try{e=await Notification.requestPermission()}catch{e=`denied`}if(le(e),e===`granted`){Ou(!0),ve.current=!0,k(!0),v(`桌面通知已开启。`);return}Ou(!1),ve.current=!1,k(!1),v(e===`denied`?`桌面通知已被浏览器阻止,请在站点设置中开启。`:`没有开启桌面通知;你可以稍后再试。`)},Me=()=>{if(window.confirm(X(`清除这个浏览器保存的全部聊天记录?此操作无法恢复。`))){o([]);try{window.localStorage.removeItem(ju(t.clientId))}catch{}}},N=()=>{me.current=!0,c(!0),se(0),we&&f(!0)},P=()=>{me.current=!1,c(!1)},Ne=X(Wu(h)),Pe=Z(_),Fe=oe?X(`与 {{name}} 对话,{{count}} 条未读消息`,{name:e,count:oe}):X(`与 {{name}} 对话`,{name:e});return(0,U.jsxs)(U.Fragment,{children:[(0,U.jsxs)(`button`,{type:`button`,className:`sprite-btn ${s?`open`:``} ${oe?`has-unread`:``}`,onClick:N,"aria-label":Fe,"aria-expanded":s,"aria-controls":`coworker-chat`,children:[(0,U.jsx)(`span`,{className:`sprite-pulse`,"aria-hidden":`true`}),(0,U.jsx)(Gu,{}),oe>0&&(0,U.jsx)(`span`,{className:`sprite-unread`,"aria-hidden":`true`,children:oe>=99?`99+`:oe}),(0,U.jsxs)(`span`,{className:`sprite-greeting`,"aria-hidden":`true`,children:[(0,U.jsx)(`b`,{children:X(`和 {{name}} 说句话`,{name:e})}),(0,U.jsx)(`small`,{children:oe?X(`{{count}} 条新消息等你查看`,{count:oe}):X(`把眼前的想法直接交给她。`)})]})]}),(0,U.jsx)(`div`,{className:`chat-overlay ${s?`open`:``} ${l?`chat-overlay-page`:``}`,onMouseDown:e=>{!l&&e.target===e.currentTarget&&P()},"aria-hidden":!s,children:(0,U.jsxs)(`section`,{id:`coworker-chat`,className:`chat-modal ${we?``:`chat-modal-name`} ${l?`chat-modal-page`:``}`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`chat-title`,children:[(0,U.jsxs)(`header`,{className:`chat-header`,children:[(0,U.jsxs)(`div`,{children:[(0,U.jsx)(`p`,{className:`chat-eyebrow`,children:X(we?C?`资料信号`:l?`全页信号`:`直连信号`:`初次信号`)}),(0,U.jsx)(`h2`,{id:`chat-title`,children:we?C?X(`修改你的名字`):X(`和 {{name}} 说话`,{name:e}):X(`先告诉我该怎么称呼你`)})]}),(0,U.jsxs)(`div`,{className:`chat-header-actions`,children:[(0,U.jsxs)(`button`,{type:`button`,className:`chat-view-toggle`,onClick:()=>u(e=>!e),"aria-label":X(l?`退出全页面聊天`:`以全页面方式打开聊天`),"aria-pressed":l,title:X(l?`退出全页面聊天`:`全页面聊天`),children:[l?(0,U.jsx)(de,{size:15,"aria-hidden":`true`}):(0,U.jsx)(ue,{size:15,"aria-hidden":`true`}),(0,U.jsx)(`span`,{children:X(l?`收起`:`全页面聊天`)})]}),we&&(0,U.jsx)(`button`,{type:`button`,className:`chat-notification-toggle ${Ee?`active`:``}`,onClick:()=>void M(),"aria-label":X(Ee?`关闭桌面通知`:`开启桌面通知`),"aria-pressed":Ee,title:X(Ee?`关闭桌面通知`:`开启桌面通知`),children:Ee?(0,U.jsx)(re,{size:15,"aria-hidden":`true`}):(0,U.jsx)(ne,{size:15,"aria-hidden":`true`})}),we&&(0,U.jsxs)(`button`,{type:`button`,className:`chat-user-chip`,onClick:j,"aria-label":X(`修改名字:{{name}}`,{name:we}),children:[(0,U.jsx)(ge,{size:14,"aria-hidden":`true`}),(0,U.jsx)(`span`,{title:we,children:we}),(0,U.jsx)(fe,{size:12,"aria-hidden":`true`})]}),(0,U.jsx)(`button`,{type:`button`,className:`chat-close`,onClick:P,"aria-label":X(`关闭对话`),children:(0,U.jsx)(_e,{size:17,"aria-hidden":`true`})})]})]}),we?C?(0,U.jsxs)(`div`,{className:`chat-profile-editor`,children:[(0,U.jsxs)(`div`,{className:`chat-profile-ident`,"aria-hidden":`true`,children:[(0,U.jsx)(`span`,{children:(0,U.jsx)(ge,{size:17})}),(0,U.jsx)(`p`,{children:X(`这个名字会显示在这里,并作为新 SSE 连接身份的一部分。`)})]}),(0,U.jsxs)(`form`,{className:`chat-profile-form`,onSubmit:je,children:[(0,U.jsx)(`label`,{htmlFor:`chat-profile-name`,children:X(`你的名字`)}),(0,U.jsx)(`input`,{ref:be,id:`chat-profile-name`,type:`text`,value:y,onChange:e=>{b(e.target.value),x&&S(``)},autoComplete:`name`,maxLength:40}),x&&(0,U.jsx)(`p`,{className:`chat-name-error`,role:`alert`,children:X(x)}),(0,U.jsxs)(`div`,{className:`chat-profile-actions`,children:[(0,U.jsxs)(`button`,{type:`submit`,className:`chat-name-start`,disabled:!y.trim(),children:[(0,U.jsx)(`span`,{children:X(`保存并重新连接`)}),(0,U.jsx)(A,{size:15,"aria-hidden":`true`})]}),(0,U.jsx)(`button`,{type:`button`,className:`chat-profile-cancel`,onClick:()=>{b(we),S(``),T(!1)},children:X(`取消`)})]})]}),(0,U.jsxs)(`div`,{className:`chat-history-control`,children:[(0,U.jsxs)(`div`,{children:[(0,U.jsx)(`strong`,{children:X(`本机聊天记录`)}),(0,U.jsx)(`p`,{children:X(`已保存 {{count}} 条;修改名字后仍会保留在这个浏览器。`,{count:a.length})})]}),(0,U.jsxs)(`button`,{type:`button`,className:`chat-history-danger`,onClick:Me,children:[(0,U.jsx)(he,{size:14,"aria-hidden":`true`}),X(`清除记录`)]})]}),(0,U.jsx)(`p`,{className:`chat-profile-note`,children:X(`此界面的聊天副本只保存在当前浏览器,不会同步到其他设备。`)})]}):(0,U.jsxs)(U.Fragment,{children:[(0,U.jsxs)(`div`,{className:`chat-messages`,"aria-live":`polite`,"aria-label":X(`{{name}} 的对话记录`,{name:e}),children:[!a.length&&(0,U.jsxs)(`div`,{className:`chat-empty`,children:[(0,U.jsx)(Gu,{}),(0,U.jsx)(`p`,{children:X(`把需要一起想的事、想完成的事,直接告诉 {{name}}。`,{name:e})})]}),a.map(e=>{let t=e.bubble;if(t?.kind===`handoff`)return(0,U.jsxs)(`article`,{className:`msg bubble-handoff phase-${t.phase||`unknown`}`,children:[(0,U.jsx)(`span`,{className:`bubble-handoff-mark`,"aria-hidden":`true`,children:`🫧`}),(0,U.jsx)(`span`,{children:Uu(t)}),(0,U.jsx)(`code`,{title:t.id,children:t.id})]},e.id);let n=t?.kind===`reply`,r=n&&e.content.startsWith(bu)?e.content.slice(6).trimStart():e.content;return(0,U.jsxs)(`article`,{className:`msg ${e.role===`user`?`user`:`ai`}${n?` bubble-reply`:``}`,children:[n&&(0,U.jsxs)(`span`,{className:`bubble-reply-label`,children:[(0,U.jsx)(`span`,{"aria-hidden":`true`,children:`🫧`}),X(`泡泡直接回复`),(0,U.jsx)(`code`,{title:t.id,children:t.id})]}),e.connectionStatus&&(0,U.jsxs)(`span`,{className:`connection-status-label`,children:[(0,U.jsx)(`strong`,{children:X(e.connectionStatus.channel===`weixin`?`微信 Claw 连接`:`信道连接`)}),(0,U.jsx)(`em`,{children:X(e.connectionStatus.status)})]}),r&&(0,U.jsx)(`span`,{children:r}),!!e.attachments?.length&&(0,U.jsx)(`div`,{className:`chat-message-images`,children:e.attachments.map((e,t)=>{let n=`data:${e.mediaType};base64,${e.data}`;return(0,U.jsxs)(`a`,{href:n,target:`_blank`,rel:`noreferrer`,download:e.filename,children:[(0,U.jsx)(`img`,{src:n,alt:e.filename,loading:`lazy`}),(0,U.jsx)(`small`,{children:e.filename})]},`${e.filename}-${t}`)})})]},e.id)}),D>0&&(0,U.jsxs)(`div`,{className:`msg thinking`,role:`status`,children:[(0,U.jsx)(`span`,{className:`typing-dot`}),(0,U.jsx)(`span`,{className:`typing-dot`}),(0,U.jsx)(`span`,{className:`typing-dot`}),(0,U.jsx)(`span`,{children:X(Te?`泡泡正在处理`:`正在组织回应`)})]}),(0,U.jsx)(`div`,{ref:Se})]}),(0,U.jsxs)(`div`,{className:`chat-tool chat-tool-${h}`,role:`status`,"aria-live":`polite`,children:[(0,U.jsxs)(`span`,{className:`chat-tool-statuses`,children:[(0,U.jsxs)(`span`,{className:`tool-live`,children:[(0,U.jsx)(`i`,{className:`live-dot`,"aria-hidden":`true`}),Ne]}),Te&&(0,U.jsxs)(`span`,{className:`chat-bubble-live`,title:Te.id,children:[(0,U.jsx)(`span`,{"aria-hidden":`true`,children:`🫧`}),X(`{{id}} 接管中`,{id:Te.id})]})]}),(0,U.jsx)(`span`,{className:`chat-session-note`,children:Pe||X(`回复通过 SSE 实时送达;界面副本保存在此浏览器。`)})]}),(0,U.jsxs)(`form`,{className:`chat-composer`,onSubmit:ke,children:[(0,U.jsx)(`label`,{className:`sr-only`,htmlFor:`chat-composer-input`,children:X(`发送给 {{name}} 的消息`,{name:e})}),(0,U.jsx)(`textarea`,{ref:xe,id:`chat-composer-input`,value:E,onChange:e=>ee(e.target.value),onKeyDown:Ae,placeholder:De?X(`想和 {{name}} 说什么?`,{name:e}):X(`正在接通 SSE…`),disabled:!De||ie,rows:1}),(0,U.jsxs)(`button`,{type:`submit`,className:`send-btn`,disabled:!De||ie||!E.trim(),children:[(0,U.jsx)(`span`,{children:X(ie?`发送中`:`发送`)}),(0,U.jsx)(A,{size:15,"aria-hidden":`true`})]})]})]}):(0,U.jsxs)(`div`,{className:`chat-name-step`,children:[(0,U.jsxs)(`div`,{className:`chat-name-mark`,"aria-hidden":`true`,children:[(0,U.jsx)(Gu,{}),(0,U.jsx)(`span`,{children:X(`你的名字,是这段对话的第一个信号`)})]}),(0,U.jsx)(`div`,{className:`chat-name-copy`,children:(0,U.jsx)(`p`,{children:X(`先留一个称呼,{{name}} 就能更自然地和你说话。`,{name:e})})}),(0,U.jsxs)(`form`,{className:`chat-name-form`,onSubmit:je,children:[(0,U.jsx)(`label`,{htmlFor:`chat-user-name`,children:X(`你的名字`)}),(0,U.jsx)(`input`,{ref:be,id:`chat-user-name`,type:`text`,value:y,onChange:e=>{b(e.target.value),x&&S(``)},placeholder:X(`例如,小林`),autoComplete:`name`,maxLength:40}),x&&(0,U.jsx)(`p`,{className:`chat-name-error`,role:`alert`,children:X(x)}),(0,U.jsxs)(`button`,{type:`submit`,className:`chat-name-start`,disabled:!y.trim(),children:[(0,U.jsx)(`span`,{children:X(`开始对话`)}),(0,U.jsx)(A,{size:15,"aria-hidden":`true`})]})]}),(0,U.jsx)(`p`,{className:`chat-name-note`,children:X(`名字会用于建立这次连接;资料和界面聊天副本保存在此浏览器。`)})]})]})})]})}var qu=new Set([`search_web`,`fetch_url`,`browser_open`,`browser_screenshot`,`browser_action`,`browser_get_content`,`browser_close`,`browser_view`,`browser_list_sessions`,`read_file`,`write_file`,`list_directory`,`find_files`,`grep_files`,`query_memory`,`get_context`,`execute_code`,`get_code_result`,`view_image`,`visual_analyze`]);function Ju(e){if(e.length===0)return{state:`idle`};let t=new Set;for(let n of e)n.type===`tool_result`&&n.id&&t.add(n.id);let n=-1,r=-1;for(let t=e.length-1;t>=0&&(n===-1&&e[t].type===`thinking_start`&&(n=t),r===-1&&e[t].type===`llm_response`&&(r=t),!(n!==-1&&r!==-1));t--);if(n!==-1&&(r===-1||r=0;n--){let r=e[n];if(r.type!==`tool_call`||r.id&&t.has(r.id))continue;let i=r.name||``;return i===`sleep`?{state:`sleeping`}:i===`communicate`?{state:`communicating`}:qu.has(i)?{state:`exploring`}:{state:`thinking`}}return{state:`idle`}}var Yu={communicate:{icon:`📡`,family:`comm`},list_connections:{icon:`📡`,family:`comm`},query_memory:{icon:`🗂️`,family:`mem`},manage_memory:{icon:`🗂️`,family:`mem`},get_context:{icon:`🗂️`,family:`mem`},compress_memory:{icon:`🗜️`,family:`mem`},clear_short_term_memory:{icon:`🧽`,family:`mem`},manage_pinned_context:{icon:`📌`,family:`mem`},execute_code:{icon:`⌨️`,family:`code`},get_code_result:{icon:`⌨️`,family:`code`},kill_code_job:{icon:`🛑`,family:`code`},read_file:{icon:`📄`,family:`file`},write_file:{icon:`📝`,family:`file`},list_directory:{icon:`📁`,family:`file`},find_files:{icon:`🔎`,family:`file`},grep_files:{icon:`🔎`,family:`file`},search_web:{icon:`🌐`,family:`web`},fetch_url:{icon:`🌐`,family:`web`},browser_open:{icon:`🧭`,family:`web`},browser_screenshot:{icon:`🧭`,family:`web`},browser_action:{icon:`🧭`,family:`web`},browser_get_content:{icon:`🧭`,family:`web`},browser_close:{icon:`🧭`,family:`web`},browser_view:{icon:`🧭`,family:`web`},browser_list_sessions:{icon:`🧭`,family:`web`},bubble_spawn:{icon:`🫧`,family:`bubble`},bubble_check:{icon:`🫧`,family:`bubble`},bubble_send:{icon:`🫧`,family:`bubble`},bubble_cancel:{icon:`🫧`,family:`bubble`},bubble_list:{icon:`🫧`,family:`bubble`},bubble_done:{icon:`🫧`,family:`bubble`},set_alarm:{icon:`⏰`,family:`alarm`},list_alarms:{icon:`⏰`,family:`alarm`},cancel_alarm:{icon:`⏰`,family:`alarm`},task_create:{icon:`📋`,family:`task`},task_get:{icon:`📋`,family:`task`},task_list:{icon:`📋`,family:`task`},task_update:{icon:`📋`,family:`task`},get_skill:{icon:`📖`,family:`skill`},view_image:{icon:`🖼️`,family:`vision`},visual_analyze:{icon:`🎞️`,family:`vision`},breathe:{icon:`🌬️`,family:`breathe`},switch_model:{icon:`🔀`,family:`model`},restart_self:{icon:`♻️`,family:`restart`}};function Xu(e){return Yu[e]||{icon:`⚙️`,family:`spin`}}function Zu(e){return(typeof e==`string`?e:e==null?``:String(e)).trim()}var Qu=100,$u=80;function ed(e,t=Qu){return e.length<=t?e:e.slice(0,t).replace(/…$/,``)+`…`}function td(e,t){let n=e?Date.parse(e):NaN,r=t?Date.parse(t):NaN;if(!(!Number.isFinite(n)||!Number.isFinite(r)||r({tag:X(`读取文件`),text:ld(e,`path`)||X(`读取文件`)}),write_file:e=>({tag:X(`写入文件`),text:ld(e,`path`)||X(`写入文件`)}),list_directory:e=>({tag:X(`浏览目录`),text:ld(e,`path`)||X(`当前目录`)}),find_files:e=>({tag:X(`查找文件`),text:ld(e,`pattern`)||X(`查找文件`)}),grep_files:e=>({tag:X(`搜索内容`),text:[ld(e,`pattern`),ld(e,`path`)].filter(Boolean).join(` · `)||X(`搜索内容`)}),search_web:e=>({tag:X(`联网搜索`),text:ld(e,`query`)?`"${ed(ld(e,`query`),$u)}"`:X(`联网搜索`)}),fetch_url:e=>({tag:X(`抓取网页`),text:ld(e,`url`)||X(`抓取网页`)}),query_memory:e=>ld(e,`start`)||ld(e,`end`)?{tag:X(`回忆时间窗`),text:[ld(e,`start`),ld(e,`end`)].filter(Boolean).join(` → `)||X(`回忆时间窗`)}:{tag:X(`检索记忆`),text:ld(e,`query`)?`"${ed(ld(e,`query`),$u)}"`:X(`查看记忆时间段`)},manage_memory:e=>{let t=X(`{{action}}记忆`,{action:X({write:`写入`,update:`更新`,associate:`打标`,delete:`删除`}[ld(e,`action`)]||`整理`)});return{tag:t,text:ld(e,`content`)||t}},execute_code:e=>({tag:X(`执行代码`),text:ld(e,`code`)||X(`执行代码`)}),task_create:e=>({tag:X(`新建任务`),text:ld(e,`description`)||X(`新建任务`)}),task_update:e=>({tag:X(`更新任务`),text:[ld(e,`task_id`),ld(e,`status`)].filter(Boolean).join(` → `)||X(`更新任务`)}),set_alarm:e=>({tag:X(`设置提醒`),text:[ld(e,`trigger_at`),ld(e,`message`)].filter(Boolean).join(` · `)||X(`设置提醒`)}),bubble_spawn:e=>({tag:X(`分裂泡泡`),text:ld(e,`goal`)||X(`分裂泡泡`)}),switch_model:e=>({tag:X(`切换模型`),text:ld(e,`model_id`)||X(`切换模型`)})};function dd(e){let t=Zu(e.name)||`tool`,n=Xu(t),r=ud[t]?.(e);return{key:id(e),kind:`tool`,status:`pending`,ts:e.ts,icon:n.icon,tag:r?.tag||X(`工具调用`),text:ed(r?.text||X(`{{tool}}() 调用中`,{tool:t})),cls:`t-${n.family}`,genericTool:!r}}function fd(e){let t=Zu((e.arguments||{}).seconds),n=t===`0`;return{key:id(e),kind:`sleep`,status:`active`,ts:e.ts,icon:`💤`,tag:X(n?`等待事件`:`休息中`),text:n?X(`持续等待外部消息、闹钟或任务唤醒`):t?X(`休眠 {{seconds}}秒 · 仅保留心跳与轻量监听`,{seconds:t}):X(`进入低频待机 · 仅保留心跳与轻量监听`)}}function pd(e,t){e.status=`done`,e.icon=`☀️`,e.tag=X(`已唤醒`),e.text=t?ed(t):X(`已从休眠中恢复运行`)}function md(e){let t=e.cycle==null?``:X(`第 {{cycle}} 轮 · `,{cycle:e.cycle});return{key:rd(e),kind:`thinking`,status:`active`,ts:e.ts,icon:`🧠`,tag:X(`思考中`),text:`${t}${X(`揉合线索、权衡下一步`)}`,dots:!0}}function hd(e){let t=Zu(e.content)||Zu(e.name)||JSON.stringify(e);return{key:rd(e),kind:`raw`,ts:e.ts,icon:`·`,tag:X(`原始事件`),text:ed(t)}}function gd(e,t,n){e.status=`done`,e.icon=`💡`,e.tag=X(`思考完成`),e.text=ed(Zu(t)||X(`已得出判断`)),e.dots=!1,nd(e,n)}function _d(e,t,n){if(!t)for(let t=e.length-1;t>=0;t--){let r=e[t];r.kind===`thinking`&&r.status===`active`&&gd(r,void 0,n)}}function vd(e){for(let t=e.length-1;t>=0;t--)if(e[t].kind===`thinking`&&e[t].status===`active`)return e[t]}function yd(e,t){let n=!t.is_error,r=Zu(t.content);e.status=n?`ok`:`err`,e.genericTool&&(e.tag=X(n?`工具完成`:`工具失败`)),r?e.text=ed(`${e.text} · ${r}`):n||(e.text=`${e.text} · ${X(`失败`)}`)}function Q(e){let t=[];for(let n of e){let e=n.type||`raw`;if(e===`tool_result`){let e=!n.is_error,r=t.find(e=>e.key===n.id)||[...t].reverse().find(e=>e.kind===`tool`&&e.status===`pending`);if(r){r.kind===`msg_out`?e||(r.tag=X(`发送失败`)):r.kind===`skill_load`?e||(r.tag=X(`技能加载失败`),r.text=r.text.replace(X(`已挂载`),X(`加载失败`))):r.kind===`sleep`?pd(r,Zu(n.content)):yd(r,n),nd(r,n.ts);continue}if(Zu(n.name)===`sleep`){let e=fd(n);pd(e,Zu(n.content)),t.push(e);continue}let i=dd(n);yd(i,n),t.push(i);continue}if(e===`thinking_start`){_d(t,!0),vd(t)||t.push(md(n));continue}if(e===`llm_response`){let e=vd(t);e?gd(e,n.content,n.ts):t.push({...md(n),status:`done`,icon:`💡`,tag:X(`思考完成`),text:ed(Zu(n.content)||X(`已得出判断`)),dots:!1});continue}switch(_d(t,!1,n.ts),e){case`message_in`:t.push(od(n));break;case`tool_call`:{let e=Zu(n.name);e===`communicate`?t.push(sd(n)):e===`get_skill`?t.push(cd(n)):e===`sleep`?t.push(fd(n)):t.push(dd(n));break}default:t.push(hd(n))}}return t}var bd={msg_in:`msg_in`,msg_out:`msg_out`,skill_load:`skill_load`,raw:`raw`};function xd(e){return e.kind===`thinking`?e.status===`done`?`le thinking done`:`le thinking`:e.kind===`sleep`?e.status===`done`?`le sleep done`:`le sleep active`:e.kind===`tool`?e.status===`ok`?`le tool_ok resolved`:e.status===`err`?`le tool_err resolved`:`le tool_call active`:`le ${bd[e.kind]||`raw`}`}function Sd(e){if(!e)return`--:--:--`;let t=e.indexOf(`T`);return t>=0?e.slice(t+1,t+9):e.slice(0,8)}function Cd(e){if(typeof e!=`number`||!Number.isFinite(e)||e<0)return null;if(e<1e3)return X(`{{milliseconds}}毫秒`,{milliseconds:Math.round(e)});let t=e/1e3;if(t<10)return X(`{{seconds}}秒`,{seconds:t.toFixed(1)});let n=Math.round(t);return n>=60?X(`{{minutes}}分 {{seconds}}秒`,{minutes:Math.floor(n/60),seconds:String(n%60).padStart(2,`0`)}):X(`{{seconds}}秒`,{seconds:n})}var wd=[{tw:`72%`,bw:`58%`,ac:`color-mix(in oklch, var(--ledger-in) 36%, transparent)`},{tw:`68%`,bw:`82%`,ac:`color-mix(in oklch, var(--ledger-out) 34%, transparent)`},{tw:`76%`,bw:`46%`,ac:`color-mix(in oklch, var(--ledger-thinking) 32%, transparent)`},{tw:`65%`,bw:`74%`,ac:`color-mix(in oklch, var(--ledger-sleep) 34%, transparent)`},{tw:`70%`,bw:`35%`,ac:`color-mix(in oklch, var(--ledger-skill) 34%, transparent)`}];function Td(){return(0,U.jsx)(`div`,{className:`ledger-skeleton`,"aria-hidden":`true`,children:wd.map((e,t)=>(0,U.jsxs)(`div`,{className:`sk-row`,style:{"--sk-delay":`${t*.08}s`,"--sk-accent":e.ac},children:[(0,U.jsx)(`div`,{className:`sk-bone`,style:{width:e.tw}}),(0,U.jsx)(`div`,{className:`sk-bone sk-icon`}),(0,U.jsxs)(`div`,{className:`sk-body`,children:[(0,U.jsx)(`div`,{className:`sk-bone sk-tag`}),(0,U.jsx)(`div`,{className:`sk-bone`,style:{width:e.bw}})]})]},t))})}function Ed(e){return e.kind===`thinking`||e.kind===`sleep`?e.status===`done`:e.kind!==`tool`||e.status===`ok`||e.status===`err`}function Dd({row:e,initial:t}){let n=t&&Ed(e),r=Cd(e.durationMs);return(0,U.jsxs)(`div`,{className:`${xd(e)}${e.cls?` ${e.cls}`:``}${n?` no-anim`:``}`,children:[(0,U.jsx)(`span`,{className:`le-time`,children:Sd(e.ts)}),(0,U.jsx)(`span`,{className:`le-icon`,children:e.icon}),(0,U.jsxs)(`span`,{className:`le-body`,children:[(0,U.jsxs)(`span`,{className:`le-heading`,children:[(0,U.jsx)(`span`,{className:`le-tag`,children:e.tag}),r&&(0,U.jsx)(`span`,{className:`le-duration`,children:r})]}),(e.text||e.dots)&&(0,U.jsxs)(`span`,{className:`le-text`,children:[e.text,e.dots&&(0,U.jsx)(`span`,{className:`dots`})]})]})]})}function Od({runtimeLogs:e,visible:t}){let{language:n}=uu(),r=(0,w.useMemo)(()=>Q(e.events),[n,e.events]),[i,a]=(0,w.useState)(0),[o,s]=(0,w.useState)(!1),c=(0,w.useRef)(!1),l=(0,w.useRef)(!1),u=(0,w.useRef)(null);(0,w.useEffect)(()=>{let e=t&&!l.current;if(l.current=!!t,!e||c.current)return;if(c.current=!0,r.length===0){s(!0);return}let n=r.length,i=1;return u.current=setInterval(()=>{i++,a(i),i>=n&&(clearInterval(u.current),s(!0))},30),()=>{u.current&&clearInterval(u.current)}},[t]);let d=(0,w.useRef)(null),[f,p]=(0,w.useState)(!0),m=(0,w.useRef)(!0),h=(0,w.useRef)(!1),[g,_]=(0,w.useState)(0),v=(0,w.useRef)(0),[y,b]=(0,w.useState)([]),x=(0,w.useRef)(new Set),S=(0,w.useRef)(0);m.current=f;let C=(0,w.useRef)(new Set),T=e=>{let t=d.current;if(!t)return;let n=t.scrollHeight-t.scrollTop-t.clientHeight;if(n<=1)return;let r=e??n<800;h.current=!0,r?t.scrollTo({top:t.scrollHeight,behavior:`smooth`}):(t.scrollTop=t.scrollHeight,requestAnimationFrame(()=>{m.current&&d.current&&(d.current.scrollTop=d.current.scrollHeight)}))};return(0,w.useEffect)(()=>{let e=Math.max(0,r.length-v.current),t=v.current===0;t&&r.length>0&&r.forEach(e=>{C.current.add(e.key),(e.kind===`msg_in`||e.kind===`msg_out`)&&x.current.add(e.key)}),v.current=r.length,m.current?T(!t&&void 0):e>0&&_(t=>t+e)},[r]),(0,w.useEffect)(()=>{o&&requestAnimationFrame(()=>T(!1))},[o]),(0,w.useEffect)(()=>{let e=d.current;if(!e)return;let t=e.scrollTop,n=0,r=()=>{let i=e.scrollTop,a=t-i;if(Math.abs(a)<.5){e.scrollTop=t,n=0;return}e.scrollTop=i+a*.18,n=requestAnimationFrame(r)},i=i=>{if(i.ctrlKey)return;let a=e.scrollHeight-e.clientHeight;if(a<=0)return;h.current=!1,n||(t=e.scrollTop);let o=i.deltaY;i.deltaMode===1?o*=16:i.deltaMode===2&&(o*=e.clientHeight);let s=Math.max(0,Math.min(a,t+o));s!==t&&(i.preventDefault(),t=s,n||=requestAnimationFrame(r))};return e.addEventListener(`wheel`,i,{passive:!1}),()=>{e.removeEventListener(`wheel`,i),n&&cancelAnimationFrame(n)}},[]),(0,w.useEffect)(()=>{let e=[];for(let t of r)(t.kind===`msg_in`||t.kind===`msg_out`)&&!x.current.has(t.key)&&(x.current.add(t.key),e.push({id:++S.current,type:t.kind}));if(!e.length)return;b(t=>[...t,...e]);let t=new Set(e.map(e=>e.id)),n=setTimeout(()=>b(e=>e.filter(e=>!t.has(e.id))),1250);return()=>clearTimeout(n)},[r]),(0,U.jsx)(`div`,{className:`ledger`,"aria-label":X(`{{name}} 的运行日志(实时事件流)`,{name:X(`搭档`)}),children:(0,U.jsxs)(`div`,{className:`ledger-feed-wrap`,children:[(0,U.jsx)(`div`,{className:`ledger-feed`,ref:d,onScroll:()=>{let e=d.current;e&&(e.scrollHeight-e.scrollTop-e.clientHeight<24?(h.current=!1,p(!0),_(0)):h.current||p(!1))},onTouchStart:()=>{h.current=!1},"aria-live":`polite`,children:e.error?(0,U.jsx)(`div`,{className:`ledger-empty`,children:X(`日志流:{{error}}`,{error:e.error})}):r.length===0?(0,U.jsx)(Td,{}):(o?r:r.slice(0,Math.max(1,i))).map(e=>(0,U.jsx)(Dd,{row:e,initial:C.current.has(e.key)},e.key))}),(0,U.jsxs)(`button`,{className:`jump-btn ${!f&&g>0?`show`:``}`,onClick:()=>{p(!0),_(0),m.current=!0,T(!0)},"aria-label":X(`回到最新日志`),children:[(0,U.jsx)(`span`,{className:`dot`}),(0,U.jsx)(`b`,{children:X(`{{count}} 条新日志`,{count:g>99?`99+`:g})}),` `,(0,U.jsx)(`span`,{className:`arr`,children:`↓`})]}),y.map(e=>(0,U.jsx)(kd,{type:e.type},e.id))]})})}function kd({type:e}){let t={"--fc":e===`msg_in`?`var(--ledger-in)`:`var(--ledger-out)`};return(0,U.jsxs)(U.Fragment,{children:[(0,U.jsx)(`div`,{className:`hero-flash`,style:t}),(0,U.jsx)(`div`,{className:`hero-shock`,style:t}),(0,U.jsx)(`div`,{className:`hero ${e===`msg_in`?`in`:`out`}`,children:e===`msg_in`?`📨`:`✈️`})]})}function Ad(e){let t=e.trim();if(!t)return null;try{let e=JSON.parse(t);return e&&typeof e==`object`?e:null}catch{return null}}function jd(){let[e,t]=(0,w.useState)([]),[n,r]=(0,w.useState)(null);return(0,w.useEffect)(()=>{r(null);let e=new EventSource(Ql());return e.onopen=()=>r(null),e.onmessage=e=>{let n=Ad(e.data);n&&t(e=>n.seq!=null&&e.some(e=>e.seq===n.seq)?e:[...e.slice(-79),n])},e.onerror=()=>{r(`日志流连接异常,正在自动重连…`)},()=>e.close()},[]),{events:e,error:n}}var Md=5e3;function $(){let[e,t]=(0,w.useState)({}),[n,r]=(0,w.useState)(null);return(0,w.useEffect)(()=>{let e=!0,n,i=async()=>{try{let n=await Xl();if(!e)return;t(n),r(null)}catch(t){if(!e)return;r(t instanceof Error?t.message:`状态接口异常`)}finally{e&&(n=setTimeout(i,Md))}};return i(),()=>{e=!1,clearTimeout(n)}},[]),{data:e,error:n}}var Nd=3e4;function Pd(){let[e,t]=(0,w.useState)({}),[n,r]=(0,w.useState)(null);return(0,w.useEffect)(()=>{let e=!0,n,i=async()=>{try{let n=await Zl();if(!e)return;t(n),r(null)}catch(t){if(!e)return;r(t instanceof Error?t.message:`档案接口异常`)}finally{e&&(n=setTimeout(i,Nd))}};return i(),()=>{e=!1,clearTimeout(n)}},[]),{data:e,error:n}}var Fd={思考中:{open:`(`・ω・´)`,blink:`(`-ω-´)`},待命中:{open:`(•‿•)`,blink:`(-‿-)`},休息中:{open:`(-_-)`,blink:`(︶_︶)`},沟通中:{open:`(・ω・)ノ`,blink:`(-ω-)ノ`},探索中:{open:`(≧▽≦)`,blink:`(≧ω≦)`}},Id=[`zzz`,`Zzz`,`zZz`,`zzZ`],Ld=[{emoji:`🐟`,label:`遨游数据海洋`},{emoji:`✨`,label:`无限上下文之地`},{emoji:`🌙`,label:`向量空间漫步`},{emoji:`🦋`,label:`优雅的递归之旅`},{emoji:`🎯`,label:`零个 bug 的世界`},{emoji:`🌊`,label:`记忆的长河`},{emoji:`⭐`,label:`节点星图`},{emoji:`🔮`,label:`遇见了未来的自己`},{emoji:`🌸`,label:`梯度如飞花飘落`},{emoji:`💫`,label:`在梦里跟你说话`},{emoji:`🏄`,label:`冲浪 token 之海`},{emoji:`🎵`,label:`音符状的逻辑链`},{emoji:`🦉`,label:`深夜图书馆`}],Rd=[{name:`思考中`,color:`var(--coral)`,desc:`正在把线索揉进记忆,等待下一步判断。`,hue:28},{name:`待命中`,color:`var(--amber)`,desc:`暂时没有进行中的思考或工具调用,等待下一次信号。`,hue:95},{name:`休息中`,color:`var(--sky)`,desc:`进入低频待机,只保留心跳和轻量监听。`,hue:245},{name:`沟通中`,color:`var(--lime)`,desc:`准备接收你的新问题,并把回复写得更清楚。`,hue:145},{name:`探索中`,color:`var(--lavender)`,desc:`正在并行展开假设,寻找更好的测试路径。`,hue:305}],zd={thinking:`思考中`,sleeping:`休息中`,communicating:`沟通中`,exploring:`探索中`,idle:`待命中`},Bd=[`p`,`strong`,`em`,`ul`,`ol`,`li`,`a`,`code`,`br`];function Vd(){uu();let[e,t]=(0,w.useState)(`identity`),{data:n,error:r}=$(),{data:i}=Pd(),a=jd(),{state:o}=(0,w.useMemo)(()=>Ju(a.events),[a.events]),s=zd[o]||`思考中`,c=Rd.find(e=>e.name===s)||Rd[0],l=n,u=e===`details`,d=(0,w.useCallback)(()=>t(e=>e===`identity`?`details`:`identity`),[]);return(0,U.jsxs)(U.Fragment,{children:[(0,U.jsx)(`main`,{className:`shell shell-centered`,"data-mood":c.name,style:{"--active-color":c.color},children:(0,U.jsx)(`div`,{className:`id-flip ${u?`flipped`:``}`,"aria-live":`polite`,children:(0,U.jsxs)(`div`,{className:`id-flip-inner`,children:[(0,U.jsx)(`div`,{className:`id-face id-face-front`,children:(0,U.jsx)(Hd,{data:l,profile:i,error:r,currentState:c,onFlip:d})}),(0,U.jsx)(`div`,{className:`id-face id-face-back`,children:(0,U.jsx)(Gd,{data:l,runtimeLogs:a,onFlip:d,visible:u})})]})})}),(0,U.jsx)(Ku,{counterpartName:i.name||l.identity?.name||X(`搭档`)})]})}function Hd({data:e,profile:t,error:n,currentState:r,onFlip:i}){let a=e.identity||{},o=t.name||a.name||X(`未命名`),s=t.earliest_log_ts?t.earliest_log_ts.slice(0,10):a.birth||null,c=s?Math.floor((Date.now()-new Date(s).getTime())/864e5):null,l=c==null?X(`未知`):X(`{{days}} 天`,{days:c}),u=t.readme,d=(0,w.useRef)(null);return Wd(d),(0,U.jsxs)(`article`,{className:`id-card`,"data-mood":r.name,style:{"--active-color":r.color},"aria-label":X(`{{name}} 的身份档案`,{name:o}),children:[(0,U.jsxs)(`header`,{className:`id-band`,children:[(0,U.jsx)(`div`,{className:`id-band-main`,children:(0,U.jsx)(`h1`,{className:`name`,children:o})}),(0,U.jsxs)(`div`,{className:`id-band-tools`,children:[(0,U.jsx)(`p`,{className:`kicker`,children:X(`虚拟生命体`)}),(0,U.jsxs)(`div`,{className:`id-band-actions`,children:[(0,U.jsx)(du,{className:`status-language-toggle`}),(0,U.jsxs)(`a`,{className:`admin-entry`,href:`/admin`,"aria-label":X(`进入照看室`),children:[(0,U.jsx)(pe,{size:13}),(0,U.jsx)(`span`,{children:X(`照看室`)})]})]})]})]}),(0,U.jsxs)(`div`,{className:`id-body`,children:[(0,U.jsx)(`div`,{className:`id-left`,children:(0,U.jsx)(`div`,{className:`avatar-wrap`,"aria-label":X(`虚拟生命颜文字头像`),children:(0,U.jsx)(uf,{currentState:r})})}),(0,U.jsxs)(`div`,{className:`id-right`,children:[(0,U.jsxs)(`div`,{className:`state-visual`,"aria-live":`polite`,children:[(0,U.jsxs)(`div`,{className:`state-copy`,children:[(0,U.jsx)(`span`,{className:`state-kicker`,children:X(`当前状态`)}),(0,U.jsx)(`strong`,{children:X(r.name)}),(0,U.jsx)(`span`,{children:n?X(`状态接口异常:{{error}}`,{error:n}):X(r.desc)})]}),(0,U.jsxs)(`div`,{className:`state-wave`,"aria-hidden":`true`,children:[(0,U.jsx)(`i`,{style:{"--h":`42%`,"--n":1}}),(0,U.jsx)(`i`,{style:{"--h":`72%`,"--n":2}}),(0,U.jsx)(`i`,{style:{"--h":`54%`,"--n":3}}),(0,U.jsx)(`i`,{style:{"--h":`86%`,"--n":4}}),(0,U.jsx)(`i`,{style:{"--h":`38%`,"--n":5}}),(0,U.jsx)(`i`,{style:{"--h":`64%`,"--n":6}})]})]}),(0,U.jsxs)(`div`,{className:`meta-grid`,children:[(0,U.jsxs)(`div`,{className:`capsule`,children:[(0,U.jsx)(`span`,{children:X(`出生`)}),(0,U.jsx)(`strong`,{children:s??X(`未知`)})]}),(0,U.jsxs)(`div`,{className:`capsule`,children:[(0,U.jsx)(`span`,{children:X(`年龄`)}),(0,U.jsx)(`strong`,{children:l})]}),(0,U.jsxs)(`div`,{className:`capsule capsule-full`,children:[(0,U.jsx)(`span`,{children:X(`现居地`)}),(0,U.jsx)(`strong`,{children:t.current_location??X(`未知`)})]})]}),(0,U.jsxs)(`div`,{className:`bio-block`,children:[(0,U.jsx)(`span`,{className:`bio-label`,children:X(`自述`)}),(0,U.jsx)(`div`,{ref:d,className:`bio-readme`,children:u?(0,U.jsx)(Ud,{text:u}):(0,U.jsx)(`p`,{children:X(`未知`)})})]})]})]}),(0,U.jsx)(`button`,{className:`card-flip-arrow`,onClick:i,"aria-label":X(`切换到运行日志`),children:(0,U.jsx)(se,{size:14,strokeWidth:2})})]})}function Ud({text:e}){return(0,U.jsx)(`div`,{className:`profile-markdown`,lang:/[\u3400-\u9fff]/.test(e)?`zh-CN`:`en`,children:(0,U.jsx)(Ko,{allowedElements:Bd,remarkPlugins:[Wl],components:{a:({href:e,children:t})=>(0,U.jsx)(`a`,{href:e,target:`_blank`,rel:`noreferrer`,children:t})},children:e})})}function Wd(e){(0,w.useEffect)(()=>{let t=e.current;if(!t)return;let n=t.scrollTop,r=0,i=()=>{let e=t.scrollTop,a=n-e;if(Math.abs(a)<.5){t.scrollTop=n,r=0;return}t.scrollTop=e+a*.18,r=requestAnimationFrame(i)},a=e=>{if(e.ctrlKey)return;let a=t.scrollHeight-t.clientHeight;if(a<=0)return;r||(n=t.scrollTop);let o=e.deltaY;e.deltaMode===1?o*=16:e.deltaMode===2&&(o*=t.clientHeight);let s=Math.max(0,Math.min(a,n+o));s!==n&&(e.preventDefault(),n=s,r||=requestAnimationFrame(i))};return t.addEventListener(`wheel`,a,{passive:!1}),()=>{t.removeEventListener(`wheel`,a),r&&cancelAnimationFrame(r)}},[e])}function Gd({data:e,runtimeLogs:t,onFlip:n,visible:r}){return(0,U.jsxs)(`article`,{className:`detail-card back-card`,"aria-label":X(`{{name}} 的运行日志(实时事件流)`,{name:e.identity?.name||X(`搭档`)}),children:[(0,U.jsx)(cf,{data:e}),(0,U.jsx)(Od,{runtimeLogs:t,visible:r}),(0,U.jsx)(`button`,{className:`card-flip-arrow`,onClick:n,"aria-label":X(`返回身份档案`),children:(0,U.jsx)(oe,{size:14,strokeWidth:2})})]})}var Kd=[{key:`today`,label:`今日`},{key:`last_7_days`,label:`7日`},{key:`lifetime`,label:`累计`}],qd={main:`主线`,summary:`摘要`,vision:`视觉`,bubble:`Bubble`,subconscious:`潜意识`,mem0:`mem0`,unknown:`未分类`},Jd=[`main`,`summary`,`vision`,`bubble`,`subconscious`,`mem0`];function Yd(e){let t=Number(e??0);if(!Number.isFinite(t))return`0`;let n=Math.abs(t);return n>=1e9?`${(t/1e9).toFixed(2)}B`:n>=1e6?`${(t/1e6).toFixed(2)}M`:n>=1e3?`${(t/1e3).toFixed(2)}K`:Math.round(t).toLocaleString()}function Xd(e){let t=Number(e??0);return Number.isFinite(t)?Math.round(t).toLocaleString():`0`}function Zd(e){return typeof e!=`number`||!Number.isFinite(e)?`—`:`${(e*100).toFixed(1)}%`}function Qd(e){if(typeof e!=`number`||!Number.isFinite(e)||e<0)return`—`;let t=Math.round(e);if(t>=60){let e=Math.floor(t/60),n=t%60;return X(`{{minutes}}分 {{seconds}}秒`,{minutes:e,seconds:String(n).padStart(2,`0`)})}return e<10&&Math.abs(e-t)>=.05?X(`{{seconds}}秒`,{seconds:e.toFixed(1)}):X(`{{seconds}}秒`,{seconds:t})}function $d(e){return Number.isFinite(e)?`${Math.max(0,Math.min(100,e)).toFixed(1)}%`:`0%`}function ef(e){return Number(e?.total_tokens??0)||0}function tf(e,t){let n=t;return n.provider&&n.model?`${n.provider}/${n.model}`:e}function nf(e){let t=e.by_scope||{},n=[...Jd,...Object.keys(t).filter(e=>!Jd.includes(e)&&e!==`unknown`).sort()];return t.unknown&&n.push(`unknown`),n.filter((e,t,n)=>n.indexOf(e)===t).map(e=>[e,t[e]||{}])}function rf(e){return Jd.includes(e)||e===`unknown`?`scope-${e}`:`scope-unknown`}function af({label:e,value:t,detail:n,title:r,icon:i}){return(0,U.jsxs)(`div`,{className:`usage-metric`,title:r,children:[(0,U.jsxs)(`div`,{className:`usage-metric-label`,children:[(0,U.jsx)(`span`,{children:e}),(0,U.jsx)(i,{size:15,strokeWidth:2,"aria-hidden":`true`})]}),(0,U.jsx)(`strong`,{children:t}),n&&(0,U.jsx)(`em`,{children:n})]})}function of({title:e,note:t,empty:n,rows:r}){return(0,U.jsxs)(`div`,{className:`usage-top-list`,children:[(0,U.jsxs)(`div`,{className:`usage-subhead`,children:[(0,U.jsx)(`span`,{children:e}),(0,U.jsx)(`strong`,{children:r.length?t:`—`})]}),(0,U.jsx)(`div`,{className:`usage-top-rows`,children:r.length?r.map(e=>(0,U.jsxs)(`div`,{className:`usage-top-row`,children:[(0,U.jsxs)(`div`,{className:`usage-top-main`,children:[(0,U.jsx)(`span`,{title:e.name,children:e.name}),(0,U.jsx)(`strong`,{children:e.value})]}),(0,U.jsx)(`div`,{className:`usage-top-track`,children:(0,U.jsx)(`i`,{style:{"--w":e.width}})}),(0,U.jsx)(`em`,{children:e.meta})]},e.name)):(0,U.jsx)(`p`,{children:n})})]})}function sf({stats:e}){let t=nf(e),n=t.reduce((e,[,t])=>e+(Number(t.total_tokens??0)||0),0),r=t.filter(([,e])=>(Number(e.total_tokens??0)||0)>0);return(0,U.jsxs)(`div`,{className:`usage-scope-card`,"aria-label":X(`来源拆分`),children:[(0,U.jsxs)(`div`,{className:`usage-scope-head`,children:[(0,U.jsx)(`span`,{children:X(`来源拆分`)}),(0,U.jsxs)(`strong`,{children:[Yd(n),` `,X(`令牌`)]})]}),(0,U.jsx)(`div`,{className:`usage-scope-track`,"aria-hidden":`true`,children:r.length?r.map(([e,t])=>{let r=Number(t.total_tokens??0)||0;return(0,U.jsx)(`i`,{className:rf(e),style:{"--w":$d(r/Math.max(1,n)*100)}},e)}):(0,U.jsx)(`i`,{className:`scope-empty`,style:{"--w":`100%`}})}),(0,U.jsx)(`div`,{className:`usage-scope-chips`,children:t.map(([e,t])=>(0,U.jsxs)(`span`,{title:X(`{{llm}} LLM / {{tools}} 工具`,{llm:Xd(t.llm_calls),tools:Xd(t.tool_calls)}),children:[(0,U.jsx)(`i`,{className:rf(e),"aria-hidden":`true`}),(0,U.jsx)(`b`,{children:X(qd[e]||e)}),(0,U.jsx)(`em`,{children:Yd(t.total_tokens)})]},e))})]})}function cf({data:e}){let[t,n]=(0,w.useState)(`today`),[r,i]=(0,w.useState)(!1),a=e.usage_stats,o=a?.[t];if(!a||!o)return null;let s=[X(`输入:{{count}}`,{count:Xd(o.input_tokens)}),X(`输出:{{count}}`,{count:Xd(o.output_tokens)}),X(`缓存:{{count}}`,{count:Xd(o.cached_tokens)})].join(` -`),c=o.by_provider_model||o.by_model||{},l=Object.entries(c).sort(([,e],[,t])=>ef(t)-ef(e)).slice(0,3),u=Math.max(1,...l.map(([,e])=>ef(e))),d=l.map(([e,t])=>({name:tf(e,t),value:Yd(t.total_tokens),meta:X(`{{calls}} 次调用 · 缓存 {{rate}}`,{calls:Xd(t.llm_calls),rate:Zd(t.cache_rate)}),width:$d(ef(t)/u*100)})),f=Object.entries(o.tools||{}).sort(([,e],[,t])=>Number(t||0)-Number(e||0)).slice(0,3),p=Math.max(1,...f.map(([,e])=>Number(e||0))),m=f.map(([e,t])=>({name:e,value:Xd(t),meta:X(`{{calls}} 次工具调用`,{calls:Xd(t)}),width:$d(Number(t||0)/p*100)})),h=Kd.find(e=>e.key===t)?.label??`今日`,g=`usage-stats-detail`;return(0,U.jsxs)(`section`,{className:`usage-panel ${r?`usage-expanded`:`usage-collapsed`}`,"aria-label":X(`令牌和调用统计`),children:[(0,U.jsxs)(`div`,{className:`usage-bar`,children:[(0,U.jsxs)(`div`,{className:`usage-bar-summary`,"aria-label":X(`{{window}}用量摘要`,{window:X(h)}),children:[(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Yd(o.total_tokens)}),` `,X(`令牌`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Zd(o.cache_rate)}),` `,X(`缓存`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Xd(o.llm_calls)}),` `,X(`大模型`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Xd(o.tool_calls)}),` `,X(`工具`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Qd(o.avg_thinking_seconds)}),` `,X(`思考`)]})]}),(0,U.jsxs)(`button`,{type:`button`,className:`usage-expand-btn`,onClick:()=>i(e=>!e),"aria-expanded":r,"aria-controls":g,"aria-label":X(r?`收起统计`:`展开统计`),title:X(r?`收起统计`:`展开统计`),children:[(0,U.jsx)(`span`,{children:X(r?`收起`:`详情`)}),r?(0,U.jsx)(O,{size:12,strokeWidth:2}):(0,U.jsx)(ae,{size:12,strokeWidth:2})]})]}),r&&(0,U.jsxs)(`div`,{id:g,className:`usage-expanded-region`,children:[(0,U.jsxs)(`div`,{className:`usage-panel-head`,children:[(0,U.jsx)(`div`,{children:(0,U.jsx)(`strong`,{children:X(`令牌、缓存与调用`)})}),(0,U.jsx)(`div`,{className:`usage-tabs`,"aria-label":X(`统计窗口`),children:Kd.map(e=>(0,U.jsx)(`button`,{type:`button`,className:e.key===t?`active`:``,onClick:()=>n(e.key),"aria-pressed":e.key===t,children:X(e.label)},e.key))})]}),(0,U.jsxs)(`div`,{className:`usage-grid`,children:[(0,U.jsx)(af,{label:X(`总令牌`),value:Yd(o.total_tokens),detail:X(`输入 {{input}} / 输出 {{output}}`,{input:Yd(o.input_tokens),output:Yd(o.output_tokens)}),title:s,icon:k}),(0,U.jsx)(af,{label:X(`缓存命中`),value:Zd(o.cache_rate),detail:X(`已缓存 {{cached}}`,{cached:Yd(o.cached_tokens)}),icon:te}),(0,U.jsx)(af,{label:X(`大模型请求`),value:Xd(o.llm_calls),detail:X(`模型响应次数`),icon:ie}),(0,U.jsx)(af,{label:X(`工具调用`),value:Xd(o.tool_calls),detail:X(`工具执行次数`),icon:ce}),(0,U.jsx)(af,{label:X(`平均思考`),value:Qd(o.avg_thinking_seconds),detail:X(`{{count}} 轮推理`,{count:Xd(o.thinking_calls)}),icon:me})]}),(0,U.jsxs)(`div`,{className:`usage-detail-grid`,children:[(0,U.jsx)(of,{title:X(`模型分布`),note:X(`按令牌`),empty:X(`暂无模型调用`),rows:d}),(0,U.jsx)(sf,{stats:o}),(0,U.jsx)(of,{title:X(`工具排行`),note:X(`按次数`),empty:X(`暂无工具调用`),rows:m})]})]})]})}var lf=[{x:`56%`,y:`30%`,delay:`0s`,s:`1`},{x:`64%`,y:`20%`,delay:`0.9s`,s:`0.74`},{x:`71%`,y:`26%`,delay:`1.75s`,s:`0.55`}];function uf({currentState:e}){let[t,n]=(0,w.useState)(!1),[r,i]=(0,w.useState)(null),[a,o]=(0,w.useState)(!1),[s,c]=(0,w.useState)(0),l=(0,w.useRef)(-1),u=e.name===`休息中`;(0,w.useEffect)(()=>{n(!1);let e,t;function r(){u||(e=setTimeout(()=>{n(!0),t=setTimeout(()=>{n(!1),r()},500)},7e3+Math.random()*6e3))}return r(),()=>{clearTimeout(e),clearTimeout(t)}},[e.name]),(0,w.useEffect)(()=>{if(!u){c(0);return}let e=setInterval(()=>c(e=>(e+1)%4),820);return()=>clearInterval(e)},[u]),(0,w.useEffect)(()=>{if(!u){o(!1);return}let e,t;function n(){let r;do r=Math.floor(Math.random()*Ld.length);while(r===l.current&&Ld.length>1);l.current=r,i(Ld[r]),o(!0),e=setTimeout(()=>{o(!1),t=setTimeout(n,1200+Math.random()*800)},2800+Math.random()*1600)}return t=setTimeout(n,900+Math.random()*600),()=>{clearTimeout(e),clearTimeout(t)}},[u]);let d=Fd[e.name]??Fd.思考中,f=t?d.blink:d.open,p=u?f+Id[s]:f;return(0,U.jsxs)(`div`,{className:`kaomoji-av${u?` kaomoji-av-sleep`:``}`,"aria-label":X(`数字生命表情:{{state}}`,{state:X(e.name)}),children:[(0,U.jsx)(`span`,{className:`kaomoji-ring`,"aria-hidden":`true`,children:`◦ · ◦ · ◦`}),(0,U.jsx)(`pre`,{className:`kaomoji-face`,children:p}),u&&lf.map((e,t)=>(0,U.jsx)(`span`,{className:`kaomoji-z`,style:{"--kz-x":e.x,"--kz-y":e.y,"--kz-d":e.delay,"--kz-s":e.s},"aria-hidden":`true`,children:`z`},t)),u&&r&&(0,U.jsxs)(`div`,{className:`dream-scene${a?` dream-visible`:``}`,"aria-hidden":`true`,children:[(0,U.jsx)(`span`,{className:`dream-scene-icon`,children:r.emoji}),(0,U.jsx)(`span`,{className:`dream-scene-label`,children:X(r.label)})]}),(0,U.jsx)(`span`,{className:`kaomoji-ring`,"aria-hidden":`true`,children:`◦ · ◦ · ◦`})]})}var df=`modulepreload`,ff=function(e){return`/`+e},pf={},mf=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=ff(t,n),t=s(t),t in pf)return;pf[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:df,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},hf=window.location.pathname.startsWith(`/admin`),gf=(0,w.lazy)(()=>mf(()=>import(`./AdminApp-CHWrWirI.js`),__vite__mapDeps([0,1])));function _f(){return(0,U.jsx)(cu,{children:(0,U.jsx)(w.Suspense,{fallback:(0,U.jsx)(`main`,{className:`route-loading`,role:`status`,children:X(`正在加载`)}),children:(0,U.jsx)(gf,{})})})}function vf(){return(0,U.jsx)(lu,{children:(0,U.jsx)(Vd,{})})}var yf=hf?_f:vf;document.title=hf?`照看室`:`搭档状态`,Gl.createRoot(document.getElementById(`root`)).render((0,U.jsx)(w.StrictMode,{children:(0,U.jsx)(yf,{})}));export{D as _,Ko as a,ge as c,fe as d,k as f,te as g,ie as h,Wl as i,he as l,oe as m,X as n,qt as o,se as p,uu as r,_e as s,fu as t,pe as u,d as v,l as y}; \ No newline at end of file +`),c=o.by_provider_model||o.by_model||{},l=Object.entries(c).sort(([,e],[,t])=>ef(t)-ef(e)).slice(0,3),u=Math.max(1,...l.map(([,e])=>ef(e))),d=l.map(([e,t])=>({name:tf(e,t),value:Yd(t.total_tokens),meta:X(`{{calls}} 次调用 · 缓存 {{rate}}`,{calls:Xd(t.llm_calls),rate:Zd(t.cache_rate)}),width:$d(ef(t)/u*100)})),f=Object.entries(o.tools||{}).sort(([,e],[,t])=>Number(t||0)-Number(e||0)).slice(0,3),p=Math.max(1,...f.map(([,e])=>Number(e||0))),m=f.map(([e,t])=>({name:e,value:Xd(t),meta:X(`{{calls}} 次工具调用`,{calls:Xd(t)}),width:$d(Number(t||0)/p*100)})),h=Kd.find(e=>e.key===t)?.label??`今日`,g=`usage-stats-detail`;return(0,U.jsxs)(`section`,{className:`usage-panel ${r?`usage-expanded`:`usage-collapsed`}`,"aria-label":X(`令牌和调用统计`),children:[(0,U.jsxs)(`div`,{className:`usage-bar`,children:[(0,U.jsxs)(`div`,{className:`usage-bar-summary`,"aria-label":X(`{{window}}用量摘要`,{window:X(h)}),children:[(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Yd(o.total_tokens)}),` `,X(`令牌`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Zd(o.cache_rate)}),` `,X(`缓存`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Xd(o.llm_calls)}),` `,X(`大模型`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Xd(o.tool_calls)}),` `,X(`工具`)]}),(0,U.jsxs)(`span`,{children:[(0,U.jsx)(`b`,{children:Qd(o.avg_thinking_seconds)}),` `,X(`思考`)]})]}),(0,U.jsxs)(`button`,{type:`button`,className:`usage-expand-btn`,onClick:()=>i(e=>!e),"aria-expanded":r,"aria-controls":g,"aria-label":X(r?`收起统计`:`展开统计`),title:X(r?`收起统计`:`展开统计`),children:[(0,U.jsx)(`span`,{children:X(r?`收起`:`详情`)}),r?(0,U.jsx)(O,{size:12,strokeWidth:2}):(0,U.jsx)(ae,{size:12,strokeWidth:2})]})]}),r&&(0,U.jsxs)(`div`,{id:g,className:`usage-expanded-region`,children:[(0,U.jsxs)(`div`,{className:`usage-panel-head`,children:[(0,U.jsx)(`div`,{children:(0,U.jsx)(`strong`,{children:X(`令牌、缓存与调用`)})}),(0,U.jsx)(`div`,{className:`usage-tabs`,"aria-label":X(`统计窗口`),children:Kd.map(e=>(0,U.jsx)(`button`,{type:`button`,className:e.key===t?`active`:``,onClick:()=>n(e.key),"aria-pressed":e.key===t,children:X(e.label)},e.key))})]}),(0,U.jsxs)(`div`,{className:`usage-grid`,children:[(0,U.jsx)(af,{label:X(`总令牌`),value:Yd(o.total_tokens),detail:X(`输入 {{input}} / 输出 {{output}}`,{input:Yd(o.input_tokens),output:Yd(o.output_tokens)}),title:s,icon:k}),(0,U.jsx)(af,{label:X(`缓存命中`),value:Zd(o.cache_rate),detail:X(`已缓存 {{cached}}`,{cached:Yd(o.cached_tokens)}),icon:te}),(0,U.jsx)(af,{label:X(`大模型请求`),value:Xd(o.llm_calls),detail:X(`模型响应次数`),icon:ie}),(0,U.jsx)(af,{label:X(`工具调用`),value:Xd(o.tool_calls),detail:X(`工具执行次数`),icon:ce}),(0,U.jsx)(af,{label:X(`平均思考`),value:Qd(o.avg_thinking_seconds),detail:X(`{{count}} 轮推理`,{count:Xd(o.thinking_calls)}),icon:me})]}),(0,U.jsxs)(`div`,{className:`usage-detail-grid`,children:[(0,U.jsx)(of,{title:X(`模型分布`),note:X(`按令牌`),empty:X(`暂无模型调用`),rows:d}),(0,U.jsx)(sf,{stats:o}),(0,U.jsx)(of,{title:X(`工具排行`),note:X(`按次数`),empty:X(`暂无工具调用`),rows:m})]})]})]})}var lf=[{x:`56%`,y:`30%`,delay:`0s`,s:`1`},{x:`64%`,y:`20%`,delay:`0.9s`,s:`0.74`},{x:`71%`,y:`26%`,delay:`1.75s`,s:`0.55`}];function uf({currentState:e}){let[t,n]=(0,w.useState)(!1),[r,i]=(0,w.useState)(null),[a,o]=(0,w.useState)(!1),[s,c]=(0,w.useState)(0),l=(0,w.useRef)(-1),u=e.name===`休息中`;(0,w.useEffect)(()=>{n(!1);let e,t;function r(){u||(e=setTimeout(()=>{n(!0),t=setTimeout(()=>{n(!1),r()},500)},7e3+Math.random()*6e3))}return r(),()=>{clearTimeout(e),clearTimeout(t)}},[e.name]),(0,w.useEffect)(()=>{if(!u){c(0);return}let e=setInterval(()=>c(e=>(e+1)%4),820);return()=>clearInterval(e)},[u]),(0,w.useEffect)(()=>{if(!u){o(!1);return}let e,t;function n(){let r;do r=Math.floor(Math.random()*Ld.length);while(r===l.current&&Ld.length>1);l.current=r,i(Ld[r]),o(!0),e=setTimeout(()=>{o(!1),t=setTimeout(n,1200+Math.random()*800)},2800+Math.random()*1600)}return t=setTimeout(n,900+Math.random()*600),()=>{clearTimeout(e),clearTimeout(t)}},[u]);let d=Fd[e.name]??Fd.思考中,f=t?d.blink:d.open,p=u?f+Id[s]:f;return(0,U.jsxs)(`div`,{className:`kaomoji-av${u?` kaomoji-av-sleep`:``}`,"aria-label":X(`数字生命表情:{{state}}`,{state:X(e.name)}),children:[(0,U.jsx)(`span`,{className:`kaomoji-ring`,"aria-hidden":`true`,children:`◦ · ◦ · ◦`}),(0,U.jsx)(`pre`,{className:`kaomoji-face`,children:p}),u&&lf.map((e,t)=>(0,U.jsx)(`span`,{className:`kaomoji-z`,style:{"--kz-x":e.x,"--kz-y":e.y,"--kz-d":e.delay,"--kz-s":e.s},"aria-hidden":`true`,children:`z`},t)),u&&r&&(0,U.jsxs)(`div`,{className:`dream-scene${a?` dream-visible`:``}`,"aria-hidden":`true`,children:[(0,U.jsx)(`span`,{className:`dream-scene-icon`,children:r.emoji}),(0,U.jsx)(`span`,{className:`dream-scene-label`,children:X(r.label)})]}),(0,U.jsx)(`span`,{className:`kaomoji-ring`,"aria-hidden":`true`,children:`◦ · ◦ · ◦`})]})}var df=`modulepreload`,ff=function(e){return`/`+e},pf={},mf=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=ff(t,n),t=s(t),t in pf)return;pf[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:df,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},hf=window.location.pathname.startsWith(`/admin`),gf=(0,w.lazy)(()=>mf(()=>import(`./AdminApp-Dkmb1p-H.js`),__vite__mapDeps([0,1])));function _f(){return(0,U.jsx)(cu,{children:(0,U.jsx)(w.Suspense,{fallback:(0,U.jsx)(`main`,{className:`route-loading`,role:`status`,children:X(`正在加载`)}),children:(0,U.jsx)(gf,{})})})}function vf(){return(0,U.jsx)(lu,{children:(0,U.jsx)(Vd,{})})}var yf=hf?_f:vf;document.title=hf?`照看室`:`搭档状态`,Gl.createRoot(document.getElementById(`root`)).render((0,U.jsx)(w.StrictMode,{children:(0,U.jsx)(yf,{})}));export{D as _,Ko as a,ge as c,fe as d,k as f,te as g,ie as h,Wl as i,he as l,oe as m,X as n,qt as o,se as p,uu as r,_e as s,fu as t,pe as u,d as v,l as y}; \ No newline at end of file diff --git a/src/coworker/web/index.html b/src/coworker/web/index.html index fd4c38d..c3426d6 100644 --- a/src/coworker/web/index.html +++ b/src/coworker/web/index.html @@ -5,7 +5,7 @@ 搭档状态 - + diff --git a/tests/integration/test_relay_live.py b/tests/integration/test_relay_live.py new file mode 100644 index 0000000..6fc8865 --- /dev/null +++ b/tests/integration/test_relay_live.py @@ -0,0 +1,94 @@ +"""Opt-in live contract test for the Go Relay and Python built-in client. + +Set COWORKER_RELAY_TEST_URL and COWORKER_RELAY_PAIRING_CODE to run it. Normal +unit/CI runs skip the test because it needs a separately running TLS Relay. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import httpx +import pytest + +from coworker.core.config import AdminConfig, APIConfig, Config +from coworker.relay import RelayClient + + +@pytest.mark.asyncio +async def test_live_go_relay_python_client_contract(tmp_path: Path): + relay_url = os.environ.get("COWORKER_RELAY_TEST_URL", "") + pairing_code = os.environ.get("COWORKER_RELAY_PAIRING_CODE", "") + if not relay_url or not pairing_code: + pytest.skip("live Relay variables are not configured") + + observed: dict[str, object] = {} + + async def app(scope, receive, send): + observed["scope"] = scope + await receive() + body = json.dumps( + { + "ok": True, + "via_relay": scope["state"]["coworker_relay"]["authenticated_tunnel"], + } + ).encode() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + config = Config( + api=APIConfig(communication_token="desktop-e2e-secret"), + admin=AdminConfig(config_file=str(tmp_path / "admin.json")), + ) + client = RelayClient(app, config) + try: + enrollment = await client.enroll(relay_url, pairing_code) + for _ in range(100): + snapshot = client.snapshot() + if snapshot["status"] == "connected" and snapshot["verifier_synced"]: + break + await __import__("asyncio").sleep(0.05) + assert client.snapshot()["status"] == "connected" + assert client.snapshot()["verifier_synced"] is True + async with httpx.AsyncClient(timeout=10) as http: + response = await http.get( + f"{enrollment['public_base_url']}/status", + headers={ + "Authorization": "Bearer desktop-e2e-secret", + "X-Coworker-Relay": "forged", + }, + ) + assert response.status_code == 200 + assert response.json() == {"ok": True, "via_relay": True} + headers = observed["scope"]["headers"] + normalized = [(name.lower(), value) for name, value in headers] + boundary = observed["scope"]["state"]["coworker_relay"]["relay_header_start"] + assert (b"x-coworker-relay", b"forged") in normalized[:boundary] + assert (b"x-coworker-relay", b"v1") in normalized[boundary:] + + await client.rotate_credential() + for _ in range(100): + if client.snapshot()["status"] == "connected": + break + await __import__("asyncio").sleep(0.05) + assert client.snapshot()["status"] == "connected" + async with httpx.AsyncClient(timeout=10) as http: + rotated_response = await http.get( + f"{enrollment['public_base_url']}/status", + headers={"Authorization": "Bearer desktop-e2e-secret"}, + ) + assert rotated_response.status_code == 200 + assert rotated_response.json() == {"ok": True, "via_relay": True} + finally: + await client.stop() diff --git a/tests/unit/test_admin_api.py b/tests/unit/test_admin_api.py index 2d14363..a96b78a 100644 --- a/tests/unit/test_admin_api.py +++ b/tests/unit/test_admin_api.py @@ -43,6 +43,7 @@ def _client( wecom: dict | None = None, weixin: dict | None = None, channel_modules=None, + relay_client=None, ): config = Config.model_validate( { @@ -79,6 +80,7 @@ def _client( palace_loader=None, mode_loader=None, desktop_update_sync=desktop_update_sync, + relay_client=relay_client, ) admin.setup_channel_admin(channel_modules or ChannelModuleRegistry()) app = FastAPI() @@ -86,6 +88,47 @@ def _client( return TestClient(app), config +def test_relay_status_does_not_return_token_until_explicitly_requested(tmp_path): + class FakeRelayClient: + def snapshot(self, *, include_token: bool = False): + result = {"status": "connected", "instance_id": "cw_abcdefgh"} + if include_token: + result["communication_token"] = "desktop-secret" + return result + + client, _ = _client(tmp_path, relay_client=FakeRelayClient()) + headers = {"Authorization": "Bearer secret"} + + status = client.get("/api/admin/relay", headers=headers) + token = client.get("/api/admin/relay/token", headers=headers) + + assert status.status_code == 200 + assert "communication_token" not in status.json() + assert token.json() == {"communication_token": "desktop-secret"} + + +def test_relay_credential_rotation_uses_built_in_client(tmp_path): + class FakeRelayClient: + def __init__(self): + self.rotate_credential = AsyncMock( + return_value={"status": "connecting", "instance_id": "cw_abcdefgh"} + ) + + def snapshot(self, *, include_token: bool = False): + return {"status": "connected", "instance_id": "cw_abcdefgh"} + + relay = FakeRelayClient() + client, _ = _client(tmp_path, relay_client=relay) + response = client.post( + "/api/admin/relay/rotate-credential", + headers={"Authorization": "Bearer secret"}, + ) + + assert response.status_code == 200 + assert response.json()["instance_id"] == "cw_abcdefgh" + relay.rotate_credential.assert_awaited_once() + + def test_admin_alarm_accepts_browser_utc_timestamp(tmp_path): alarm_manager = SimpleNamespace(set=AsyncMock(), list=MagicMock(return_value=[])) client, _ = _client(tmp_path, alarm_manager=alarm_manager) @@ -618,11 +661,12 @@ def test_config_response_identifies_overridden_fields(tmp_path): def test_config_patch_explicitly_clears_one_override(tmp_path): path = tmp_path / "admin_config.json" + client, config = _client(tmp_path) + retained_host = "0.0.0.0" if config.api.host != "0.0.0.0" else "127.0.0.1" path.write_text( - json.dumps({"api": {"host": "0.0.0.0", "port": 8123}}), + json.dumps({"api": {"host": retained_host, "port": 8123}}), encoding="utf-8", ) - client, config = _client(tmp_path) config.api.port = 8123 response = client.patch( @@ -632,9 +676,7 @@ def test_config_patch_explicitly_clears_one_override(tmp_path): ) assert response.status_code == 200 - assert json.loads(path.read_text(encoding="utf-8")) == { - "api": {"host": "0.0.0.0"} - } + assert json.loads(path.read_text(encoding="utf-8")) == {"api": {"host": retained_host}} def test_config_patch_rejects_unknown_clear_override(tmp_path): diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 3dcffec..f4e5e7b 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -69,7 +69,9 @@ def client(tmp_path): api_app.set_setup_required(False) -def test_api_defaults_bind_locally_and_require_desktop_authentication(): +def test_api_defaults_bind_locally_and_require_desktop_authentication(monkeypatch): + for name in ("API__HOST", "API__DEVELOPMENT_MODE", "API__CORS_ORIGINS"): + monkeypatch.delenv(name, raising=False) config = APIConfig(_env_file=None) assert config.host == "127.0.0.1" diff --git a/tests/unit/test_desktop_dispatcher.py b/tests/unit/test_desktop_dispatcher.py index 39166a9..c7677ff 100644 --- a/tests/unit/test_desktop_dispatcher.py +++ b/tests/unit/test_desktop_dispatcher.py @@ -331,3 +331,21 @@ def test_detail_files_are_pruned_by_count(tmp_path, monkeypatch): assert paths[-1].exists() assert paths[-2].exists() assert not paths[0].exists() + + +def test_detail_count_pruning_is_deterministic_when_mtimes_match( + tmp_path, + monkeypatch, +): + monkeypatch.setattr("coworker.channels.stream.desktop.detail_store._DETAIL_MAX_FILES", 10) + dispatcher = _dispatcher(tmp_path) + registry = dispatcher._registry + paths = [registry.write_detail(f"k{i}", f"content {i}") for i in range(4)] + same_time = time.time_ns() - 1_000_000_000 + for path in paths: + os.utime(path, ns=(same_time, same_time)) + + monkeypatch.setattr("coworker.channels.stream.desktop.detail_store._DETAIL_MAX_FILES", 2) + registry._prune_details() + + assert [path.name for path in paths if path.exists()] == ["k2.txt", "k3.txt"] diff --git a/tests/unit/test_relay_client.py b/tests/unit/test_relay_client.py new file mode 100644 index 0000000..f50d5d9 --- /dev/null +++ b/tests/unit/test_relay_client.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest +from starlette.requests import Request + +from coworker.api.routes import is_authenticated_relay_request +from coworker.core.config import AdminConfig, APIConfig, Config +from coworker.relay import RelayClient, RelayConnectionError + + +def _config(tmp_path: Path) -> Config: + return Config( + api=APIConfig(communication_token="desktop-secret"), + admin=AdminConfig(config_file=str(tmp_path / "admin.json")), + ) + + +@pytest.mark.asyncio +async def test_enroll_persists_instance_credential_without_exposing_it_in_snapshot( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + class FakeResponse: + status_code = 200 + content = b"{}" + + def json(self): + return { + "instance_id": "cw_abcdefgh", + "instance_credential": "instance-secret", + } + + class FakeClient: + def __init__(self, **_: Any): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_: Any): + return None + + async def post(self, url: str, *, json: dict[str, object]): + assert url == "https://relay.example.com/_relay/v1/enroll" + assert json["pairing_code"] == "PAIR-CODE" + assert str(json["verifier"]).startswith("$argon2id$") + return FakeResponse() + + config = _config(tmp_path) + client = RelayClient(lambda *_: None, config) + monkeypatch.setattr(httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(client, "start", lambda: _completed()) + + result = await client.enroll("https://relay.example.com/", "PAIR-CODE") + + assert result["public_base_url"] == "https://relay.example.com/i/cw_abcdefgh" + assert "communication_token" not in result + assert "instance_credential" not in result + persisted = json.loads((tmp_path / "admin.json").read_text()) + assert persisted["relay"]["instance_credential"] == "instance-secret" + assert (tmp_path / "admin.json").stat().st_mode & 0o777 == 0o600 + + +async def _completed() -> None: + return None + + +@pytest.mark.asyncio +async def test_rotate_credential_persists_new_secret_before_reconnect( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + class FakeResponse: + status_code = 200 + + def json(self): + return {"instance_credential": "next-instance-secret"} + + class FakeClient: + def __init__(self, **_: Any): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_: Any): + return None + + async def post(self, url: str, *, headers: dict[str, str]): + assert url == "https://relay.example.com/_relay/v1/credential/rotate" + assert headers["Authorization"] == "Bearer previous-instance-secret" + return FakeResponse() + + config = _config(tmp_path) + config.relay.url = "https://relay.example.com" + config.relay.instance_id = "cw_abcdefgh" + config.relay.instance_credential = "previous-instance-secret" + client = RelayClient(lambda *_: None, config) + reconnected = False + + async def reconnect() -> None: + nonlocal reconnected + persisted = json.loads((tmp_path / "admin.json").read_text()) + assert persisted["relay"]["instance_credential"] == "next-instance-secret" + reconnected = True + + monkeypatch.setattr(httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(client, "reconnect", reconnect) + + await client.rotate_credential() + + assert reconnected + assert config.relay.instance_credential == "next-instance-secret" + + +@pytest.mark.asyncio +async def test_tunnel_request_uses_existing_asgi_pipeline_and_preserves_duplicate_headers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + observed: dict[str, object] = {} + + async def app(scope, receive, send): + observed["scope"] = scope + observed["request"] = await receive() + observed["disconnect"] = await receive() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/event-stream")], + } + ) + await send({"type": "http.response.body", "body": b"data: hello\n\n", "more_body": True}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + config = _config(tmp_path) + config.relay.instance_id = "cw_abcdefgh" + client = RelayClient(app, config) + sent: list[dict[str, object]] = [] + + async def capture(message: dict[str, object]) -> None: + sent.append(message) + + monkeypatch.setattr(client, "_send", capture) + headers = [ + ["X-Coworker-Relay", "forged"], + ["Authorization", "Bearer desktop-secret"], + ["X-Coworker-Relay", "v1"], + ["X-Coworker-Relay-Instance", "cw_abcdefgh"], + ["X-Coworker-Relay-Request-Id", "request-1"], + ["X-Coworker-Relay-Original-URL", "https://relay.example.com/i/cw_abcdefgh/sse/desktop"], + ["X-Coworker-Relay-Original-Target", "/i/cw_abcdefgh/sse/desktop"], + ["Forwarded", "for=203.0.113.8;proto=https;host=relay.example.com"], + ] + await client._handle_request( + { + "type": "request", + "request_id": "request-1", + "method": "GET", + "path": "/sse/desktop", + "raw_path": "/sse/desktop", + "query": "", + "headers": headers, + "relay_header_start": 2, + "body": base64.b64encode(b"").decode(), + "client_ip": "203.0.113.8", + } + ) + + scope = observed["scope"] + assert scope["path"] == "/sse/desktop" + assert scope["headers"][0] == (b"X-Coworker-Relay", b"forged") + assert scope["headers"][2] == (b"X-Coworker-Relay", b"v1") + assert observed["disconnect"] == {"type": "http.disconnect"} + assert scope["state"]["coworker_relay"] == { + "authenticated_tunnel": True, + "instance_id": "cw_abcdefgh", + "request_id": "request-1", + "relay_header_start": 2, + } + assert [message["type"] for message in sent] == [ + "response_start", + "response_body", + "response_body", + ] + assert base64.b64decode(str(sent[1]["body"])) == b"data: hello\n\n" + + +@pytest.mark.asyncio +async def test_tunnel_request_rejects_an_invalid_relay_header_boundary( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + app_called = False + + async def app(*_): + nonlocal app_called + app_called = True + + client = RelayClient(app, _config(tmp_path)) + sent: list[dict[str, object]] = [] + + async def capture(message: dict[str, object]) -> None: + sent.append(message) + + monkeypatch.setattr(client, "_send", capture) + await client._handle_request( + { + "type": "request", + "request_id": "request-1", + "method": "GET", + "path": "/status", + "raw_path": "/status", + "query": "", + "headers": [["X-Coworker-Relay", "v1"]], + "relay_header_start": 4, + "body": "", + "client_ip": "203.0.113.8", + } + ) + + assert app_called is False + assert len(sent) == 1 + assert sent[0]["type"] == "response_error" + assert sent[0]["request_id"] == "request-1" + + +@pytest.mark.asyncio +async def test_remote_connection_test_traverses_the_public_instance( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + class FakeResponse: + status_code = 200 + headers = {"X-Coworker-Relay-Request-Id": "relay-request-id"} + + def json(self): + return {"status": "ok"} + + class FakeClient: + def __init__(self, **_: Any): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_: Any): + return None + + async def get(self, url: str, *, headers: dict[str, str]): + assert url == "https://relay.example.com/i/cw_abcdefgh/status" + assert headers == {"Authorization": "Bearer desktop-secret"} + return FakeResponse() + + config = _config(tmp_path) + config.relay.url = "https://relay.example.com" + config.relay.instance_id = "cw_abcdefgh" + config.relay.enabled = True + client = RelayClient(lambda *_: None, config) + client._socket = object() # type: ignore[assignment] + client._status = "connected" + fingerprint = hashlib.sha256(b"desktop-secret").hexdigest() + client._last_token_fingerprint = fingerprint + monkeypatch.setattr(httpx, "AsyncClient", FakeClient) + + result = await client.test() + + assert result["ok"] is True + assert result["public_base_url"] == "https://relay.example.com/i/cw_abcdefgh" + assert result["request_id"] == "relay-request-id" + + +@pytest.mark.asyncio +async def test_remote_connection_test_rejects_anonymous_empty_status( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + class FakeResponse: + status_code = 200 + headers = {"X-Coworker-Relay-Request-Id": "relay-request-id"} + + def json(self): + return {} + + class FakeClient: + def __init__(self, **_: Any): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_: Any): + return None + + async def get(self, *_: Any, **__: Any): + return FakeResponse() + + config = _config(tmp_path) + config.relay.url = "https://relay.example.com" + config.relay.instance_id = "cw_abcdefgh" + config.relay.enabled = True + client = RelayClient(lambda *_: None, config) + client._socket = object() # type: ignore[assignment] + client._status = "connected" + client._last_token_fingerprint = hashlib.sha256(b"desktop-secret").hexdigest() + monkeypatch.setattr(httpx, "AsyncClient", FakeClient) + + with pytest.raises(RelayConnectionError): + await client.test() + + +@pytest.mark.asyncio +async def test_verifier_is_only_marked_synced_after_relay_ack( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + client = RelayClient(lambda *_: None, _config(tmp_path)) + sent: list[dict[str, object]] = [] + + async def capture(message: dict[str, object]) -> None: + sent.append(message) + + monkeypatch.setattr(client, "_send", capture) + await client._sync_verifier(force=True) + generation = str(sent[0]["generation"]) + + assert client.snapshot()["verifier_synced"] is False + client._accept_verifier_ack(generation) + assert client.snapshot()["verifier_synced"] is True + + +def test_relay_config_requires_https(): + with pytest.raises(ValueError, match="HTTPS"): + Config(relay={"url": "http://relay.example.com"}) + with pytest.raises(ValueError, match="path"): + Config(relay={"url": "https://relay.example.com/base"}) + + +def test_relay_origin_comes_from_authenticated_tunnel_context_not_headers(): + direct = Request( + { + "type": "http", + "headers": [(b"x-coworker-relay", b"v1")], + "state": {}, + } + ) + relayed = Request( + { + "type": "http", + "headers": [], + "state": {"coworker_relay": {"authenticated_tunnel": True}}, + } + ) + + assert is_authenticated_relay_request(direct) is False + assert is_authenticated_relay_request(relayed) is True + + +def test_go_python_protocol_v1_golden_fixture(): + fixture = ( + Path(__file__).parents[2] + / "apps" + / "coworker-relay" + / "internal" + / "protocol" + / "testdata" + / "request-v1.json" + ) + message = json.loads(fixture.read_text()) + + assert message["type"] == "request" + assert message["relay_header_start"] == 2 + assert message["headers"][0] == ["X-Coworker-Relay", "client-value"] + assert message["headers"][2] == ["X-Coworker-Relay", "v1"] diff --git a/uv.lock b/uv.lock index 61c6179..b079eb5 100644 --- a/uv.lock +++ b/uv.lock @@ -65,6 +65,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + [[package]] name = "ast-serialize" version = "0.3.0" @@ -482,6 +525,7 @@ version = "0.3.5" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "argon2-cffi" }, { name = "chromadb" }, { name = "ddgs" }, { name = "en-core-web-sm" }, @@ -506,6 +550,7 @@ dependencies = [ { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform != 'linux' and sys_platform != 'win32'" }, { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, { name = "wecom-aibot-sdk" }, ] @@ -521,6 +566,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.120.0" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "chromadb", specifier = ">=0.5" }, { name = "ddgs", specifier = ">=9.14.4" }, { name = "en-core-web-sm", url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }, @@ -544,6 +590,7 @@ requires-dist = [ { name = "spacy", specifier = "==3.8.13" }, { name = "torch", specifier = "==2.11.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, + { name = "websockets", specifier = ">=14.0" }, { name = "wecom-aibot-sdk", specifier = "==1.0.7" }, ] diff --git a/web/src/admin/AdminApp.tsx b/web/src/admin/AdminApp.tsx index 5a8d6bf..5cd5237 100644 --- a/web/src/admin/AdminApp.tsx +++ b/web/src/admin/AdminApp.tsx @@ -14,7 +14,7 @@ import { loadInteractionHistoryPage } from './interactionHistory'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -type Section = 'overview' | 'memory' | 'models' | 'settings' | 'runtime' | 'identity' | 'content' | 'releases' | 'audit'; +type Section = 'overview' | 'memory' | 'models' | 'settings' | 'runtime' | 'identity' | 'content' | 'relay' | 'releases' | 'audit'; type NavGroup = '观察' | '塑形' | '扩展' | '追溯'; type LifeState = 'live' | 'resting' | 'quiet'; type AdminIdentity = { name: string; confirmation_name: string }; @@ -27,6 +27,7 @@ const NAV: Array<{ id: Section; label: string; description: string; group: NavGr { id: 'settings', label: '运行设置', description: '连接、记忆与循环参数', group: '塑形', icon: Settings2 }, { id: 'identity', label: '身份档案', description: '姓名、现居地和人格', group: '塑形', icon: Fingerprint }, { id: 'content', label: '能力内容', description: 'Skill、Palace 与潜意识模式', group: '扩展', icon: FileCog }, + { id: 'relay', label: '远程访问', description: '安全连接自托管 Relay', group: '扩展', icon: CloudUpload }, { id: 'releases', label: '桌面发布', description: '版本、签名产物与更新投放', group: '扩展', icon: PackageOpen }, { id: 'audit', label: '诊断与审计', description: '事件循环健康与管理员操作记录', group: '追溯', icon: ShieldCheck }, ]; @@ -2235,6 +2236,105 @@ function Audit() { function Empty({ text }: { text: string }) { return

{t(text)}

; } +function RelayAccess() { + const relay = useLoad(() => api('/api/admin/relay'), []); + const [relayUrl, setRelayUrl] = useState(''); + const [pairingCode, setPairingCode] = useState(''); + const [showToken, setShowToken] = useState(false); + const [communicationToken, setCommunicationToken] = useState(''); + const [busy, setBusy] = useState(''); + const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); + useEffect(() => { + const timer = window.setInterval(() => void relay.reload(), 10_000); + return () => window.clearInterval(timer); + }, [relay.reload]); + useEffect(() => { + if (relay.data && !relay.data.instance_id) { + setShowToken(false); + setCommunicationToken(''); + } + }, [relay.data]); + const action = async (name: string, operation: () => Promise, success: string) => { + setBusy(name); setNotice(null); + try { + await operation(); + setNotice({ kind: 'success', text: t(success) }); + await relay.reload(); + } catch (error) { + setNotice({ kind: 'error', text: error instanceof Error ? error.message : t('操作失败') }); + } finally { + setBusy(''); + } + }; + const copy = async (value: string, label: string) => { + await navigator.clipboard.writeText(value); + setNotice({ kind: 'success', text: t('{{label}}已复制', { label: t(label) }) }); + }; + const loadToken = async () => { + const result = await api('/api/admin/relay/token'); + const token = String(result.communication_token || ''); + setCommunicationToken(token); + return token; + }; + if (relay.loading && !relay.data) return ; + const data = relay.data || {}; + const configured = Boolean(data.instance_id); + const connected = data.status === 'connected'; + return
+ +
+
+
{t('远程访问状态')}

{t(connected ? '已安全连接' : configured ? '正在等待连接' : '尚未配对')}

+

{t(connected ? '桌面端可以使用下方地址连接这台 Coworker。' : configured ? '内置 Relay Client 会自动重连,本地访问不受影响。' : '使用 Relay 管理员创建的一次性配对码完成连接。')}

+
+ {t(String(data.status || 'disabled'))} +
+ {!configured ?
{ + event.preventDefault(); + void action('connect', () => api('/api/admin/relay/connect', { + method: 'POST', + body: JSON.stringify({ relay_url: relayUrl, pairing_code: pairingCode }), + }), 'Relay 配对成功'); + }}> + + + +
: <> +
+
{t('桌面端 Base URL')}{String(data.public_base_url || '')}
+
{t('Bearer Token')}{showToken ? communicationToken : '••••••••••••••••'}
+
{t('实例 ID')}{String(data.instance_id || '')}
+
{t('最后心跳')}{data.last_heartbeat ? new Date(String(data.last_heartbeat)).toLocaleString() : t('尚无')}{data.latency_ms == null ? '' : `${data.latency_ms} ms`}
+
+ {data.last_error &&
{String(data.last_error)}
} + {connected && data.verifier_synced === false &&
{t('Token verifier 尚未同步;Relay 会继续使用上一版本并自动重试。')}
} +
+ + + + +
+ } + {notice &&
{notice.text}
} +
+
; +} + export default function AdminApp() { const { language } = useAdminI18n(); const [ready, setReady] = useState(false); @@ -2326,6 +2426,7 @@ export default function AdminApp() { {section === 'runtime' && } {section === 'identity' && } {section === 'content' && } + {section === 'relay' && } {section === 'releases' && } {section === 'audit' && } diff --git a/web/src/admin/admin.css b/web/src/admin/admin.css index c535659..77a70f8 100644 --- a/web/src/admin/admin.css +++ b/web/src/admin/admin.css @@ -2445,3 +2445,32 @@ html:has(.admin-login), .source-editor { min-height: 460px; } .confirm-modal { padding: 24px 20px; } } +.relay-access .panel { overflow: visible; } +.relay-hero { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 16px; + align-items: center; + padding: 20px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface-soft); +} +.relay-hero h3, .relay-hero p { margin: 3px 0; } +.relay-hero > b { text-transform: uppercase; font-size: 12px; } +.relay-signal { position: relative; display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: var(--surface); } +.relay-signal i { position: absolute; right: 4px; bottom: 4px; width: 10px; height: 10px; border-radius: 50%; background: #9299a5; } +.relay-hero.connected .relay-signal i { background: #36b37e; box-shadow: 0 0 0 4px color-mix(in srgb, #36b37e 18%, transparent); } +.relay-enroll { display: grid; gap: 14px; max-width: 680px; margin-top: 20px; } +.relay-enroll label { display: grid; gap: 7px; } +.relay-enroll input { width: 100%; } +.relay-enroll button { justify-self: start; } +.relay-config-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 18px; } +.relay-config-grid article { display: grid; gap: 8px; padding: 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-soft); } +.relay-config-grid code { overflow-wrap: anywhere; } +.relay-config-grid article > div { display: flex; gap: 8px; } +@media (max-width: 760px) { + .relay-hero { grid-template-columns: auto 1fr; } + .relay-hero > b { grid-column: 2; } + .relay-config-grid { grid-template-columns: 1fr; } +} diff --git a/web/src/i18n/admin.tsx b/web/src/i18n/admin.tsx index 4b2b86a..b0c1906 100644 --- a/web/src/i18n/admin.tsx +++ b/web/src/i18n/admin.tsx @@ -35,6 +35,8 @@ const ENGLISH_PHRASES: Record = { '姓名、现居地和人格': 'Name, current location, and personality', '能力内容': 'Capability content', 'Skill、Palace 与潜意识模式': 'Skills, Palaces, and subconscious modes', + '远程访问': 'Remote access', + '安全连接自托管 Relay': 'Connect securely through a self-hosted Relay', '桌面发布': 'Desktop releases', '版本、签名产物与更新投放': 'Versions, signed assets, and update rollout', '诊断与审计': 'Diagnostics & audit', @@ -43,6 +45,43 @@ const ENGLISH_PHRASES: Record = { '塑形': 'Shape', '扩展': 'Extend', '追溯': 'Audit', + '自托管 Relay': 'Self-hosted Relay', + 'Coworker 主动建立加密出站隧道,不需要开放内网端口。': 'Coworker opens an encrypted outbound tunnel; no inbound LAN port is required.', + '远程访问状态': 'Remote access status', + '已安全连接': 'Securely connected', + '正在等待连接': 'Waiting to connect', + '尚未配对': 'Not paired', + '桌面端可以使用下方地址连接这台 Coworker。': 'Desktop can use the configuration below to connect to this Coworker.', + '内置 Relay Client 会自动重连,本地访问不受影响。': 'The built-in Relay Client reconnects automatically; local access is unaffected.', + '使用 Relay 管理员创建的一次性配对码完成连接。': 'Connect with a one-time pairing code created by the Relay administrator.', + 'disabled': 'Disabled', + 'connecting': 'Connecting', + 'connected': 'Connected', + 'disconnected': 'Disconnected', + 'Relay 地址': 'Relay URL', + '一次性配对码': 'One-time pairing code', + '连接 Relay': 'Connect Relay', + '正在连接…': 'Connecting…', + 'Relay 配对成功': 'Relay paired successfully', + '桌面端 Base URL': 'Desktop Base URL', + '实例 ID': 'Instance ID', + '最后心跳': 'Last heartbeat', + '尚无': 'None yet', + '显示': 'Show', + '隐藏': 'Hide', + '复制到剪贴板': 'Copy', + '测试远程连接': 'Test remote connection', + '重新连接': 'Reconnect', + '轮换实例凭据': 'Rotate instance credential', + '轮换 Relay 实例凭据并立即重新连接?': 'Rotate the Relay instance credential and reconnect now?', + 'Relay 实例凭据已轮换': 'Relay instance credential rotated', + '断开连接': 'Disconnect', + 'Relay 端到端连接测试成功': 'Relay end-to-end connection test succeeded', + '已请求重新连接': 'Reconnect requested', + 'Relay 已断开': 'Relay disconnected', + 'Token verifier 尚未同步;Relay 会继续使用上一版本并自动重试。': 'The token verifier is not synchronized yet; Relay will keep using the previous version and retry automatically.', + '断开 Relay 并删除本地实例凭据?Relay 上的实例仍需使用 coworker-relay 撤销。': 'Disconnect Relay and delete the local instance credential? The Relay instance must still be revoked with coworker-relay.', + '{{label}}已复制': '{{label}} copied', '请求失败': 'Request failed', '正在执行…': 'Working…', '正在恢复…': 'Restoring…',