diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e6ebfa..a90aec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: - '.github/actions/**' web: - 'apps/web/ui/**' + - 'apps/desktop/**' + - 'scripts/check_conversation_selection.cjs' - '.github/workflows/ci.yml' - '.github/actions/**' @@ -87,6 +89,12 @@ jobs: working-directory: apps/web/ui run: npm ci + - name: Check shared settings and client preferences + if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }} + run: | + node --test apps/desktop/test/policy.test.cjs + node scripts/check_conversation_selection.cjs + - name: Check TypeScript id: typecheck if: ${{ !cancelled() && steps.ui-deps.outcome == 'success' }} diff --git a/.github/workflows/release-images.yml b/.github/workflows/release-images.yml index 134d73a..db4cdf9 100644 --- a/.github/workflows/release-images.yml +++ b/.github/workflows/release-images.yml @@ -55,6 +55,11 @@ jobs: TAG="${TAG#refs/tags/}" TAG="${TAG//\//-}" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Check settings interface policy before release + run: node --test apps/desktop/test/policy.test.cjs - name: Build application bundle run: python scripts/build_release_bundle.py --version "${{ steps.version.outputs.version }}" --repository "${{ github.repository }}" - uses: actions/upload-artifact@v4 @@ -68,6 +73,7 @@ jobs: images: name: ${{ matrix.image }} (${{ matrix.target.arch }}) + needs: bundle if: ${{ github.run_attempt == 1 && github.run_number <= fromJSON(vars.MUTEKI_RELEASE_LAST_RUN_NUMBER || '0') }} timeout-minutes: 30 strategy: @@ -270,6 +276,131 @@ jobs: if-no-files-found: error retention-days: 7 + desktop-macos: + name: Muteki Desktop macOS (${{ matrix.target.arch }}) + needs: bundle + if: ${{ github.run_attempt == 1 && github.run_number <= fromJSON(vars.MUTEKI_RELEASE_LAST_RUN_NUMBER || '0') }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + target: + - arch: arm64 + runner: macos-15 + - arch: x64 + runner: macos-15-intel + runs-on: ${{ matrix.target.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24.21.0" + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/web/ui/package-lock.json + - uses: actions/setup-python@v5 + with: + python-version: "3.13.13" + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: uv.lock + - name: Verify runner architecture + env: + EXPECTED_ARCH: ${{ matrix.target.arch }} + run: | + set -euo pipefail + case "$EXPECTED_ARCH:$(uname -m)" in + arm64:arm64|x64:x86_64) ;; + *) echo "Runner architecture does not match $EXPECTED_ARCH" >&2; exit 1 ;; + esac + - name: Install desktop dependencies + working-directory: apps/desktop + run: npm ci + - name: Install web dependencies + working-directory: apps/web/ui + run: npm ci + - name: Check desktop + working-directory: apps/desktop + run: | + npm test + npm run typecheck + npm run build + - name: Build relocatable desktop runtime + run: python scripts/build_desktop_runtime.py + - name: Package macOS desktop release + working-directory: apps/desktop + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: npx electron-builder --mac dmg zip --${{ matrix.target.arch }} --publish never -c.mac.identity=- + - name: Verify packaged application + env: + EXPECTED_ARCH: ${{ matrix.target.arch }} + run: | + set -euo pipefail + app="$(find apps/desktop/dist -maxdepth 3 -name Muteki.app -print -quit)" + test -n "$app" + codesign --verify --deep --strict "$app" + file "$app/Contents/Resources/native/macos-speech.node" + case "$EXPECTED_ARCH" in + arm64) file "$app/Contents/Resources/runtime/node" | grep -F 'arm64' ;; + x64) file "$app/Contents/Resources/runtime/node" | grep -F 'x86_64' ;; + esac + test -n "$(find apps/desktop/dist -maxdepth 1 -name '*.dmg' -print -quit)" + test -n "$(find apps/desktop/dist -maxdepth 1 -name '*.zip' -print -quit)" + - name: Run packaged desktop acceptance check + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + from pathlib import Path + import subprocess + + apps = list(Path("apps/desktop/dist").glob("mac*/Muteki.app")) + if len(apps) != 1: + raise SystemExit(f"Expected one packaged Muteki.app, found {apps!r}") + app = apps[0] + expected_version = json.loads(Path("apps/desktop/package.json").read_text())["version"] + profile = Path(os.environ["RUNNER_TEMP"]) / "muteki-desktop-acceptance" + profile.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["MUTEKI_DESKTOP_USER_DATA"] = str(profile / "desktop") + env["MUTEKI_DESKTOP_REGISTER_PROTOCOL"] = "0" + for key in ( + "MUTEKI_DESKTOP_URL", "MUTEKI_ENVIRONMENT_ROOT", "MUTEKI_DESKTOP_WORKSPACE", + "NODE_OPTIONS", "NODE_PATH", "ELECTRON_RUN_AS_NODE", "PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", + ): + env.pop(key, None) + executable = app / "Contents/MacOS/Muteki" + log_path = profile / "acceptance.log" + try: + with log_path.open("wb") as log: + result = subprocess.run( + [str(executable), "--muteki-channel=candidate", f"--muteki-environment-root={profile}", "--muteki-self-check"], + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, env=env, timeout=180, check=False, + ) + except subprocess.TimeoutExpired: + tail = log_path.read_text(errors="replace")[-12000:] + raise SystemExit(f"Packaged app acceptance exceeded 180 seconds\\n{tail}") + result_path = profile / "candidate-result.json" + payload = json.loads(result_path.read_text()) if result_path.exists() else {} + if result.returncode != 0 or payload.get("status") != "passed" or payload.get("version") != expected_version: + tail = log_path.read_text(errors="replace")[-12000:] + raise SystemExit(f"Packaged app acceptance failed (exit {result.returncode}); expected version={expected_version!r}; result={payload!r}\\n{tail}") + print(f"Packaged desktop acceptance passed for version {payload.get('version')}") + PY + - name: Upload macOS desktop assets + uses: actions/upload-artifact@v4 + with: + name: desktop-macos-${{ matrix.target.arch }} + path: | + apps/desktop/dist/*.dmg + apps/desktop/dist/*.zip + if-no-files-found: error + retention-days: 7 + worker-base: name: muteki-worker base (${{ matrix.target.arch }}) timeout-minutes: 45 @@ -685,15 +816,21 @@ jobs: publish: timeout-minutes: 5 - needs: [bundle, merge, merge-worker] + needs: [bundle, images, desktop-macos, merge, merge-worker] # Tag push only — workflow_dispatch must never create a formal GitHub Release. - if: ${{ always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.bundle.result == 'success' && needs.merge.result == 'success' && needs.merge-worker.result == 'success' }} + if: ${{ always() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.bundle.result == 'success' && needs.images.result == 'success' && needs.desktop-macos.result == 'success' && needs.merge.result == 'success' && needs.merge-worker.result == 'success' }} runs-on: ubuntu-24.04 steps: - uses: actions/download-artifact@v4 with: name: muteki-release-bundle path: dist + - name: Download macOS desktop assets + uses: actions/download-artifact@v4 + with: + pattern: desktop-macos-* + path: dist/desktop + merge-multiple: true - name: Download merged image digests uses: actions/download-artifact@v4 with: @@ -729,11 +866,11 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Attach bundle + image-identity so the GitHub Release lines up with GHCR digests. + # Attach desktop, bundle + image-identity so the GitHub Release lines up with GHCR digests. TAG="${{ github.ref_name }}" RELEASE_ARGS=(--repo "${{ github.repository }}" --verify-tag --generate-notes --title "Muteki $TAG") if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then RELEASE_ARGS+=(--prerelease) fi - gh release create "$TAG" dist/muteki-*.tar.gz dist/release-manifest.json dist/image-identity.json \ + gh release create "$TAG" dist/muteki-*.tar.gz dist/release-manifest.json dist/image-identity.json dist/desktop/*.dmg dist/desktop/*.zip \ "${RELEASE_ARGS[@]}" diff --git a/.gitignore b/.gitignore index 6be4707..cd2c6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,5 @@ data/ /docker/tsec-hosted/.build-ctx/ /apps/web/ui/.next-readme/ +/apps/web/ui/.next-desktop/ +/apps/desktop/dist-dev/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 055d6cb..c95430c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable public release changes are tracked here. +## 0.4.1 - 2026-10-09 + +### Added + +- Added macOS desktop packages for Apple silicon and Intel, with an isolated local workspace, bundled Python/Node/Web runtime, and remote service connections. +- Added shared settings for Web and desktop, including Agent accounts, model visibility, permissions, keyboard shortcuts, notifications, and appearance preferences. +- Added provider usage and limit views, a usage ledger, notification inbox, conversation checkpoints, subagent activity, and scoped browser and computer-control tools. + +### Changed + +- Updated the CTF and penetration-testing workspaces with shared navigation, Worker extensions, evidence-backed reports, and clearer report printing and export. +- Improved Agent compatibility, model discovery, session recovery, approvals, and process cleanup across supported runtimes. +- Refined conversation navigation, drag-and-drop ordering, message rendering, previews, and context controls. The default Web view continues to show CTF and penetration testing; conversation and competition workspaces remain optional. +- Release versions and default image tags now use `0.4.1` / `v0.4.1`. Container images continue to support Linux amd64 and arm64; desktop packages in this release are macOS only. + +### Fixed + +- Fixed the Web interface freezing while deleting an Agent credential and detaching its conversation and Worker references, as reported with Ubuntu container deployments ([#199](https://github.com/FishCodeTech/muteki/issues/199)). +- Fixed desktop notification ownership, permission persistence, foreground read tracking, and controlled overlay interactions. +- Fixed Worker settings navigation, stale draft runtime panels, local directory error feedback, and findings counts for older penetration-testing reports. + +### Upgrade + +- Use `muteki upgrade v0.4.1` or `./run.sh upgrade v0.4.1` for a managed Web installation, and add `--compose` for a container deployment. Existing `.env`, `state/`, credentials, and sessions remain outside the release bundle. +- Download the macOS `.dmg` or `.zip` matching your Mac from the release page. Windows and Linux desktop packages are deferred. +- The macOS packages are ad-hoc signed and are not notarized. Install desktop updates manually; the signed-candidate update flow requires a stable Developer ID signature. + ## 0.4.0 - 2026-09-28 ### Added diff --git a/README.md b/README.md index 5199fa9..c77615a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@

- English · 简体中文 · Changelog + English · 简体中文 · Changelog · Dev build guide

@@ -74,13 +74,17 @@ After a month of engineering work, tuning, and fixes, Muteki is open source with ## Release notes -The current version is **0.4.0**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes. +The current version is **0.4.1**. See [CHANGELOG.md](CHANGELOG.md) for new features, behavior changes, removals, and upgrade notes. + +The [0.4.1 release](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) includes a macOS desktop app for Apple silicon and Intel, with a bundled local service and support for remote Muteki services. Windows and Linux desktop packages are not included in this release. Web and container deployments remain available. + +The macOS packages are ad-hoc signed and are not notarized. Install desktop updates manually; the in-app signed-candidate update flow requires a stable Developer ID signature. --- ## Quick start -> **Start with the single-challenge CTF workspace.** The home page shows this workspace by default. Conversation, competitions, and custom extensions are still being tested and can be enabled as described below. The Web pentest mode is being reworked and is currently unavailable. +> **Start with the CTF or penetration-testing workspace.** Both appear on the home page by default. Conversation and competition workspaces are optional and can be enabled in appearance settings as described below. ### 1. Prepare the environment and start the Web app @@ -291,7 +295,7 @@ The UI remains at `http://localhost:3001` by default. `MUTEKI_HOST_DATA_ROOT` mu ```bash ./run.sh upgrade --check # Check the latest stable release ./run.sh install # Set up a managed installation -muteki upgrade v0.4.0 # Install this release +muteki upgrade v0.4.1 # Install this release muteki rollback # Return to the previous installed release muteki version # Show version and installation kind ``` diff --git a/README_CN.md b/README_CN.md index 8a2e4b1..ba4a9e1 100644 --- a/README_CN.md +++ b/README_CN.md @@ -23,7 +23,7 @@

- English · 简体中文 · 版本更新 + English · 简体中文 · 版本更新 · 开发版编译与更新

@@ -95,13 +95,17 @@ tsecbench 托管模式下+deepseek-flash,排名13 ## 版本更新 -当前版本为 **0.4.0**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。 +当前版本为 **0.4.1**。新增功能、行为变化、移除内容及升级说明见 [CHANGELOG.md](CHANGELOG.md)。 + +[0.4.1 发布页](https://github.com/FishCodeTech/muteki/releases/tag/v0.4.1) 提供 Apple 芯片和 Intel Mac 的 macOS 客户端,内置本地服务,也可连接远程 Muteki 服务。本次暂不提供 Windows、Linux 桌面安装包,Web 和容器部署继续支持。 + +本次 macOS 安装包使用临时签名,尚未公证。桌面更新请手动安装;应用内候选版本更新流程需要稳定的 Developer ID 签名。 --- ## 快速开始 -> **当前优先体验:CTF 单题模式。** 首页默认只显示“单题任务”;对话、比赛及自定义插件仍在测试中,按下文步骤开启。Web 页面的“渗透”模式正在重写,当前不可选。 +> **从 CTF 或渗透测试工作区开始。** 首页默认显示这两个工作区。对话和比赛工作区可按下文说明,在外观设置中开启。 ### 1. 准备环境并启动 @@ -315,7 +319,7 @@ MUTEKI_WEB_PASSWORD='请替换为强密码' \ ```bash ./run.sh upgrade --check # 检查稳定版 ./run.sh install # 安装托管版本 -muteki upgrade v0.4.0 # 更新到本次版本 +muteki upgrade v0.4.1 # 更新到本次版本 muteki rollback # 回滚到上一个已安装版本 muteki version # 查看版本与安装形态 ``` diff --git a/apps/desktop/electron-builder.dev.cjs b/apps/desktop/electron-builder.dev.cjs new file mode 100644 index 0000000..2970479 --- /dev/null +++ b/apps/desktop/electron-builder.dev.cjs @@ -0,0 +1,21 @@ +const config = require('./package.json').build; +const path = require('node:path'); +const { resolveDevSigningIdentity, requireDevSigningIdentity } = require('./scripts/dev-signing.cjs'); +const signingIdentity = process.platform === 'darwin' + ? resolveDevSigningIdentity(path.resolve(__dirname, '../../state/_secrets/mac-signing.json')) + : undefined; +module.exports = { + ...config, + appId: 'tech.fishcode.muteki.desktop.dev', + productName: 'Muteki Dev', + extraMetadata: { mutekiChannel: 'dev', main: 'src/dev-bootstrap.cjs', mutekiWorkspace: path.resolve(__dirname, '../..') }, + protocols: [], + mac: { ...config.mac, identity: signingIdentity, forceCodeSigning: true }, + beforePack: async context => { + if (context.electronPlatformName !== 'darwin') return; + const { findIdentity } = require('app-builder-lib/out/codeSign/macCodeSign'); + const { keychainFile } = await context.packager.codeSigningInfo.value; + await requireDevSigningIdentity(signingIdentity, keychainFile, findIdentity); + }, + directories: { output: 'dist-dev' }, +}; diff --git a/apps/desktop/native/speech.mm b/apps/desktop/native/speech.mm index d60cf92..d6e9ce6 100644 --- a/apps/desktop/native/speech.mm +++ b/apps/desktop/native/speech.mm @@ -3,6 +3,7 @@ #include #import #import +#import // Node-API keeps this module independent of Electron's Node/V8 ABI. All Cocoa // state belongs to the main queue; only the TSFN crosses into JavaScript. @@ -188,8 +189,44 @@ static napi_value Start(napi_env env, napi_callback_info info) { static napi_value Finish(napi_env, napi_callback_info) { [activeSession endAudio]; return nullptr; } static napi_value Cancel(napi_env, napi_callback_info) { [activeSession finish:@{@"type": @"cancelled"}]; return nullptr; } static void Cleanup(void *) { [activeSession finish:@{@"type": @"cancelled"}]; } +static napi_value NotificationSettings(napi_env env, napi_callback_info info) { + size_t argc = 1; napi_value args[1], resource; napi_valuetype type; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + if (argc != 1 || napi_typeof(env, args[0], &type) != napi_ok || type != napi_function) { + napi_throw_type_error(env, nullptr, "notificationSettings requires a callback"); return nullptr; + } + napi_create_string_utf8(env, "Muteki notification settings", NAPI_AUTO_LENGTH, &resource); + napi_threadsafe_function callback; + if (napi_create_threadsafe_function(env, args[0], nullptr, resource, 0, 1, nullptr, nullptr, nullptr, Deliver, &callback) != napi_ok) { + napi_throw_error(env, "desktop.notification_status_bridge_failed", "Cannot create notification settings callback"); return nullptr; + } + auto deliver = ^(NSDictionary *result) { + NSData *bytes = [NSJSONSerialization dataWithJSONObject:result options:0 error:nil]; + auto *json = new std::string(static_cast(bytes.bytes), bytes.length); + if (napi_call_threadsafe_function(callback, json, napi_tsfn_nonblocking) != napi_ok) delete json; + napi_release_threadsafe_function(callback, napi_tsfn_release); + }; + @try { + // Read only. Never call requestAuthorization or schedule a notification. + [UNUserNotificationCenter.currentNotificationCenter getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) { + NSString *status; + switch (settings.authorizationStatus) { + case UNAuthorizationStatusNotDetermined: status = @"notDetermined"; break; + case UNAuthorizationStatusDenied: status = @"denied"; break; + case UNAuthorizationStatusAuthorized: status = @"authorized"; break; + case UNAuthorizationStatusProvisional: status = @"provisional"; break; + default: status = @"unknown"; break; + } + deliver(@{@"authorizationStatus": status, @"authorizationStatusRaw": @(settings.authorizationStatus), + @"alertSettingRaw": @(settings.alertSetting), @"soundSettingRaw": @(settings.soundSetting), @"notificationCenterSettingRaw": @(settings.notificationCenterSetting), @"lockScreenSettingRaw": @(settings.lockScreenSetting), @"alertStyleRaw": @(settings.alertStyle)}); + }]; + } @catch (NSException *exception) { + deliver(@{@"error": @{@"code": @"desktop.notification_status_failed", @"message": exception.reason ?: exception.description, @"nativeName": exception.name}}); + } + return nullptr; +} static napi_value Init(napi_env env, napi_value exports) { - napi_property_descriptor methods[] = {{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr}, {"finish", nullptr, Finish, nullptr, nullptr, nullptr, napi_default, nullptr}, {"cancel", nullptr, Cancel, nullptr, nullptr, nullptr, napi_default, nullptr}}; - napi_define_properties(env, exports, 3, methods); napi_add_env_cleanup_hook(env, Cleanup, nullptr); return exports; + napi_property_descriptor methods[] = {{"start", nullptr, Start, nullptr, nullptr, nullptr, napi_default, nullptr}, {"finish", nullptr, Finish, nullptr, nullptr, nullptr, napi_default, nullptr}, {"cancel", nullptr, Cancel, nullptr, nullptr, nullptr, napi_default, nullptr}, {"notificationSettings", nullptr, NotificationSettings, nullptr, nullptr, nullptr, napi_default, nullptr}}; + napi_define_properties(env, exports, 4, methods); napi_add_env_cleanup_hook(env, Cleanup, nullptr); return exports; } NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index e6c7af0..228dc70 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,14 +1,15 @@ { "name": "muteki-desktop", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "muteki-desktop", - "version": "0.4.0", + "version": "0.4.1", "license": "AGPL-3.0-only", "devDependencies": { + "@electron/osx-sign": "1.3.3", "@heroui/react": "3.2.4", "@heroui/styles": "3.2.4", "@tailwindcss/postcss": "^4.3.3", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c7e071d..5ae7a23 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,10 +1,10 @@ { "name": "muteki-desktop", "productName": "Muteki", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", - "description": "Desktop client for an existing local or remote Muteki service", + "description": "Muteki desktop with an isolated local workspace and remote service connections", "license": "AGPL-3.0-only", "author": "Muteki contributors", "main": "src/main.cjs", @@ -12,46 +12,50 @@ "start": "npm run build && electron .", "test": "node --test test/*.test.cjs", "test:smoke": "node test/smoke.cjs", - "pack": "npm run build && electron-builder --dir --publish never", - "dist": "npm run build && electron-builder --publish never", + "pack": "npm test && npm run typecheck && npm run build && npm run runtime && electron-builder --dir --publish never", + "dist": "npm test && npm run typecheck && npm run build && npm run runtime && electron-builder --publish never", "build": "npm run build:native && vite build", "build:native": "node scripts/build-native.cjs", "typecheck": "tsc --noEmit", - "dev": "vite --host 127.0.0.1" + "dev": "vite --host 127.0.0.1", + "runtime": "python3 ../../scripts/build_desktop_runtime.py", + "pack:dev": "npm test && npm run typecheck && npm run build && npm run runtime -- --allow-dirty && electron-builder --config electron-builder.dev.cjs --dir --publish never" }, "devDependencies": { - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "@vitejs/plugin-react": "6.1.1", - "electron": "44.4.5", - "electron-builder": "26.15.3", - "lucide-react": "1.48.0", - "playwright-core": "1.63.0", - "react": "19.3.0", - "react-dom": "19.3.0", - "typescript": "7.0.2", - "vite": "8.3.1", - "@types/node": "^20.14.0", + "@electron/osx-sign": "1.3.3", "@heroui/react": "3.2.4", "@heroui/styles": "3.2.4", + "@tailwindcss/postcss": "^4.3.3", "@tanstack/react-virtual": "^3.14.13", + "@types/node": "^20.14.0", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "@xyflow/react": "^12.11.0", "clsx": "^2.1.1", + "electron": "44.4.5", + "electron-builder": "26.15.3", "katex": "0.18.9", + "lucide-react": "1.48.0", "motion": "^13.4.4", + "playwright-core": "1.63.0", + "react": "19.3.0", + "react-dom": "19.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "remark-math": "6.0.0", "shiki": "^4.4.3", "tailwind-merge": "^3.7.0", - "@tailwindcss/postcss": "^4.3.3", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "typescript": "7.0.2", + "vite": "8.3.1" }, "build": { "appId": "tech.fishcode.muteki.desktop", "asar": true, + "artifactName": "${productName}-${version}-${arch}.${ext}", "directories": { "output": "dist" }, @@ -64,6 +68,10 @@ { "from": "../../LICENSE", "to": "LICENSE" + }, + { + "from": "build/runtime", + "to": "runtime" } ], "mac": { @@ -77,7 +85,13 @@ "NSMicrophoneUsageDescription": "Muteki uses the microphone only when you start voice input.", "NSSpeechRecognitionUsageDescription": "Muteki uses macOS speech recognition to add your spoken words to the current draft." }, - "extraResources": [{ "from": "native/build/macos-speech.node", "to": "native/macos-speech.node" }] + "extraResources": [ + { + "from": "native/build/macos-speech.node", + "to": "native/macos-speech.node" + } + ], + "sign": "scripts/sign-macos.cjs" }, "win": { "icon": "../web/ui/public/favicon.ico", @@ -104,6 +118,13 @@ "muteki" ] } - ] + ], + "electronFuses": { + "runAsNode": false, + "enableNodeOptionsEnvironmentVariable": false, + "enableNodeCliInspectArguments": false, + "enableEmbeddedAsarIntegrityValidation": true, + "onlyLoadAppFromAsar": true + } } } diff --git a/apps/desktop/renderer/App.tsx b/apps/desktop/renderer/App.tsx index 7375986..52914b2 100644 --- a/apps/desktop/renderer/App.tsx +++ b/apps/desktop/renderer/App.tsx @@ -1,5 +1,6 @@ -import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, ArrowRight, CircleHelp, Crosshair, Home, Layers, LoaderCircle, Maximize2, MessagesSquare, Minimize2, Moon, PanelLeft, Plug, RefreshCw, Search, Settings, Sun, Target, Trophy, X } from 'lucide-react'; +import { invalidateAuth } from "@/lib/serviceAuth"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { ArrowLeft, ArrowRight, ChartNoAxesCombined, CircleHelp, Crosshair, Home, Layers, LoaderCircle, Maximize2, MessagesSquare, Minimize2, Moon, PanelLeft, Plug, RefreshCw, Search, Settings, Sun, Target, Trophy, X } from 'lucide-react'; import { ConversationShell, type ConversationNavigation } from '@/components/conversation/ConversationShell'; import { ConversationChromeContext } from '@/components/conversationChrome'; import { LoginGate } from '@/components/LoginGate'; @@ -11,20 +12,21 @@ import { flushUserInputDraftStore } from '@/lib/userInputDraftStore'; import { flushQueueEditDraftStore } from '@/lib/queueEditDraftStore'; import { flushSendIntentStore } from '@/lib/sendIntentStore'; import { flushTaskReceiptStore } from '@/lib/task-center'; -import { readSavedTheme, readSavedSelection, applySelection } from '@/lib/palette-engine'; +import { readSavedSelection, readSavedTheme } from '@/lib/palette-engine'; import { IconButton } from './ui'; import type { DesktopState } from './types'; -import { ConversationShortcutsHelp } from '@/components/conversation/ConversationShortcutsHelp'; -import { NativeCapabilitiesPanel } from '@/components/NativeCapabilitiesPanel'; +import { DesktopSettings } from './settings/DesktopSettings'; +import { readThemePreference, subscribeThemePreference } from '@/lib/themePreference'; +import { refreshUiPreferences } from '@/lib/uiPreferences'; +import { applyTheme } from '@/components/settings/AppearanceSettings'; +import { clampRailWidth, CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY, CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY, RAIL_WIDTH_DEFAULT } from '@/lib/railSizing'; + +const readStored = (key: string) => { try { return window.localStorage.getItem(key); } catch { return null; } }; +const writeStored = (key: string, value: string) => { try { window.localStorage.setItem(key, value); } catch { /* keep the in-memory value */ } }; -const WorkerSettings = lazy(() => import('@/components/WorkerOrchestration').then(module => ({ default: module.WorkerOrchestration }))); -const Appearance = lazy(() => import('@/components/WorkerOrchestration').then(module => ({ default: module.AppearanceWorkspace }))); -const AgentExtensions = lazy(() => import('./ChatExtensionsSettings').then(module => ({ default: module.ChatExtensionsSettings }))); -const Notifications = lazy(() => import('@/components/NotificationSettings').then(module => ({ default: module.NotificationSettings }))); const desktop = window.mutekiDesktop; const initial: DesktopState = { origin: '', transportOrigin: '', status: 'idle', message: '', configuring: true, platform: '', route: '/chat' }; -const entries = [{ href: '/chat', label: ['对话', 'Chat'], icon: MessagesSquare }, { href: '/ctf', label: ['CTF 工作台', 'CTF workspace'], icon: Crosshair }, { href: '/pentest', label: ['渗透测试', 'Pentest'], icon: Target }, { href: '/competitions', label: ['比赛', 'Competitions'], icon: Trophy }]; -const settingsEntries = [{href:'/settings/appearance',label:['外观和语言','Appearance and language']},{href:'/settings/agents',label:['Agents 和接入点','Agents and providers']},{href:'/settings/agent-extensions',label:['Agent 扩展','Agent extensions']},{href:'/settings/notifications',label:['通知','Notifications']},{href:'/settings/shortcuts',label:['聊天快捷键','Chat shortcuts']},{href:'/settings/desktop',label:['桌面能力','Desktop capabilities']}]; +const entries = [{ href: '/chat', label: ['对话', 'Chat'], icon: MessagesSquare }, { href: '/ctf', label: ['CTF 工作台', 'CTF workspace'], icon: Crosshair }, { href: '/pentest', label: ['渗透测试', 'Pentest'], icon: Target }, { href: '/competitions', label: ['比赛', 'Competitions'], icon: Trophy }, { href: '/usage', label: ['全局用量', 'Usage'], icon: ChartNoAxesCombined }]; export function App() { const { lang } = useLang(), en = lang === 'en'; @@ -36,11 +38,17 @@ export function App() { const [authStatus, setAuthStatus] = useState({open: false, transportOrigin: ''}); const authOpen = authStatus.open && authStatus.transportOrigin === state.transportOrigin; const acceptAuthState = useCallback((open: boolean) => setAuthStatus({open, transportOrigin: state.transportOrigin}), [state.transportOrigin]); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false), [sidebarWidth, setSidebarWidth] = useState(280); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readStored(CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY) === '1'); + const [sidebarWidth, setSidebarWidthState] = useState(() => { + const stored = Number(readStored(CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY)); + return Number.isFinite(stored) && stored > 0 ? clampRailWidth(stored, window.innerWidth) : RAIL_WIDTH_DEFAULT; + }); + const setSidebarWidth = useCallback((width: number) => setSidebarWidthState(clampRailWidth(width, window.innerWidth)), []); + useEffect(() => { writeStored(CONVERSATION_SIDEBAR_WIDTH_STORAGE_KEY, String(sidebarWidth)); }, [sidebarWidth]); + useEffect(() => { writeStored(CONVERSATION_SIDEBAR_COLLAPSED_STORAGE_KEY, sidebarCollapsed ? '1' : '0'); }, [sidebarCollapsed]); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false), [theme, setTheme] = useState<'light' | 'dark'>(() => readSavedTheme()); - const [reload, setReload] = useState(0), [settingsQuery, setSettingsQuery] = useState(''); + const [reload, setReload] = useState(0); const route = useMemo(() => new URL(state.route || '/chat', window.location.href), [state.route]); - const settingsPathname = route.pathname === '/settings/chat-plugins' ? '/settings/agent-extensions' : route.pathname; const chat = route.pathname.startsWith('/chat'), settings = route.pathname.startsWith('/settings'); const connected = state.status === 'connected', busy = state.status === 'connecting'; const perform = useCallback(async (fn: () => Promise) => { @@ -51,11 +59,12 @@ export function App() { await desktop.navigate(href, replace ? 'replace' : 'push'); }); }, [perform]); - const navigation = useMemo(() => ({ router: { push: href => go(href), replace: href => go(href, true) }, pathname: route.pathname, searchParams: route.searchParams }), [go, route]); + const navigation = useMemo(() => ({ router: { push: href => go(href), replace: href => go(href, true) }, pathname: route.pathname, searchParams: route.searchParams, hash: state.anchorTarget?.hash, anchorRequestId: state.anchorTarget?.id, consumeAnchor: (id: number) => { void desktop.consumeAnchor(id).catch(error => setError(error.message)); } }), [go, route, state.anchorTarget]); useEffect(() => { let updates = 0; const accept = (next: DesktopState) => { updates++; + if (next.authSessionVersion !== stateRef.current.authSessionVersion && next.transportOrigin === stateRef.current.transportOrigin) invalidateAuth("peer_changed"); if (next.transportOrigin && next.transportOrigin !== stateRef.current.transportOrigin) { resetAuthForServiceChange(next.origin, next.transportOrigin); setMobileSidebarOpen(false); @@ -67,18 +76,40 @@ export function App() { return off; }, []); useEffect(() => { if (state.origin) setOrigin(state.origin); }, [state.origin]); + useEffect(() => desktop.onFocus(() => { void refreshUiPreferences(); }), []); useEffect(() => { void desktop.setLocale(lang).catch(error => setError(error.message)); }, [lang]); useEffect(() => { const observer = new MutationObserver(() => { const current = document.documentElement.dataset.theme; if (current === 'light' || current === 'dark') setTheme(current); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); return () => observer.disconnect(); }, []); + useEffect(() => { + // Remote pages (CTF, pentest, competitions) lost their own theme toggle with the web rail, so they follow this shell. + let last = ''; + const sync = () => { + const payload = { preference: readThemePreference(), resolvedTheme: readSavedTheme(), selection: readSavedSelection() }; + const key = JSON.stringify(payload); + if (key === last) return; + last = key; + void desktop.syncAppearance(payload).catch(error => { last = ''; setError(error.message); }); + }; + sync(); + const offTheme = subscribeThemePreference(sync); + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-scheme', 'style'] }); + return () => { observer.disconnect(); offTheme(); }; + }, []); useEffect(() => { const off = desktop.onCommand(({ name }) => { - if (name === 'settings') go('/settings/appearance'); + if (name === 'settings') go('/settings'); else if (name === 'shortcuts') go('/settings/shortcuts'); - else if (name === 'sidebar') { if (stateRef.current.configuring) return; setSidebarCollapsed(value => !value); } - else if (name === 'reload') setReload(value => value + 1); - else if (name === 'theme') { const next = theme === 'dark' ? 'light' : 'dark'; try { localStorage.setItem('muteki.theme', next); } catch (error) { setError(String(error)); } applySelection(readSavedSelection(), next); document.documentElement.dataset.theme = next; } + else if (name === 'sidebar') { + if (stateRef.current.configuring) return; + // Below the shell's 769px breakpoint the sidebar is an overlay drawer that ignores sidebarCollapsed. + if (window.matchMedia('(max-width: 768px)').matches) setMobileSidebarOpen(value => !value); + else setSidebarCollapsed(value => !value); + } + else if (name === 'reload') { void refreshUiPreferences(); setReload(value => value + 1); } + else if (name === 'theme') applyTheme(theme === 'dark' ? 'light' : 'dark'); else if (chat && !stateRef.current.configuring) window.dispatchEvent(new CustomEvent('muteki:command', { detail: { name } })); else if (name === 'new-chat' || name === 'search') go('/chat'); }); @@ -91,24 +122,32 @@ export function App() { return desktop.acknowledgeClose({ token, persisted: results.every(result => result.persisted), error: results.flatMap(result => result.error ? [result.error] : []).join('\n') }); }).catch(error => { void desktop.acknowledgeClose({ token, persisted: false, error: error.message }).catch(() => {}); setError(error.message); }); }), [chat, state.transportOrigin, authOpen]); - const chrome = useMemo(() => ({ sidebarCollapsed, toggleSidebarCollapsed: () => setSidebarCollapsed(value => !value), sidebarWidth, setSidebarWidth, mobileSidebarOpen, setMobileSidebarOpen }), [sidebarCollapsed, sidebarWidth, mobileSidebarOpen]); + const chrome = useMemo(() => ({ sidebarCollapsed, toggleSidebarCollapsed: () => setSidebarCollapsed(value => !value), sidebarWidth, setSidebarWidth, mobileSidebarOpen, setMobileSidebarOpen }), [sidebarCollapsed, sidebarWidth, setSidebarWidth, mobileSidebarOpen]); const pageTitle = settings ? label('设置', 'Settings') : entries.find(item => route.pathname.startsWith(item.href))?.label[en ? 1 : 0] || 'Muteki'; const noticeKey = `${state.transportOrigin}:${state.noticeVersion}:${state.persistenceWarning}:${state.message}`; const notice = error || (dismissedNotice !== noticeKey ? state.persistenceWarning || (!state.configuring ? state.message : '') : ''); return

void perform(() => desktop.action('back'))} /> void perform(() => desktop.action('forward'))} /> void perform(() => desktop.action('sidebar'))} />
-

{pageTitle}

{connected ? new URL(state.origin).host : ''} void perform(() => desktop.action('reload'))} /> +

{pageTitle}{state.environment?.channel === 'dev' && DEV}{state.environment?.channel === 'candidate' && CANDIDATE}

{connected ? new URL(state.origin).host : ''} void perform(() => desktop.action('reload'))} /> {state.platform !== 'darwin' &&
void perform(() => desktop.windowAction('minimize'))} /> void perform(() => desktop.windowAction('maximize'))} /> void perform(() => desktop.windowAction('close'))} />
}
- +
- {state.transportOrigin && } - {state.configuring &&
{help ?

Muteki Desktop

{label('对话、搜索和设置在本地窗口运行。预览在隔离的页面中打开;项目路径需要区分桌面客户端和服务端宿主。', 'Chat, search, and settings run in this local window. Previews are isolated. Project paths distinguish the desktop client from the service host.')}

:

MUTEKI DESKTOP

{label('连接你的工作台', 'Connect your workspace')}

{ event.preventDefault(); void perform(() => desktop.connect(origin)); }}> setOrigin(event.target.value)} disabled={busy} />

{label('本机默认端口为 3001;远程服务使用 HTTPS。', 'Local default port: 3001. Remote services use HTTPS.')}

{(error || state.message) &&

{error || state.message}

}
{(connected || state.draftRecovery) && }
}
} + {state.configuring &&
{help ?

Muteki Desktop

{label('对话、搜索和设置在本地窗口运行。预览在隔离的页面中打开;项目路径需要区分桌面客户端和服务端宿主。', 'Chat, search, and settings run in this local window. Previews are isolated. Project paths distinguish the desktop client from the service host.')}

:

MUTEKI DESKTOP

{label('打开你的工作台', 'Open your workspace')}

+
+ +

{label('自动启动配套服务,数据保存在此 App 的独立环境中。', 'Starts the bundled service with data in this App’s separate environment.')}

+ {state.managedService?.state === 'failed' &&

{state.managedService.error}
{label('日志:', 'Logs: ')}{state.managedService.logs}

} +
+
{ event.preventDefault(); void perform(() => desktop.connect(origin)); }}> setOrigin(event.target.value)} disabled={busy} />

{label('本机默认端口为 3001;远程服务使用 HTTPS。', 'Local default port: 3001. Remote services use HTTPS.')}

{(error || state.message) &&

{error || state.message}

}
{(connected || state.draftRecovery) && }
}
}
{notice &&
{notice}
}
; diff --git a/apps/desktop/renderer/ChatExtensionsSettings.tsx b/apps/desktop/renderer/ChatExtensionsSettings.tsx deleted file mode 100644 index 3ead18c..0000000 --- a/apps/desktop/renderer/ChatExtensionsSettings.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; -import { Button, Callout, Dialog, Input, Switch, TextArea } from '@/components/chat/ui'; -import { Icon } from '@/components/Icon'; -import { NativePathPicker } from '@/components/NativePathPicker'; -import { NativeCapabilitiesPanel } from '@/components/NativeCapabilitiesPanel'; -import { apiFetch, currentAuthScope } from '@/lib/useRun'; -import { conversationStorageScope, subscribeConversationStorageScope } from '@/lib/conversationStorageScope'; -import { useLang } from '@/lib/i18n'; - -type SourceKind = 'local-dir' | 'archive' | 'git'; -type ExtensionPackage = { - id: string; name: string; description: string; version: string; enabled: boolean; - skills: Array<{ name: string; description: string }>; mcp_servers: string[]; - native_components: string[]; diagnostics: string[]; can_rollback: boolean; digest: string; - native_hooks_enabled?: boolean; hook_commands?: string[]; hook_definitions?: unknown[]; - compatibility?: Record; -}; -type Catalog = { - engine: string; packages: ExtensionPackage[]; control_enabled: boolean; - host_discovery_enabled?: boolean; - native_skills: Array<{ id: string; name: string; description: string; source: string }>; - native_mcp: Array<{ name: string; enabled: boolean; source: string; transport: string }>; -}; -export interface ChatExtensionsNavigation { - pathname: string; searchParams: URLSearchParams; router: { replace: (href: string) => void }; -} -const ENGINES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', pi: 'Pi', omp: 'OMP', kimi: 'Kimi', grok: 'Grok', opencode: 'OpenCode' }; -const record = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value)); -const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === 'string'); - -export class ChatExtensionsError extends Error { - constructor(readonly code: string, message: string, readonly rawBody = '', readonly httpStatus?: number) { - super(message); this.name = 'ChatExtensionsError'; - } -} -export async function requestChatExtensions(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise { - const response = await apiFetch(`/api/chat-plugins${path}`, { method, signal, - headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }); - const raw = await response.text(); - let data: unknown; - try { data = JSON.parse(raw); } catch { - throw new ChatExtensionsError('chat_extensions.response_invalid', `HTTP ${response.status}: ${raw}`, raw, response.status); - } - if (!response.ok) { - const error = record(data) && record(data.error) ? data.error : record(data) && record(data.detail) ? data.detail : null; - const detail = error?.message ?? (record(data) ? data.detail : undefined); - throw new ChatExtensionsError(typeof error?.code === 'string' ? error.code : 'chat_extensions.http_error', - typeof detail === 'string' ? detail : `HTTP ${response.status}`, raw, response.status); - } - return data; -} -export function chatExtensionsCatalog(value: unknown, engine: string): Catalog { - if (!record(value) || value.engine !== engine || typeof value.control_enabled !== 'boolean' - || (value.host_discovery_enabled != null && typeof value.host_discovery_enabled !== 'boolean') - || !Array.isArray(value.packages) || !Array.isArray(value.native_skills) || !Array.isArray(value.native_mcp) - || value.packages.some(item => !record(item) || !['id', 'name', 'description', 'version', 'digest'].every(key => typeof item[key] === 'string') - || !item.id || typeof item.enabled !== 'boolean' || typeof item.can_rollback !== 'boolean' - || !strings(item.mcp_servers) || !strings(item.native_components) || !strings(item.diagnostics) - || !Array.isArray(item.skills) || item.skills.some(skill => !record(skill) || typeof skill.name !== 'string' || typeof skill.description !== 'string')) - || value.native_skills.some(item => !record(item) || !['id', 'name', 'description', 'source'].every(key => typeof item[key] === 'string')) - || value.native_mcp.some(item => !record(item) || !['name', 'source', 'transport'].every(key => typeof item[key] === 'string') || typeof item.enabled !== 'boolean')) { - throw new ChatExtensionsError('chat_extensions.catalog_invalid', 'chat_extensions.catalog_invalid', JSON.stringify(value, null, 2)); - } - const ids = (value.packages as ExtensionPackage[]).map(item => item.id); - if (new Set(ids).size !== ids.length) throw new ChatExtensionsError('chat_extensions.catalog_invalid', 'Duplicate package IDs', JSON.stringify(value, null, 2)); - return value as unknown as Catalog; -} - -/** Chat-only desktop UI for the existing main-branch /api/chat-plugins contract. */ -export function ChatExtensionsSettings({ navigation }: { navigation: ChatExtensionsNavigation }) { - const { lang } = useLang(); const en = lang === 'en'; const t = (zh: string, english: string) => en ? english : zh; - const scope = useSyncExternalStore(subscribeConversationStorageScope, conversationStorageScope, () => ''); - const [engine, setEngine] = useState(ENGINES[navigation.searchParams.get('engine') || ''] ? navigation.searchParams.get('engine')! : 'codex'); - const [sourceTab, setSourceTab] = useState(navigation.searchParams.get('source') === 'native' ? 'native' : 'managed'); - const [query, setQuery] = useState(''); const [catalogRecord, setCatalogRecord] = useState<{ owner: string; data: Catalog } | null>(null); - const [error, setError] = useState(null); const [notice, setNotice] = useState(''); - const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [picking, setPicking] = useState(false); - const [installOpen, setInstallOpen] = useState(false); const [installOwner, setInstallOwner] = useState(''); const [installType, setInstallType] = useState<'package' | 'mcp'>('package'); - const [kind, setKind] = useState('local-dir'); const [source, setSource] = useState(''); const [ref, setRef] = useState(''); const [name, setName] = useState(''); - const [config, setConfig] = useState('{\n "mcpServers": {}\n}'); const [detailId, setDetailId] = useState(''); const [removeRecord, setRemoveRecord] = useState<{ owner: string; item: ExtensionPackage } | null>(null); - const owner = JSON.stringify([scope, engine]); const ownerRef = useRef(owner); ownerRef.current = owner; - const requestId = useRef(0); const operation = useRef<{ owner: string } | null>(null); const abort = useRef(null); - const catalog = catalogRecord?.owner === owner ? catalogRecord.data : null; - const remove = removeRecord?.owner === owner ? removeRecord.item : null; - const detail = catalog?.packages.find(item => item.id === detailId); - const isCurrent = useCallback((snapshot: string) => ownerRef.current === snapshot && Boolean(scope) && currentAuthScope() === scope, [scope]); - const reportError = (cause: unknown) => cause instanceof ChatExtensionsError ? cause : new ChatExtensionsError('chat_extensions.request_failed', cause instanceof Error ? cause.message : String(cause)); - const load = useCallback(async (snapshot = owner): Promise => { - if (!isCurrent(snapshot)) return null; - abort.current?.abort(); const controller = new AbortController(); abort.current = controller; - const id = ++requestId.current; setLoading(true); - try { - const data = chatExtensionsCatalog(await requestChatExtensions(`?engine=${encodeURIComponent(engine)}`, 'GET', undefined, controller.signal), engine); - if (isCurrent(snapshot) && id === requestId.current) { setCatalogRecord({ owner: snapshot, data }); setError(null); return data; } - return null; - } catch (cause) { - if (controller.signal.aborted || id !== requestId.current || !isCurrent(snapshot)) return null; - throw cause; - } finally { if (isCurrent(snapshot) && id === requestId.current) setLoading(false); } - }, [engine, isCurrent, owner]); - useEffect(() => { - setCatalogRecord(null); setError(null); setNotice(''); setLoading(false); setBusy(false); setPicking(false); - setInstallOpen(false); setDetailId(''); setRemoveRecord(null); setSource(''); setName(''); setRef(''); setConfig('{\n "mcpServers": {}\n}'); setKind('local-dir'); setInstallType('package'); - operation.current = null; - if (scope) void load().catch(cause => { if (isCurrent(owner)) setError(reportError(cause)); }); - return () => { abort.current?.abort(); ++requestId.current; }; - }, [owner, scope, load, isCurrent]); - const refresh = () => { setNotice(''); void load().catch(cause => { if (isCurrent(owner)) setError(reportError(cause)); }); }; - const mutate = async (action: () => Promise, success: string, completed?: () => void, confirm?: (data: Catalog) => boolean) => { - if (!isCurrent(owner) || operation.current) return; - const ticket = { owner }; operation.current = ticket; setBusy(true); setError(null); setNotice(''); let applied = false; - try { - await action(); applied = true; - if (!isCurrent(owner) || operation.current !== ticket) return; - completed?.(); const refreshed = await load(owner); - if (refreshed && confirm && !confirm(refreshed)) throw new ChatExtensionsError('chat_extensions.readback_mismatch', t('目录尚未确认本次变更,请重载核对。', 'The catalog did not confirm this change. Reload to verify it.')); - if (refreshed && isCurrent(owner) && operation.current === ticket) setNotice(success); - } catch (cause) { - if (isCurrent(owner) && operation.current === ticket) { - const failure = reportError(cause); - setError(applied ? new ChatExtensionsError('chat_extensions.readback_failed', t('变更已返回,但目录刷新失败;请重载确认结果。', 'The change returned, but catalog refresh failed. Reload to verify the outcome.'), failure.rawBody || failure.message, failure.httpStatus) : failure); - } - } finally { if (operation.current === ticket) { operation.current = null; if (isCurrent(owner)) setBusy(false); } } - }; - const install = () => { - let installedId = ''; - void mutate(async () => { - let result: unknown; - if (installType === 'mcp') { - let parsed: unknown; try { parsed = JSON.parse(config); } catch { throw new ChatExtensionsError('chat_extensions.mcp_invalid', t('MCP 配置不是有效 JSON。', 'MCP configuration is not valid JSON.')); } - if (!record(parsed)) throw new ChatExtensionsError('chat_extensions.mcp_invalid', t('MCP 配置须为对象。', 'MCP configuration must be an object.')); - result = await requestChatExtensions('/mcp', 'POST', { name: name.trim(), servers: parsed.mcpServers ?? parsed }); - } else { - if (!source.trim()) throw new ChatExtensionsError('chat_extensions.source_missing', t('请填写服务可访问的来源。', 'Enter a source accessible to the service.')); - result = await requestChatExtensions('/install', 'POST', { source: { kind, ...(kind === 'git' ? { url: source.trim(), ref: ref.trim() } : { path: source.trim() }) } }); - } - if (!record(result) || typeof result.id !== 'string' || !result.id) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认已导入的扩展,请重载核对。', 'The service did not confirm the imported package. Reload to verify it.'), JSON.stringify(result, null, 2)); - installedId = result.id; - }, t('扩展已添加,聊天会在下一轮读取变更。', 'Extension added. Chat sessions read changes on their next turn.'), () => { setInstallOpen(false); setSource(''); setName(''); setRef(''); }, data => data.packages.some(item => item.id === installedId)); - }; - const update = (item: ExtensionPackage, body: Record, success: string) => void mutate(async () => { - const result = await requestChatExtensions(`/packages/${encodeURIComponent(item.id)}`, 'PATCH', body); - if (!record(result) || result.id !== item.id || (typeof body.enabled === 'boolean' && result.enabled !== body.enabled) - || (typeof body.native_hooks === 'boolean' && result.native_hooks_enabled !== body.native_hooks)) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认扩展变更,请重载核对。', 'The service did not confirm the package change. Reload to verify it.'), JSON.stringify(result, null, 2)); - }, success, undefined, data => data.packages.some(row => row.id === item.id && (typeof body.enabled !== 'boolean' || row.enabled === body.enabled) - && (typeof body.native_hooks !== 'boolean' || Boolean(row.native_hooks_enabled) === body.native_hooks))); - const check = (item: ExtensionPackage) => void mutate(async () => { - const result = await requestChatExtensions(`/check/${encodeURIComponent(item.id)}`, 'POST'); - if (!record(result) || !Number.isSafeInteger(result.tool_count) || Number(result.tool_count) < 0 || !strings(result.diagnostics)) throw new ChatExtensionsError('chat_extensions.check_invalid', 'chat_extensions.check_invalid', JSON.stringify(result, null, 2)); - if (result.diagnostics.length) throw new ChatExtensionsError('chat_extensions.check_failed', result.diagnostics.join('\n'), JSON.stringify(result, null, 2)); - }, t('MCP 检查已返回;目录已刷新。', 'MCP check returned; the catalog was refreshed.')); - const errorView = error ?

{error.message}

{t('完整诊断', 'Full diagnostics')}
{error.code}{error.httpStatus != null ? ` · HTTP ${error.httpStatus}` : ''}{error.rawBody ? `\n${error.rawBody}` : ''}
: null; - const filtered = catalog?.packages.filter(item => `${item.name} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || []; - const locked = busy || picking || !scope; - return
-

{t('聊天扩展', 'Chat extensions')}

-

{t('仅管理聊天 Agent 的扩展。包与本机能力来自当前服务宿主;桌面客户端的文件不会自动同步至服务。', 'Manage chat Agent extensions only. Packages and native capabilities belong to the connected service host; client files do not automatically synchronize with the service.')}

- - {!scope ? {t('尚未确认服务身份,请先连接并登录。', 'Connect and sign in to confirm the service identity.')} : null} -
{(['managed', 'native'] as const).map(tab => )}
setQuery(event.target.value)} placeholder={t('搜索名称和说明', 'Search name and description')} />
- {!installOpen && !detail && !remove ? errorView : null}{notice ? {notice} : null}{loading ?

{t('读取目录中…', 'Loading catalog…')}

: null} - {sourceTab === 'managed' && catalog ?
{t('Muteki 平台工具', 'Muteki platform tools')}

{t('聊天中访问授权的平台能力。', 'Access authorized platform capabilities from chat.')}

void mutate(() => requestChatExtensions('/control', 'PUT', { enabled }).then(result => { if (!record(result) || result.enabled !== enabled) throw new ChatExtensionsError('chat_extensions.mutation_response_invalid', t('服务未确认平台工具变更。', 'The service did not confirm the platform tool change.'), JSON.stringify(result, null, 2)); return result; }), t('平台工具状态已更新。', 'Platform tool state updated.'), undefined, data => data.control_enabled === enabled)} />
{filtered.map(item =>

{item.description}

{item.version} · {item.skills.length} Skills · {item.mcp_servers.length} MCP

{item.diagnostics.map((diagnostic, index) =>

{diagnostic}

)}
update(item, { enabled }, t('扩展启用状态已更新。', 'Package enabled state updated.'))} />
)}{!filtered.length ?

{t('没有匹配的扩展。', 'No matching packages.')}

: null}
: null} - {sourceTab === 'native' && catalog ?
{catalog.host_discovery_enabled === false ? {t('当前服务已禁用宿主能力发现。请使用已登记凭据和手动导入扩展;这不表示 Agent 未登录。', 'This service disables host discovery. Use registered credentials and manually imported packages; this does not mean the Agent is signed out.')} :

{t('只读查看服务宿主已发现的 Skill 和 MCP 配置;配置存在不等于已连接。', 'Read-only discovered skills and MCP configuration on the service host. Configuration presence does not prove a connection.')}

}{catalog.native_skills.filter(item => `${item.name} ${item.description}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())).map(item =>
{item.name}

{item.description}

{item.source}

)}{catalog.native_mcp.filter(item => item.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())).map((item, index) =>
{item.name}

{item.enabled ? t('配置已启用', 'Configuration enabled') : t('配置已禁用', 'Configuration disabled')} · {item.transport} · {item.source}

)}
: null} - { if (!busy && !picking) setInstallOpen(next); }} title={t('添加聊天扩展', 'Add chat extension')} size="md" dismissable={!busy && !picking} footer={<>}> -
{installType === 'mcp' ? <> setName(event.target.value)} aria-label={t('MCP 名称', 'MCP name')} />