- closebar()} /> diff --git a/src/routes/page/conversation/+page.svelte b/src/routes/page/conversation/+page.svelte index b490cd4b..13d2eadb 100644 --- a/src/routes/page/conversation/+page.svelte +++ b/src/routes/page/conversation/+page.svelte @@ -24,8 +24,11 @@ setUrlQueryParams, goToUrl, convertTimeRange, - formatNumber + formatNumber, + getCleanUrl } from '$lib/helpers/utils/common'; + import { globalMenuStore } from '$lib/helpers/store'; + import { SIMPLECLAW_ROUTE, SIMPLECLAW_PLANNER_AGENT_ID } from '$lib/helpers/constants/simpleclaw'; const duration = 3000; const firstPage = 1; @@ -58,6 +61,19 @@ /** @type {import('$commonTypes').LabelValuePair[]} */ let agentOptions = $state([]); + /** + * Whether the SimpleClaw module is available here. + * + * One check answers all three questions at once. The backend only puts this entry in + * the menu when the plugin is installed and the signed-in role is allowed it, and the + * layout then drops it in the web build, where the feature cannot work at all. Asking + * the menu we were actually given is therefore more accurate than testing any of the + * three separately — and it stays correct if any of them changes. + */ + let simpleClawEnabled = $derived( + ($globalMenuStore || []).some((x) => getCleanUrl(x.link) === SIMPLECLAW_ROUTE) + ); + /** @type {import('$commonTypes').LabelValuePair[]} */ const statusOptions = [ { value: 'open', label: 'Active' }, @@ -439,7 +455,8 @@
-
+ +
@@ -565,7 +582,8 @@
-
+ +
@@ -642,6 +660,22 @@ > + {#if simpleClawEnabled && conv.agent_id === SIMPLECLAW_PLANNER_AGENT_ID} + + + {/if}
- pageTo(pn)} /> + +
+ pageTo(pn)} /> +
diff --git a/src/routes/simpleclaw/+layout.svelte b/src/routes/simpleclaw/+layout.svelte new file mode 100644 index 00000000..c59851e6 --- /dev/null +++ b/src/routes/simpleclaw/+layout.svelte @@ -0,0 +1,74 @@ + + + + {@render children?.()} + diff --git a/src/routes/simpleclaw/+page.svelte b/src/routes/simpleclaw/+page.svelte new file mode 100644 index 00000000..6e2d5507 --- /dev/null +++ b/src/routes/simpleclaw/+page.svelte @@ -0,0 +1,694 @@ + + + + + +{#snippet botAvatar()} + +{/snippet} + +{#if !desktop} +
+
+ +
+

This needs the desktop app

+

+ SimpleClaw runs on your own machine and is only reachable from the desktop app, so this + page cannot do anything in a browser. +

+
+{:else} + + +
+ + + +
+
+
+ {#if messages.length === 0} +
+
+ +
+

+ Ask a question, or describe work that needs doing +

+

+ Anything that has to happen in a real system gets carried out by AI Agent. + You will always see the plan before it runs. +

+
+ {#each SUGGESTIONS as s} + + {/each} +
+
+ {/if} + + {#each messages as m (m.id)} + {#if m.kind === 'user'} +
+
+ {m.text} +
+
+ {:else if m.kind === 'assistant'} +
+ {@render botAvatar()} +
+ + +
+
+ {:else if m.kind === 'plan'} + + {:else if m.kind === 'running'} + + {:else if m.kind === 'done'} + + {:else if m.kind === 'ask'} + sendAnswer(m.id, text)} + /> + {:else if m.kind === 'takeover'} + + {:else if m.kind === 'warning'} +
+ +

{m.text}

+
+ {:else if m.kind === 'result'} + + {/if} + {/each} + + {#if phase === 'planning'} +
+ {@render botAvatar()} +
+ + {#each [0, 150, 300] as delay} + + {/each} + Thinking +
+
+ {/if} +
+
+ + + {#if !pinned && messages.length > 0} + + {/if} +
+ +
+
+ +
+ + +
+

+ Enter to send · Shift+Enter for a new line +

+
+
+
+{/if} diff --git a/src/routes/simpleclaw/connection-bar.svelte b/src/routes/simpleclaw/connection-bar.svelte new file mode 100644 index 00000000..03776d47 --- /dev/null +++ b/src/routes/simpleclaw/connection-bar.svelte @@ -0,0 +1,67 @@ + + +
+ + + + {status?.connected ? 'Connected' : 'Not connected'} + + + {#if status?.connected} + + v{status.version ?? '?'} + {#if status.organization} + + {status.organization} + {/if} + + {agentCount} {agentCount === 1 ? 'agent' : 'agents'} + + {#if status.busy} + + Busy + + {/if} + {:else if hint} + {hint} + {/if} + + +
diff --git a/src/routes/simpleclaw/msg-ask.svelte b/src/routes/simpleclaw/msg-ask.svelte new file mode 100644 index 00000000..33d06910 --- /dev/null +++ b/src/routes/simpleclaw/msg-ask.svelte @@ -0,0 +1,101 @@ + + +
+
+ + + Waiting for your answer +
+ +

{question}

+ + {#if answered} +

Answer sent.

+ {:else if expired} +

+ Ten minutes passed with no answer, so SimpleClaw stopped the run. +

+ {:else} +
+ + +
+ + + {clock} left before the run is stopped +
+
+ {/if} +
diff --git a/src/routes/simpleclaw/msg-plan.svelte b/src/routes/simpleclaw/msg-plan.svelte new file mode 100644 index 00000000..be85ed59 --- /dev/null +++ b/src/routes/simpleclaw/msg-plan.svelte @@ -0,0 +1,161 @@ + + + +
+ +
+ + Plan + {#if auto} + + + Ran automatically + + {/if} + {#if plan.steps.length} + + {plan.steps.length} {plan.steps.length === 1 ? 'step' : 'steps'} + + {/if} +
+ +
+ {#if plan.reply} +

{plan.reply}

+ {/if} + + {#if plan.steps.length === 0} +

No steps were produced, so nothing will run.

+ {:else} +
    + {#each plan.steps as step, i} +
  1. + + + {#if expanded === i} +
    + {#if agentSystem(step.agentId)} +
    +
    System
    +
    {agentSystem(step.agentId)}
    +
    + {/if} + {#if step.expect} +
    +
    Reports
    +
    {step.expect}
    +
    + {/if} + {#if step.capture?.name} +
    +
    Captures
    +
    + {step.capture.name}{#if step.capture.pattern} ({step.capture.pattern}){/if} +
    +
    + {/if} +
    + {/if} +
  2. + {/each} +
+ {/if} + + {#if !decided && plan.steps.length > 0} +
+ + + +
+ {/if} +
+
diff --git a/src/routes/simpleclaw/msg-result.svelte b/src/routes/simpleclaw/msg-result.svelte new file mode 100644 index 00000000..8f9aa38c --- /dev/null +++ b/src/routes/simpleclaw/msg-result.svelte @@ -0,0 +1,99 @@ + + +
+
+ {#if icon} + +
+ +
+ {:else} + + {/if} + +
+ {#if agentName} +

{agentName}

+ {/if} + +

+ + {ok ? 'Done' : 'Did not finish'} +

+
+
+ + +
+ +
+ + {#if needsDemo} +

+ + Record a demonstration of this operation in SimpleClaw and run it again. Rewording the + request will not help — the agent has not been shown how to do this yet. +

+ {/if} +
diff --git a/src/routes/simpleclaw/msg-step-done.svelte b/src/routes/simpleclaw/msg-step-done.svelte new file mode 100644 index 00000000..3073ba17 --- /dev/null +++ b/src/routes/simpleclaw/msg-step-done.svelte @@ -0,0 +1,49 @@ + + +
+ + + {#if open} +
+

{answer}

+

{steps} {steps === 1 ? 'action' : 'actions'}

+
+ {/if} +
diff --git a/src/routes/simpleclaw/msg-step-run.svelte b/src/routes/simpleclaw/msg-step-run.svelte new file mode 100644 index 00000000..3b417e8f --- /dev/null +++ b/src/routes/simpleclaw/msg-step-run.svelte @@ -0,0 +1,90 @@ + + + +
+
+ + Running + Step {index + 1} of {total} +
+ +
+ {agentName} +

{goal}

+ + {#if queuePosition > 0} +

+ + Queued — {queuePosition} ahead. SimpleClaw runs one task at a time. +

+ {:else} +
+
+
+ +
+ +

+ {activity || 'Working…'} +

+ + {step} {step === 1 ? 'action' : 'actions'} + +
+ {/if} + + +
+
diff --git a/src/routes/simpleclaw/msg-takeover.svelte b/src/routes/simpleclaw/msg-takeover.svelte new file mode 100644 index 00000000..412481c1 --- /dev/null +++ b/src/routes/simpleclaw/msg-takeover.svelte @@ -0,0 +1,40 @@ + + +
+
+ + + {active ? 'A person is at the controls' : 'Control was handed back'} + +
+ + {#if active} +

+ Someone is operating the machine directly in SimpleClaw. This run waits for as long as + that takes — nothing here will time it out. +

+ + {:else} +

The run has resumed.

+ {/if} +
diff --git a/svelte.config.js b/svelte.config.js index bf5de71d..8cf4396d 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,7 +18,8 @@ const config = { $pluginTypes: './src/lib/helpers/types/pluginTypes.js', $realtimeTypes: './src/lib/helpers/types/realtimeTypes.js', $instructTypes: './src/lib/helpers/types/instructTypes.js', - $mcpTypes: './src/lib/helpers/types/mcpTypes.js' + $mcpTypes: './src/lib/helpers/types/mcpTypes.js', + $simpleclawTypes: './src/lib/helpers/types/simpleclawTypes.js' }, // for static deployment @@ -65,6 +66,7 @@ const config = { "/page/plugin", "/page/plugin/[pluginId]", "/page/setting", + "/simpleclaw", "/page/user/me", "/page/users", "/page/roles", From 576f393f01a660e11d0b328f82b97f9b854b22df Mon Sep 17 00:00:00 2001 From: hchen Date: Thu, 30 Jul 2026 17:06:56 -0500 Subject: [PATCH 3/3] refactor: remove SimpleClaw components and related functionality - Deleted connection-bar.svelte, msg-ask.svelte, msg-plan.svelte, msg-result.svelte, msg-step-done.svelte, msg-step-run.svelte, msg-takeover.svelte components. - Updated svelte.config.js to remove references to simpleclawTypes and related routes. --- ...tic-web-apps-victorious-moss-007e11310.yml | 8 + README.md | 37 +- package.json | 1 + scripts/build-frontend.mjs | 31 +- src-tauri/Cargo.lock | 183 +---- src-tauri/Cargo.toml | 13 +- src-tauri/src/lib.rs | 19 +- src-tauri/src/simpleclaw.rs | 461 ------------ src/lib/helpers/constants/simpleclaw.js | 22 - src/lib/helpers/types/simpleclawTypes.js | 130 ---- src/lib/helpers/utils/desktop.js | 15 + src/lib/langs/en.json | 3 - src/lib/langs/zh.json | 1 - src/lib/services/simpleclaw-orchestrator.js | 553 -------------- src/lib/services/simpleclaw-service.js | 114 --- src/routes/+layout.svelte | 2 +- src/routes/VerticalLayout/Index.svelte | 24 +- src/routes/page/conversation/+page.svelte | 34 +- src/routes/simpleclaw/+layout.svelte | 74 -- src/routes/simpleclaw/+page.svelte | 694 ------------------ src/routes/simpleclaw/connection-bar.svelte | 67 -- src/routes/simpleclaw/msg-ask.svelte | 101 --- src/routes/simpleclaw/msg-plan.svelte | 161 ---- src/routes/simpleclaw/msg-result.svelte | 99 --- src/routes/simpleclaw/msg-step-done.svelte | 49 -- src/routes/simpleclaw/msg-step-run.svelte | 90 --- src/routes/simpleclaw/msg-takeover.svelte | 40 - svelte.config.js | 4 +- 28 files changed, 109 insertions(+), 2921 deletions(-) delete mode 100644 src-tauri/src/simpleclaw.rs delete mode 100644 src/lib/helpers/constants/simpleclaw.js delete mode 100644 src/lib/helpers/types/simpleclawTypes.js create mode 100644 src/lib/helpers/utils/desktop.js delete mode 100644 src/lib/services/simpleclaw-orchestrator.js delete mode 100644 src/lib/services/simpleclaw-service.js delete mode 100644 src/routes/simpleclaw/+layout.svelte delete mode 100644 src/routes/simpleclaw/+page.svelte delete mode 100644 src/routes/simpleclaw/connection-bar.svelte delete mode 100644 src/routes/simpleclaw/msg-ask.svelte delete mode 100644 src/routes/simpleclaw/msg-plan.svelte delete mode 100644 src/routes/simpleclaw/msg-result.svelte delete mode 100644 src/routes/simpleclaw/msg-step-done.svelte delete mode 100644 src/routes/simpleclaw/msg-step-run.svelte delete mode 100644 src/routes/simpleclaw/msg-takeover.svelte diff --git a/.github/workflows/azure-static-web-apps-victorious-moss-007e11310.yml b/.github/workflows/azure-static-web-apps-victorious-moss-007e11310.yml index 21025558..333bf1ac 100644 --- a/.github/workflows/azure-static-web-apps-victorious-moss-007e11310.yml +++ b/.github/workflows/azure-static-web-apps-victorious-moss-007e11310.yml @@ -31,6 +31,14 @@ jobs: app_location: "./" # App source code path api_location: "" # Api source code path - optional output_location: "build" # Built app content directory - optional + # Not the default `npm run build`. `PUBLIC_`-prefixed values are inlined into the + # client bundle, and the tracked `.env` holds real PUBLIC_ADMIN_USERNAME / + # PUBLIC_ADMIN_PASSWORD values for local convenience. `.env.production` blanking + # them is what keeps the deployed bundle clean today — but that is a convention, + # not a check, and any mode file that simply omitted the keys would fall through to + # the real ones. `build:verified` blanks them AND fails the build if either string + # survives into build/, which is the only version of this that cannot rot. + app_build_command: "npm run build:verified" ###### End of Repository/Build Configurations ###### close_pull_request_job: diff --git a/README.md b/README.md index 8846d8da..7ff991f0 100644 --- a/README.md +++ b/README.md @@ -92,22 +92,45 @@ Only the sizes listed in `bundle.icon` in and Microsoft Store sizes `tauri icon` also emits are gitignored — rerun the command if you start building for those targets. -`PUBLIC_`-prefixed values are inlined into the client bundle, so -`PUBLIC_ADMIN_USERNAME` and `PUBLIC_ADMIN_PASSWORD` are stripped from desktop -builds — otherwise they would be readable by anyone who unpacks the installer. -`tauri:build` verifies they are gone and refuses to package if they are not. -`tauri:dev` keeps them, so the login prefill still works while developing. +The shell exposes **no native commands** to the page. It is a window around the same app the +browser loads, so a feature that works in one works in the other. It did once bridge to a +service running on the user's own machine — that is what a native process was needed for — +and when that service moved into a container with an HTTP API, the bridge had nothing left to +do. Adding a Tauri command means adding a capability the browser build will not have, so +prefer a backend endpoint unless the thing genuinely requires the OS. ## Deployment -To manual deploy as [Azure Static Web Apps](https://learn.microsoft.com/en-us/azure/static-web-apps/) at scale. +Deployed as an [Azure Static Web App](https://learn.microsoft.com/en-us/azure/static-web-apps/). +CI runs `npm run build:verified`, not `npm run build` — see the credential note below. ```bash -npm run build -- --mode production +npm run build:verified -- --mode production npm install -g @azure/static-web-apps-cli swa deploy ./build/ --env production --deployment-token {token} ``` +### Credentials are never inlined into a distributed bundle + +`PUBLIC_`-prefixed values are compiled into the client bundle, and the committed `.env` +holds real `PUBLIC_ADMIN_USERNAME` / `PUBLIC_ADMIN_PASSWORD` values for the local login +prefill. `npm run build:verified` blanks them for the build **and then searches the output +to prove they are gone**, failing rather than deploying a readable credential. + +The verification is the part that matters. `.env.production` and `.env.staging` define both +keys as empty today, which is what keeps the deployed bundle clean — but that is a +convention nothing enforces, and any future mode file that simply omitted the keys would +fall through to `.env`'s real values. `npm run build` alone performs no such check. + +`npm run dev` and `npm run tauri:dev` are untouched: nothing is distributed, so the prefill +stays useful. + +Both distributable paths run the same check. `tauri:build` invokes +`scripts/build-frontend.mjs` as its `beforeBuildCommand`, and CI invokes it through +`build:verified`. The check began life on the desktop path only, where an installer anyone +could unpack made the risk obvious; a static bundle served over HTTPS is just as readable, so +it now guards both. + ## Customization Create a new `.env.production` file in the root folder. diff --git a/package.json b/package.json index 02510904..7299d60d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:vite": "vite dev", "open-browser": "start http://localhost:5015/", "build": "vite build", + "build:verified": "node scripts/build-frontend.mjs", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "node scripts/tauri-run.mjs dev", diff --git a/scripts/build-frontend.mjs b/scripts/build-frontend.mjs index d017e552..ca66d496 100644 --- a/scripts/build-frontend.mjs +++ b/scripts/build-frontend.mjs @@ -1,16 +1,24 @@ /** - * Builds the frontend for a desktop bundle. + * Builds the frontend for DISTRIBUTION, with a credential check that fails the build. * - * `PUBLIC_`-prefixed values are inlined into the client bundle, so any - * credential kept in an .env file for local convenience would ship inside the - * installer and be readable by anyone who unpacks it. This strips those values - * for desktop builds and then verifies they are actually gone, failing the - * build rather than shipping a secret. + * `PUBLIC_`-prefixed values are inlined into the client bundle, so any credential kept in an + * .env file for local convenience ships inside whatever is distributed and is readable by + * anyone who unpacks it. This blanks those values and then VERIFIES they are actually gone. * - * Dev builds are left alone — nothing is distributed, so the login prefill - * stays useful. + * The verification is the point, not the blanking. `.env.production` and `.env.staging` + * happen to define `PUBLIC_ADMIN_*` as empty today, which is what keeps the deployed bundle + * clean — but that is a convention nothing enforces, and the tracked `.env` holds real + * values that any mode file omitting the keys would fall through to. This script is the + * enforcement, so it has to be on the path that actually builds what gets deployed. + * + * It used to be reachable only through the script that packaged the desktop app. That build + * is gone, so the guarantee moved to the web deployment, where it matters just as much: see + * `build:verified` in package.json and `app_build_command` in the Static Web Apps workflow. + * + * Dev builds are left alone — nothing is distributed, so the login prefill stays useful. * * Usage: + * node scripts/build-frontend.mjs # --mode production * node scripts/build-frontend.mjs --mode staging * node scripts/build-frontend.mjs --mode staging --verify-only */ @@ -44,7 +52,12 @@ const overrides = Object.fromEntries(CREDENTIAL_KEYS.map((key) => [key, ''])); if (!verifyOnly) { if (stripped.length > 0) { - console.log(`Stripping from the desktop bundle: ${stripped.join(', ')}`); + console.log(`Stripping from the distributed bundle: ${stripped.join(', ')}`); + } else { + // Said out loud, because a check that verifies nothing looks exactly like a check + // that passed. Nothing to strip means the resolved env already defines these as + // empty (as `.env.production` does), so there is no value that could leak. + console.log(`No credentials to strip for mode "${mode}": ${CREDENTIAL_KEYS.join(', ')} are already empty.`); } const build = spawnSync('npx', ['vite', 'build', '--mode', mode], { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9503f086..a7d0dff0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -68,10 +68,7 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" name = "app" version = "0.1.0" dependencies = [ - "dirs 5.0.1", - "futures-util", "log", - "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -580,34 +577,13 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -618,7 +594,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -2383,17 +2359,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -2454,41 +2419,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -2519,7 +2449,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -2544,12 +2474,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -2740,18 +2664,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.21.0" @@ -3092,7 +3004,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3113,7 +3025,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "serde_repr", @@ -3142,7 +3054,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -3657,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs", "libappindicator", "muda", "objc2", @@ -3907,19 +3819,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -4229,15 +4128,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -4271,21 +4161,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -4326,12 +4201,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4344,12 +4213,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4362,12 +4225,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4386,12 +4243,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4404,12 +4255,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4422,12 +4267,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4440,12 +4279,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4508,7 +4341,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs", "dom_query", "dpi", "dunce", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e11d3c40..054ad00b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,8 +23,11 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.11.3", features = [] } tauri-plugin-log = "2" -# SimpleClaw's control API is plain HTTP on 127.0.0.1, so TLS is dead weight — -# default-features off keeps native-tls/openssl out of the build entirely. -reqwest = { version = "0.12", default-features = false, features = ["json", "stream"] } -futures-util = "0.3" -dirs = "5" +# reqwest, futures-util and dirs are deliberately gone from here. They served a bridge that +# spoke HTTP and SSE to a service on the operator's own machine and read its discovery file +# out of the userData directory. Nothing native does I/O now, and an HTTP client compiled +# into the shell with no caller is only attack surface. +# +# Cargo.lock still lists all three: Tauri resolves its own `dirs` (v6, and it IS built) plus +# optional copies of reqwest and futures-util that no enabled feature reaches. A name in the +# lockfile is not a crate in the binary — `cargo tree -e normal` is what answers that. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94d82371..59e81c9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,3 @@ -mod simpleclaw; - use tauri::{App, LogicalSize, Manager, Size}; /// Shrink the configured window if it would not fit the screen it opens on. @@ -38,19 +36,12 @@ fn fit_to_screen(app: &App) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // No invoke_handler: the shell exposes no commands to the page. It held a bridge to a + // service that ran on the operator's own machine, reachable from nowhere else, which is + // what a native process was needed FOR. That service is a container with an HTTP API now, + // so the page talks to it the same way it talks to any other backend and the shell is back + // to being a window around the app. tauri::Builder::default() - .manage(simpleclaw::Bridge::default()) - .invoke_handler(tauri::generate_handler![ - simpleclaw::simpleclaw_status, - simpleclaw::simpleclaw_capabilities, - simpleclaw::simpleclaw_start_run, - simpleclaw::simpleclaw_get_run, - simpleclaw::simpleclaw_follow_run, - simpleclaw::simpleclaw_unfollow_run, - simpleclaw::simpleclaw_answer_run, - simpleclaw::simpleclaw_stop_run, - simpleclaw::simpleclaw_show_window, - ]) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( diff --git a/src-tauri/src/simpleclaw.rs b/src-tauri/src/simpleclaw.rs deleted file mode 100644 index 081ef586..00000000 --- a/src-tauri/src/simpleclaw.rs +++ /dev/null @@ -1,461 +0,0 @@ -//! Bridge to SimpleClaw, the desktop agent that operates real GUIs. -//! -//! SimpleClaw exposes a control API on 127.0.0.1 and publishes its port and a fresh -//! bearer token to a discovery file in its own userData directory on every launch. -//! That token is remote control of this machine — `POST /v1/runs` runs an arbitrary -//! natural-language task against whatever application the agent is sealed to. So it -//! stays in this process: the webview gets a narrow set of commands and never sees the -//! credential. That is also why we do not reach for tauri-plugin-http or -fs, either of -//! which would hand the renderer a general capability where a specific one will do. -//! -//! Everything here is transport: discovery, auth, SSE relay, error classification. No -//! business meaning, no plan state — that all lives in the frontend orchestrator. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Mutex; - -use futures_util::StreamExt; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, State}; - -/// Event name the webview listens on. Every run event is relayed under this one name -/// with the run id in the payload, so a subscriber can filter without opening a channel -/// per run. -const RUN_EVENT: &str = "simpleclaw://run"; - -/// Electron derives its userData directory name from `productName` when packaged and -/// from the package name in development, and SimpleClaw is mid-rename. Probing a list -/// beats hardcoding one: a wrong single guess surfaces as "not installed", which is the -/// least diagnosable of our error kinds. -const CANDIDATE_DIRS: &[&str] = &[ - "SimpleClaw", - "Computer Autoplay", - "computer-autoplay", - "simpleclaw", -]; - -const DISCOVERY_FILE: &str = "autoplay-api.json"; - -// --- Errors ------------------------------------------------------------------------ - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BridgeError { - /// Discriminant the UI switches on. `notInstalled`, `notRunning` and `unauthorized` - /// need visibly different wording — "install it", "start it", "it restarted, retry" - /// are three different actions for the user. - pub kind: String, - pub message: String, -} - -impl BridgeError { - fn new(kind: &str, message: impl Into) -> Self { - Self { kind: kind.into(), message: message.into() } - } - - fn not_installed() -> Self { - Self::new( - "notInstalled", - "SimpleClaw is not installed on this machine, or has never been launched.", - ) - } - - fn not_running() -> Self { - Self::new( - "notRunning", - "SimpleClaw is not answering. Start it, then try again.", - ) - } - - fn http(status: u16, body: &str) -> Self { - // The API returns {"error": "..."} on every failure path; fall back to the raw - // body so an unexpected shape still reaches the user instead of a bare code. - let detail = serde_json::from_str::(body) - .ok() - .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned)) - .unwrap_or_else(|| body.trim().to_owned()); - - let kind = match status { - 401 => "unauthorized", - 403 => "forbidden", - 409 => "ambiguousAgent", - 404 => "notFound", - _ => "http", - }; - Self::new(kind, if detail.is_empty() { format!("HTTP {status}") } else { detail }) - } -} - -impl From for BridgeError { - fn from(err: reqwest::Error) -> Self { - if err.is_connect() { - // A discovery file pointing at a dead port is what a hard-killed SimpleClaw - // leaves behind — it is removed only on a clean quit. - BridgeError::not_running() - } else { - BridgeError::new("http", err.to_string()) - } - } -} - -type BridgeResult = Result; - -// --- Discovery --------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize)] -struct Discovery { - port: u16, - token: String, -} - -fn discovery_paths() -> Vec { - // dirs::config_dir() resolves to the same place Electron calls `appData`: - // %APPDATA% on Windows, ~/Library/Application Support on macOS, ~/.config on Linux. - let Some(base) = dirs::config_dir() else { - return Vec::new(); - }; - CANDIDATE_DIRS - .iter() - .map(|dir| base.join(dir).join(DISCOVERY_FILE)) - .collect() -} - -fn read_discovery() -> BridgeResult { - let mut last_parse_error: Option = None; - - for path in discovery_paths() { - let Ok(raw) = std::fs::read_to_string(&path) else { - continue; - }; - match serde_json::from_str::(&raw) { - Ok(info) if info.port != 0 && !info.token.is_empty() => return Ok(info), - Ok(_) => last_parse_error = Some(format!("{} is incomplete", path.display())), - Err(e) => last_parse_error = Some(format!("{}: {e}", path.display())), - } - } - - // A file that exists but cannot be used is a different problem from no file at all, - // and telling someone to install what they already have wastes their time. - match last_parse_error { - Some(detail) => Err(BridgeError::new("notInstalled", format!( - "Found a SimpleClaw discovery file but could not read it — {detail}" - ))), - None => Err(BridgeError::not_installed()), - } -} - -// --- Shared state -------------------------------------------------------------------- - -#[derive(Default)] -pub struct Bridge { - /// Cached so a burst of calls does not re-read the file, but never persisted: the - /// token is regenerated on every SimpleClaw launch and must not outlive the process. - discovery: Mutex>, - /// Runs we are currently relaying, so `follow` is idempotent and `unfollow` works. - followed: Mutex>, -} - -impl Bridge { - fn cached(&self) -> Option { - self.discovery.lock().ok().and_then(|g| g.clone()) - } - - fn resolve(&self) -> BridgeResult { - if let Some(info) = self.cached() { - return Ok(info); - } - self.refresh() - } - - fn refresh(&self) -> BridgeResult { - let info = read_discovery()?; - if let Ok(mut guard) = self.discovery.lock() { - *guard = Some(info.clone()); - } - Ok(info) - } - - fn invalidate(&self) { - if let Ok(mut guard) = self.discovery.lock() { - *guard = None; - } - } -} - -// --- HTTP ---------------------------------------------------------------------------- - -async fn send(info: &Discovery, method: reqwest::Method, path: &str, body: Option) -> BridgeResult<(u16, String)> { - let client = reqwest::Client::new(); - let mut req = client - .request(method, format!("http://127.0.0.1:{}{path}", info.port)) - .bearer_auth(&info.token); - if let Some(payload) = body { - req = req.json(&payload); - } - let res = req.send().await?; - let status = res.status().as_u16(); - let text = res.text().await.unwrap_or_default(); - Ok((status, text)) -} - -/// Perform a request, and on 401 re-read the discovery file once before giving up. -/// -/// SimpleClaw mints a new token on every launch, so a token that worked a minute ago is -/// stale the moment it restarts. Retrying once turns "you have to restart OneBrain too" -/// into something the user never notices. -async fn request(bridge: &Bridge, method: reqwest::Method, path: &str, body: Option) -> BridgeResult { - let info = bridge.resolve()?; - let (status, text) = send(&info, method.clone(), path, body.clone()).await?; - - let (status, text) = if status == 401 { - bridge.invalidate(); - let info = bridge.refresh()?; - send(&info, method, path, body).await? - } else { - (status, text) - }; - - if !(200..300).contains(&status) { - return Err(BridgeError::http(status, &text)); - } - Ok(serde_json::from_str(&text).unwrap_or(Value::Null)) -} - -// --- Commands ------------------------------------------------------------------------ - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BridgeStatus { - pub connected: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub organization: Option, - pub busy: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub active_run_id: Option, - pub queued: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Liveness probe. Returns a status object rather than an error so the UI can render a -/// disconnected state without treating it as an exception — SimpleClaw being closed is -/// an ordinary condition, not a fault. -#[tauri::command] -pub async fn simpleclaw_status(bridge: State<'_, Bridge>) -> Result { - let disconnected = |error: BridgeError| BridgeStatus { - connected: false, - version: None, - organization: None, - busy: false, - active_run_id: None, - queued: 0, - error: Some(error), - }; - - let health = match request(&bridge, reqwest::Method::GET, "/v1/health", None).await { - Ok(v) => v, - Err(e) => return Ok(disconnected(e)), - }; - - // Organization is only in /v1/capabilities; a failure there should not read as - // "disconnected", so it degrades to None. - let organization = request(&bridge, reqwest::Method::GET, "/v1/capabilities", None) - .await - .ok() - .and_then(|v| v.get("organization").and_then(|o| o.as_str()).map(str::to_owned)); - - Ok(BridgeStatus { - connected: true, - version: health.get("version").and_then(|v| v.as_str()).map(str::to_owned), - organization, - busy: health.get("busy").and_then(|v| v.as_bool()).unwrap_or(false), - active_run_id: health.get("activeRunId").and_then(|v| v.as_str()).map(str::to_owned), - queued: health.get("queued").and_then(|v| v.as_u64()).unwrap_or(0) as u32, - error: None, - }) -} - -#[tauri::command] -pub async fn simpleclaw_capabilities(bridge: State<'_, Bridge>) -> BridgeResult { - request(&bridge, reqwest::Method::GET, "/v1/capabilities", None).await -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StartRunRequest { - pub goal: String, - pub agent_id: Option, - pub expect: Option, - pub outline: Option>, -} - -#[tauri::command] -pub async fn simpleclaw_start_run(bridge: State<'_, Bridge>, req: StartRunRequest) -> BridgeResult { - let mut body = json!({ "goal": req.goal }); - if let Some(agent_id) = req.agent_id { - body["agentId"] = json!(agent_id); - } - if let Some(expect) = req.expect { - body["expect"] = json!(expect); - } - if let Some(outline) = req.outline { - body["outline"] = json!(outline); - } - request(&bridge, reqwest::Method::POST, "/v1/runs", Some(body)).await -} - -#[tauri::command] -pub async fn simpleclaw_get_run(bridge: State<'_, Bridge>, run_id: String) -> BridgeResult { - request(&bridge, reqwest::Method::GET, &format!("/v1/runs/{run_id}"), None).await -} - -#[tauri::command] -pub async fn simpleclaw_answer_run(bridge: State<'_, Bridge>, run_id: String, text: String) -> BridgeResult { - request( - &bridge, - reqwest::Method::POST, - &format!("/v1/runs/{run_id}/answer"), - Some(json!({ "text": text })), - ) - .await -} - -#[tauri::command] -pub async fn simpleclaw_stop_run(bridge: State<'_, Bridge>, run_id: String) -> BridgeResult { - request(&bridge, reqwest::Method::POST, &format!("/v1/runs/{run_id}/stop"), None).await -} - -#[tauri::command] -pub async fn simpleclaw_show_window(bridge: State<'_, Bridge>) -> BridgeResult { - request(&bridge, reqwest::Method::POST, "/v1/window/show", None).await -} - -// --- SSE relay ------------------------------------------------------------------------- - -fn emit_run_event(app: &AppHandle, run_id: &str, event: &str, data: Value) { - let _ = app.emit(RUN_EVENT, json!({ "runId": run_id, "event": event, "data": data })); -} - -/// Start relaying a run's event stream to the webview. Idempotent: subscribing twice -/// would double every event, and the page re-subscribes on mount after a reload. -/// -/// The stream is owned by this process rather than the renderer, so a webview reload -/// does not drop it and the run keeps being observed. -#[tauri::command] -pub async fn simpleclaw_follow_run(app: AppHandle, bridge: State<'_, Bridge>, run_id: String) -> BridgeResult<()> { - { - let mut followed = bridge.followed.lock().map_err(|_| BridgeError::new("http", "bridge state poisoned"))?; - if followed.contains_key(&run_id) { - return Ok(()); - } - followed.insert(run_id.clone(), ()); - } - - let info = bridge.resolve()?; - let url = format!( - "http://127.0.0.1:{}/v1/runs/{}/events?token={}", - info.port, - run_id, - urlencoding_encode(&info.token) - ); - - tauri::async_runtime::spawn(async move { - let outcome = relay(&app, &run_id, &url).await; - - if let Err(err) = outcome { - emit_run_event(&app, &run_id, "error", serde_json::to_value(&err).unwrap_or(Value::Null)); - } - // Always tell the frontend the relay is over, however it ended, so a run can - // never sit in the UI as "running" with nothing behind it. - emit_run_event(&app, &run_id, "closed", Value::Null); - - if let Some(state) = app.try_state::() { - if let Ok(mut followed) = state.followed.lock() { - followed.remove(&run_id); - } - } - }); - - Ok(()) -} - -async fn relay(app: &AppHandle, run_id: &str, url: &str) -> BridgeResult<()> { - let res = reqwest::Client::new().get(url).header("Accept", "text/event-stream").send().await?; - - let status = res.status().as_u16(); - if !(200..300).contains(&status) { - let body = res.text().await.unwrap_or_default(); - return Err(BridgeError::http(status, &body)); - } - - let mut stream = res.bytes_stream(); - let mut buffer = String::new(); - - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - buffer.push_str(&String::from_utf8_lossy(&chunk)); - - // SSE frames are separated by a blank line. Anything after the last separator is - // a partial frame and stays in the buffer for the next chunk. - while let Some(idx) = buffer.find("\n\n") { - let frame: String = buffer.drain(..idx + 2).collect(); - let Some((event, data)) = parse_frame(&frame) else { - continue; - }; - let terminal = event == "result"; - emit_run_event(app, run_id, &event, data); - if terminal { - // The server closes the stream after `result`; returning here means the - // ordinary end of a run never looks like a dropped connection. - return Ok(()); - } - } - } - - Ok(()) -} - -fn parse_frame(frame: &str) -> Option<(String, Value)> { - let mut event = String::from("message"); - let mut data_lines: Vec<&str> = Vec::new(); - - for line in frame.lines() { - if let Some(rest) = line.strip_prefix("event:") { - event = rest.trim().to_owned(); - } else if let Some(rest) = line.strip_prefix("data:") { - data_lines.push(rest.strip_prefix(' ').unwrap_or(rest)); - } - // `: ping` keep-alive comments and anything else are ignored. - } - - if data_lines.is_empty() { - return None; - } - let joined = data_lines.join("\n"); - serde_json::from_str(&joined).ok().map(|data| (event, data)) -} - -#[tauri::command] -pub async fn simpleclaw_unfollow_run(bridge: State<'_, Bridge>, run_id: String) -> BridgeResult<()> { - if let Ok(mut followed) = bridge.followed.lock() { - followed.remove(&run_id); - } - Ok(()) -} - -/// Percent-encode the token for the SSE query parameter. It is hex from `randomBytes`, -/// so nothing needs escaping today — this exists so a future token format cannot -/// silently produce a malformed URL. -fn urlencoding_encode(value: &str) -> String { - value - .bytes() - .map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(), - _ => format!("%{b:02X}"), - }) - .collect() -} diff --git a/src/lib/helpers/constants/simpleclaw.js b/src/lib/helpers/constants/simpleclaw.js deleted file mode 100644 index 47218643..00000000 --- a/src/lib/helpers/constants/simpleclaw.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * The planner agent that decomposes a business request into SimpleClaw steps. - * - * Ships with the backend plugin as data (not created by hand), so the id is the same in - * every environment and lives in source rather than in a .env. - * - * MUST match SimpleClawAgentId.Planner in - * onebrain/Plugins/OneBrain.Plugin.SimpleClaw/Constants/SimpleClawAgentId.cs - */ -export const SIMPLECLAW_PLANNER_AGENT_ID = '501ea11b-79df-47a5-abbb-b5abe202a995'; - -/** - * Route the SimpleClaw page is mounted at, matching the menu link the backend sends. - * Compared against getCleanUrl(link), which strips a leading slash, so no slash here. - */ -export const SIMPLECLAW_ROUTE = 'simpleclaw'; - -/** - * SimpleClaw stops a run that sits unanswered on a question for this long. It is not a - * suggestion — an unattended caller that ignores an `ask` gets the run killed. - */ -export const ASK_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/src/lib/helpers/types/simpleclawTypes.js b/src/lib/helpers/types/simpleclawTypes.js deleted file mode 100644 index a423319c..00000000 --- a/src/lib/helpers/types/simpleclawTypes.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * @typedef {Object} SimpleClawError - * @property {'notInstalled'|'notRunning'|'unauthorized'|'forbidden'|'ambiguousAgent'|'notFound'|'http'|'notDesktop'} kind - * @property {string} message - */ - -/** - * @typedef {Object} SimpleClawStatus - * @property {boolean} connected - * @property {string} [version] - * @property {string} [organization] - * @property {boolean} busy - * @property {string} [activeRunId] - * @property {number} queued - * @property {SimpleClawError} [error] - */ - -/** - * One operation an agent has been DEMONSTRATED doing. - * @typedef {Object} SimpleClawOperation - * @property {string} operation - * @property {string} sessionId - * @property {number} demonstratedAt - */ - -/** - * `system` (the sealed start URL) and `operations` are facts and are what routing - * should key on. `persona` is prose written for the planner — it goes stale, the other - * two do not. - * @typedef {Object} SimpleClawAgent - * @property {string} id - * @property {string} name - * @property {string} [iconUrl] Inline data: URL, or absent on an older SimpleClaw. - * @property {string?} system - * @property {'desktop'|'window'|'browser'} scopeMode - * @property {boolean} persistProfile - * @property {string} persona - * @property {SimpleClawOperation[]} operations - */ - -/** - * @typedef {Object} SimpleClawCapabilities - * @property {string} organization - * @property {SimpleClawAgent[]} agents - */ - -/** - * @typedef {Object} StartRunRequest - * @property {string} goal - * @property {string} [agentId] - * @property {string} [expect] - * @property {string[]} [outline] - */ - -/** - * @typedef {Object} StartRunResponse - * @property {string} runId - * @property {string} agentId - * @property {{confidence: number, reason: string}} [routing] - * @property {string} state - * @property {number} queuePosition - */ - -/** - * @typedef {Object} RunTodo - * @property {string} text - * @property {string} status - * @property {boolean} demoBacked - * @property {string} [sourceSessionId] - */ - -/** - * @typedef {Object} RunResult - * @property {'finished'|'stopped'|'error'} status - * @property {string} answer - * @property {string} sessionId - * @property {number} steps - * @property {RunTodo[]} todos - * @property {number?} demoCoverage Fraction of plan items a demonstration informed. - * A low value on a FAILED run means the operation was never taught — the fix is to - * record a demo, not to retry. - */ - -/** - * @typedef {Object} RunView - * @property {string} runId - * @property {string} agentId - * @property {string} goal - * @property {'queued'|'running'|'done'} state - * @property {string} status - * @property {'ask'|'takeover'|string} [pauseReason] - * @property {string} [askMessage] - * @property {boolean} takeover - * @property {number} step - * @property {number} queuePosition - * @property {string} [sessionId] - * @property {RunResult} [result] - * @property {string} [error] - */ - -/** - * Declares a value a later step references as {{name}}. `pattern` is run against the - * step's reported answer and the first match becomes the value. - * @typedef {Object} PlanCapture - * @property {string} name - * @property {string} [pattern] - */ - -/** - * @typedef {Object} PlanStep - * @property {string} agentId - * @property {string} goal - * @property {string} [expect] - * @property {PlanCapture} [capture] - */ - -/** - * `autoRun` is decided by the backend, which requires both that the copilot understood - * the request and that the plan only reads. The client obeys it rather than re-deriving - * it, so the rule cannot drift between the two sides. - * - * @typedef {Object} Plan - * @property {string} reply - * @property {PlanStep[]} steps - * @property {boolean} [autoRun] - * @property {'high'|'low'} [confidence] - * @property {boolean} [readOnly] - */ - -export default {}; diff --git a/src/lib/helpers/utils/desktop.js b/src/lib/helpers/utils/desktop.js new file mode 100644 index 00000000..bf69477e --- /dev/null +++ b/src/lib/helpers/utils/desktop.js @@ -0,0 +1,15 @@ +/** + * Whether the app is running inside the Tauri desktop shell rather than a browser tab. + * + * One bundle serves both, so this is a runtime question, not a build-time one. `$env` and + * `import.meta.env` cannot answer it: the same compiled output is loaded by the shell and by + * the browser, and only the shell injects `__TAURI_INTERNALS__` into the page. + * + * Safe before mount and during prerendering — `window` is absent there, which reads as "not + * the desktop", the correct answer for a page being rendered to a static file. + * + * @returns {boolean} + */ +export function isDesktop() { + return typeof window !== 'undefined' && !!(/** @type {any} */ (window).__TAURI_INTERNALS__); +} diff --git a/src/lib/langs/en.json b/src/lib/langs/en.json index 59eea981..7f905c0b 100644 --- a/src/lib/langs/en.json +++ b/src/lib/langs/en.json @@ -20,7 +20,6 @@ "TUI-Calendar": "TUI-Calendar", "Full-Calendario": "Full-Calendario", "Chat": "Chat", - "Open in SimpleClaw": "Open in SimpleClaw", "File Manager": "File Manager", "New": "New", "Hot": "Hot", @@ -181,8 +180,6 @@ "Task":"Task", "MongoDB":"MongoDB", "Plugins":"Plugins", - "SimpleClaw":"SimpleClaw", - "Computer use":"Computer use", "Settings": "Settings", "Search":"Search", "Welcome Back !":"Welcome Back !", diff --git a/src/lib/langs/zh.json b/src/lib/langs/zh.json index 49617a79..c2c06b19 100644 --- a/src/lib/langs/zh.json +++ b/src/lib/langs/zh.json @@ -374,7 +374,6 @@ "Agent":"代理", "Knowledge Base":"知识库", "Conversation":"对话", - "Open in SimpleClaw":"在 SimpleClaw 中打开", "Task":"任务", "MongoDB":"MongoDB", "Home":"主页", diff --git a/src/lib/services/simpleclaw-orchestrator.js b/src/lib/services/simpleclaw-orchestrator.js deleted file mode 100644 index d7e26f75..00000000 --- a/src/lib/services/simpleclaw-orchestrator.js +++ /dev/null @@ -1,553 +0,0 @@ -/** - * The run loop: plan a request, then walk its steps one at a time, carrying values - * between them. - * - * The two halves of the contract are kept strictly apart. This file speaks only - * business language — goals, values, which agent — and never clicks or coordinates. - * SimpleClaw owns the other half. Keeping that line clean is what lets either side - * change without breaking the other. - * - * Only one orchestration may be in flight at a time. SimpleClaw's loop and browser - * backend are single-flight, so two loops pushing steps into the same queue would have - * their steps interleave and destroy each other's browser sessions. - */ - -import { newConversation, sendMessageToHub, getDialogs } from '$lib/services/conversation-service'; -import { BOT_SENDERS } from '$lib/helpers/constants'; -import * as simpleclaw from '$lib/services/simpleclaw-service'; -import { SIMPLECLAW_PLANNER_AGENT_ID } from '$lib/helpers/constants/simpleclaw'; - -/** - * Render the agent roster the planner routes on. - * - * Only `system` and `operations` are included. `persona` is deliberately left out: in - * practice it holds the agent's ReAct operating instructions ("each turn, briefly - * reason, then call exactly one tool…"), which say nothing about which business system - * the agent serves. Feeding that to the planner only dilutes the two fields that do. - * - * @param {import('$simpleclawTypes').SimpleClawAgent[]} agents - * @returns {string} - */ -export function buildRoster(agents) { - if (!agents?.length) return '(no agents are configured)'; - - return agents - .map((a) => { - // "not demonstrated yet" is not "cannot do it" — the planner is told to route - // on reachability and use this only to break ties. - const ops = a.operations?.length - ? a.operations.map((o) => o.operation).join(' | ') - : '(none demonstrated yet)'; - return [ - `- id: ${a.id}`, - ` name: ${a.name}`, - ` system: ${a.system ?? '(desktop — operates local applications, not a website)'}`, - ` operations: ${ops}` - ].join('\n'); - }) - .join('\n'); -} - -/** The fence the copilot is told to use. */ -const PLAN_FENCE = /```simpleclaw-plan\s*([\s\S]*?)```/i; - -/** - * Any fenced block, whatever its tag. Used only as a fallback, and only when the block - * turns out to look like a plan — models reach for ```json out of habit, and losing a - * plan over the tag would silently drop the work the person asked for. - */ -const ANY_FENCE = /```[a-z-]*\s*([\s\S]*?)```/gi; - -/** - * Is this a plan and not, say, a JSON snippet the copilot quoted in an answer? Requiring - * an agentId on every step is what makes the test safe: prose about JSON does not carry - * one, and a plan without one cannot run anyway. - * - * @param {any} value - */ -function looksLikePlan(value) { - return ( - value && - typeof value === 'object' && - Array.isArray(value.steps) && - value.steps.length > 0 && - value.steps.every((/** @type {any} */ s) => s && typeof s.agentId === 'string' && s.agentId) - ); -} - -/** - * @typedef {{ kind: 'text', text: string } - * | { kind: 'plan', reply: string, plan: import('$simpleclawTypes').Plan }} CopilotTurn - */ - -/** - * Interpret the TEXT of one assistant turn. `readTurn` is the way in; this is its - * fallback for a plan that arrived inline instead of as a function call. - * - * Most turns are ordinary prose — the copilot answers, asks something back, or reports - * on an earlier result. Only a turn carrying a plan block is a request to operate a real - * system, and that distinction has to be exact: treating prose as a plan would run - * nothing, and missing a plan block would silently drop the work. - * - * @param {string} text - * @returns {CopilotTurn} - */ -export function parseTurn(text) { - const raw = (text ?? '').trim(); - - const tagged = raw.match(PLAN_FENCE); - if (tagged) { - let parsed; - try { - parsed = JSON.parse(tagged[1].trim()); - } catch { - throw new Error('The copilot produced a plan block that is not valid JSON.'); - } - if (!Array.isArray(parsed.steps)) { - throw new Error('The plan block has no steps array.'); - } - return toPlanTurn(parsed, raw.replace(PLAN_FENCE, '').trim()); - } - - // Fallbacks. The copilot is asked for a `simpleclaw-plan` fence, but it is a language - // model: it reaches for ```json, or drops the fence entirely. Recovering here is - // cheap; the alternative is a plan quietly read as chat and never run. - for (const [block, body] of raw.matchAll(ANY_FENCE)) { - const parsed = tryParse(body); - if (looksLikePlan(parsed)) { - return toPlanTurn(parsed, raw.replace(block, '').trim()); - } - } - - const bare = tryParse(raw); - if (looksLikePlan(bare)) { - return toPlanTurn(bare, ''); - } - - return { kind: 'text', text: raw }; -} - -/** @param {string} body */ -function tryParse(body) { - try { - return JSON.parse(body.trim()); - } catch { - return null; - } -} - -/** - * @param {any} parsed - * @param {string} outside prose that came alongside the block, if any - * @returns {CopilotTurn} - */ -function toPlanTurn(parsed, outside) { - return { - kind: 'plan', - reply: outside, - plan: { - // `reply` on the plan is what the card shows above the steps; fall back to any - // prose that came with the block. - reply: String(parsed.reply ?? outside), - steps: parsed.steps, - // Never inferred here. The backend applies the rule; an absent flag means - // confirm, which is the safe direction for a field that went missing. - autoRun: parsed.autoRun === true, - confidence: parsed.confidence, - readOnly: parsed.readOnly === true - } - }; -} - -/** - * Take the useful value out of a step's prose answer, so the next step's goal reads as - * an instruction rather than a quotation. - * - * Returns `matched: false` when the pattern misses. The caller surfaces that, because - * substituting a whole paragraph into the next goal is a real degradation and silently - * doing it makes the resulting confusion impossible to trace. - * - * @param {string} answer - * @param {string} [pattern] - * @returns {{ value: string, matched: boolean }} - */ -export function captureValue(answer, pattern) { - const text = (answer ?? '').trim(); - if (!pattern) return { value: text, matched: true }; - - try { - const hit = text.match(new RegExp(pattern)); - if (hit) return { value: hit[0], matched: true }; - } catch { - // A malformed pattern from the planner is not worth aborting a run over. - } - return { value: text, matched: false }; -} - -/** - * Substitute {{name}} references with values captured by earlier steps. - * - * An unresolved reference is left verbatim and reported. Replacing it with an empty - * string would hand a mutilated instruction to an agent that is about to operate a real - * system — far worse than stopping. - * - * @param {string} text - * @param {Record} values - * @returns {{ text: string, missing: string[] }} - */ -export function resolveRefs(text, values) { - /** @type {string[]} */ - const missing = []; - - const resolved = (text ?? '').replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, name) => { - if (Object.prototype.hasOwnProperty.call(values, name)) { - return values[name]; - } - // {{stepN}} — positional fallback, so a planner that ignores the capture - // convention still produces a runnable plan. - const positional = /^step(\d+)$/i.exec(name); - if (positional) { - const key = `__step${Number(positional[1])}`; - if (Object.prototype.hasOwnProperty.call(values, key)) return values[key]; - } - missing.push(name); - return whole; - }); - - return { text: resolved, missing }; -} - -/** - * @typedef {Object} OrchestratorCallbacks - * @property {(phase: string) => void} [onPhase] - * @property {(plan: import('$simpleclawTypes').Plan) => void} [onPlan] - * @property {(index: number, step: import('$simpleclawTypes').PlanStep, goal: string) => void} [onStepStart] - * @property {(index: number, runId: string, queuePosition: number) => void} [onQueued] - * @property {(index: number, event: string, data: any) => void} [onRunEvent] - * @property {(index: number, result: import('$simpleclawTypes').RunResult, captured: string?) => void} [onStepEnd] - * @property {(message: string) => void} [onWarning] - * @property {(summary: {ok: boolean, text: string, needsDemo?: boolean, agentId?: string}) => void} [onDone] - */ - -/** - * Open a BotSharp conversation for the planner. - * - * Created lazily by the caller on the first request, not on page load — opening the page - * and typing nothing should not leave an empty conversation behind. - * - * @returns {Promise} conversation id - */ -export async function createPlannerConversation() { - const conv = await newConversation(SIMPLECLAW_PLANNER_AGENT_ID); - if (!conv?.id) throw new Error('Could not open a planner conversation.'); - return conv.id; -} - -/** - * Read an existing thread back so a session can be picked up where it was left. - * - * Only the prose survives. A plan lives on the chat response, not in the stored dialog, - * so a past plan comes back as the sentence that introduced it — and that is the right - * outcome rather than a gap to close. Restoring a live "Run these steps" button on a plan - * from another day would invite re-running work that already happened, and some of these - * steps are irreversible in a real system. - * - * @param {string} conversationId - * @returns {Promise<{role: 'user'|'assistant', text: string}[]>} - */ -export async function loadHistory(conversationId) { - const dialogs = await getDialogs(conversationId); - return (dialogs || []) - .map((d) => ({ - role: /** @type {'user'|'assistant'} */ ( - BOT_SENDERS.includes(d.sender?.role || '') ? 'assistant' : 'user' - ), - text: (d.text ?? '').trim() - })) - .filter((d) => d.text.length > 0); -} - -/** - * Send one turn to the copilot and interpret what comes back. - * - * Goes through a conversation rather than the one-shot instruct endpoint. A session is a - * conversation: the person asks several things and follows up on earlier answers, and - * operating a computer is one task within that, not the point of it. The thread is also - * what makes results usable later — "what was that work order's status again" only works - * if the earlier outcome is still in the history. - * - * The roster is re-sent every turn. It is current state, not history: an agent may have - * gained a demonstration since the last message, and a stale roster is exactly what - * produces a plan routed at the wrong system. - * - * @param {string} text - * @param {import('$simpleclawTypes').SimpleClawAgent[]} agents - * @param {string} conversationId - * @returns {Promise} - */ -export async function sendTurn(text, agents, conversationId) { - const res = await sendMessageToHub(SIMPLECLAW_PLANNER_AGENT_ID, conversationId, text, { - states: [{ key: 'roster', value: buildRoster(agents) }] - }); - return readTurn(res); -} - -/** - * Interpret one chat response. - * - * A plan arrives as a function call, not as text. The copilot's `operate_business_system` - * tool call is validated against a schema before it ever reaches here, and it has no - * partial form — which is the whole point of moving it out of the reply. Prose that - * merely announces work ("I'll check that now.") is what a plan used to degrade into, - * and it left the person waiting on a run that was never proposed. - * - * @param {any} res chat response from the hub - * @returns {CopilotTurn} - */ -export function readTurn(res) { - const plan = res?.data?.simpleclaw_plan; - if (looksLikePlan(plan)) { - return toPlanTurn(plan, ''); - } - - // The text path stays as a fallback for a reply that carries a plan inline. It should - // not happen now that the tool exists, but recovering costs nothing and the failure - // it guards against — silently reading a plan as chat — is invisible to the person. - return parseTurn(res?.text ?? ''); -} - -/** - * Put the outcome of a run back into the conversation. - * - * Without this the thread has a hole in it: the person saw the result on screen but the - * copilot never did, so the next question about it gets answered from nothing. Posting - * it as a turn also lets the copilot phrase the answer to the original question, which - * is usually more useful than the agent's raw closing line. - * - * The wording names the function on purpose, and that is not decoration. BotSharp stores a - * plan turn as a plain assistant row — the reply survives, the tool call does not — so on - * the next turn the copilot's own history shows it answering a system lookup with nothing - * but a sentence, and the result appearing out of nowhere. Left alone, that is a worked - * example of the one failure the prompt forbids, sitting closer to the model than the - * prompt is. Attributing the outcome to the call restores the half the record dropped. - * - * @param {string} conversationId - * @param {import('$simpleclawTypes').SimpleClawAgent[]} agents - * @param {{ok: boolean, text: string, needsDemo?: boolean}} outcome - * @returns {Promise} - */ -export async function reportOutcome(conversationId, agents, outcome) { - const head = outcome.ok - ? 'The plan you proposed by calling operate_business_system has finished' - : 'The plan you proposed by calling operate_business_system did not finish'; - return sendTurn(`[${head}] ${outcome.text}`, agents, conversationId); -} - -/** - * First-person commitment to go and do something. - */ -const COMMITS_TO_WORK = /\b(?:i['’]ll|i will|i['’]m going to|i am going to|let me)\b/i; - -/** - * Wording that commits to nothing after all: a question back, or a stated inability. These - * are the legitimate prose turns that would otherwise trip the pattern above — "I'll need - * the work order number first" is the copilot doing exactly what it should. - */ -const COMMITS_TO_NOTHING = /\?|\b(?:need|needed|require[sd]?|cannot|can['’]?t|unable|no agent|don['’]?t have)\b/i; - -/** - * Does this prose promise work in a system that no plan was proposed for? - * - * A heuristic, and it has to be: the signal is English phrasing, not structure. It is - * tuned to be cheap to be wrong about in either direction. A false positive costs one - * extra round-trip and produces a second prose turn. A false negative just leaves today's - * behaviour. Neither can start work — only a plan the person confirms does that. - * - * @param {string} text - * @returns {boolean} - */ -export function announcesUnproposedWork(text) { - const prose = (text ?? '').trim(); - if (!prose) return false; - return COMMITS_TO_WORK.test(prose) && !COMMITS_TO_NOTHING.test(prose); -} - -/** - * Ask the copilot to either propose the work it just described, or take back the - * description. - * - * Worth a round-trip because the alternative is silence: a turn that says "I'll look that - * up" and proposes nothing leaves the person watching a thread where their request simply - * stopped, with nothing on screen saying so. The wording covers both readings, so a - * misfire resolves into an ordinary answer rather than an argument about what was meant. - * - * Deliberately not used on the turn that follows a run. That turn is supposed to be prose, - * and pressing it to call the function again is how work that just happened gets proposed - * a second time. - * - * @param {string} conversationId - * @param {import('$simpleclawTypes').SimpleClawAgent[]} agents - * @returns {Promise} - */ -export async function nudgeForPlan(conversationId, agents) { - return sendTurn( - '[Nothing reached the operator] Your last turn described work in a business system' + - ' but made no operate_business_system call, so nothing was proposed and nothing' + - ' ran. If you meant to do that work, call the function now. If you did not,' + - ' answer in prose without describing work you are not proposing.', - agents, - conversationId - ); -} - -/** - * Execute a confirmed plan, one step at a time. - * - * Deliberately does NOT retry a failed step. SimpleClaw cannot know whether retrying is - * safe — a resubmitted filing is a real duplicate — and neither can this loop. That - * decision belongs to the person, who is the only one who knows what the last attempt - * already did. - * - * @param {import('$simpleclawTypes').Plan} plan - * @param {OrchestratorCallbacks} cb - * @returns {Promise} - */ -export async function runPlan(plan, cb = {}) { - /** @type {Record} */ - const values = {}; - let lastAnswer = ''; - - for (let i = 0; i < plan.steps.length; i++) { - const step = plan.steps[i]; - const { text: goal, missing } = resolveRefs(step.goal, values); - - // Whichever step we end on is the one whose agent produced the answer, so the - // outcome is attributed to it rather than to the plan as a whole. - if (missing.length) { - cb.onDone?.({ - ok: false, - agentId: step.agentId, - text: `Step ${i + 1} refers to ${missing.map((m) => `{{${m}}}`).join(', ')}, which no earlier step produced. Stopping before anything is changed.` - }); - return; - } - - cb.onStepStart?.(i, step, goal); - - /** @type {import('$simpleclawTypes').StartRunResponse} */ - let started; - try { - started = await simpleclaw.startRun({ goal, agentId: step.agentId, expect: step.expect }); - } catch (err) { - cb.onDone?.({ ok: false, agentId: step.agentId, text: `Could not start step ${i + 1}: ${errorText(err)}` }); - return; - } - - cb.onQueued?.(i, started.runId, started.queuePosition ?? 0); - - /** @type {import('$simpleclawTypes').RunResult} */ - let result; - try { - result = await followToResult(started.runId, (event, data) => cb.onRunEvent?.(i, event, data)); - } catch (err) { - cb.onDone?.({ ok: false, agentId: step.agentId, text: `Lost track of step ${i + 1}: ${errorText(err)}` }); - return; - } - - if (result.status !== 'finished') { - // Partial completion is normal in a multi-system flow. Report where it - // stopped and, when the plan was mostly improvised, say what would actually - // fix it — a demonstration, not a reworded prompt. - const lowCoverage = result.demoCoverage !== null && result.demoCoverage < 0.5; - cb.onStepEnd?.(i, result, null); - cb.onDone?.({ - ok: false, - agentId: step.agentId, - needsDemo: lowCoverage, - text: - `Step ${i + 1} did not complete: ${result.answer || result.status}.` + - (lowCoverage - ? ' Most of that plan was improvised rather than backed by a demonstration — recording one for this operation would likely fix it.' - : '') - }); - return; - } - - lastAnswer = result.answer; - values[`__step${i + 1}`] = result.answer.trim(); - - let captured = null; - if (step.capture?.name) { - const { value, matched } = captureValue(result.answer, step.capture.pattern); - values[step.capture.name] = value; - captured = value; - if (!matched) { - cb.onWarning?.( - `Step ${i + 1} reported an answer that did not match the expected shape for "${step.capture.name}", so the whole answer is being passed on. The next step may misread it.` - ); - } - } - - cb.onStepEnd?.(i, result, captured); - } - - cb.onDone?.({ ok: true, agentId: plan.steps[plan.steps.length - 1]?.agentId, text: lastAnswer }); -} - -/** - * Subscribe to a run and resolve with its terminal result. - * - * @param {string} runId - * @param {(event: string, data: any) => void} onEvent - * @returns {Promise} - */ -function followToResult(runId, onEvent) { - return new Promise((resolve, reject) => { - /** @type {(() => void) | null} */ - let unsubscribe = null; - // Covers every terminal path, not just success. Events can arrive before - // followRun's promise resolves, and a run that errors in that window would - // otherwise leave its listener attached for the rest of the session. - let done = false; - - function cleanup() { - done = true; - unsubscribe?.(); - unsubscribe = null; - } - - simpleclaw - .followRun(runId, (event, data) => { - onEvent(event, data); - if (done) return; - - if (event === 'result') { - cleanup(); - resolve(data); - } else if (event === 'closed') { - cleanup(); - reject(new Error('The event stream ended before the run finished.')); - } else if (event === 'error') { - cleanup(); - reject(new Error(data?.message ?? 'The event stream failed.')); - } - }) - .then((fn) => { - unsubscribe = fn; - // Terminal already reached while we were still subscribing. - if (done) cleanup(); - }) - .catch(reject); - }); -} - -/** - * @param {any} err - * @returns {string} - */ -export function errorText(err) { - if (!err) return 'Unknown error'; - if (typeof err === 'string') return err; - if (err.message) return err.message; - return String(err); -} diff --git a/src/lib/services/simpleclaw-service.js b/src/lib/services/simpleclaw-service.js deleted file mode 100644 index 629f037b..00000000 --- a/src/lib/services/simpleclaw-service.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Thin wrapper over the Tauri commands in src-tauri/src/simpleclaw.rs. - * - * Stateless by design, like the other *-service.js modules: it moves calls across the - * Rust boundary and nothing else. The run loop lives in simpleclaw-orchestrator.js. - * - * Everything here is desktop-only. The imports of @tauri-apps/api are deliberately - * dynamic — a static import would be resolved at module load and break the web build, - * which ships the same bundle without a Tauri runtime behind it. - */ - -/** @returns {boolean} */ -export function isDesktop() { - return typeof window !== 'undefined' && !!(/** @type {any} */ (window).__TAURI_INTERNALS__); -} - -/** - * @param {string} cmd - * @param {Record} [args] - * @returns {Promise} - */ -async function call(cmd, args = {}) { - if (!isDesktop()) { - throw new Error('SimpleClaw is only available in the desktop app.'); - } - const { invoke } = await import('@tauri-apps/api/core'); - return invoke(cmd, args); -} - -/** - * Liveness probe. Never throws for a disconnected SimpleClaw — that is an ordinary - * state with its own rendering, not an exception. - * @returns {Promise} - */ -export async function getStatus() { - if (!isDesktop()) { - return { - connected: false, - busy: false, - queued: 0, - error: { kind: 'notDesktop', message: 'SimpleClaw requires the desktop app.' } - }; - } - return call('simpleclaw_status'); -} - -/** @returns {Promise} */ -export async function getCapabilities() { - return call('simpleclaw_capabilities'); -} - -/** - * @param {import('$simpleclawTypes').StartRunRequest} req - * @returns {Promise} - */ -export async function startRun(req) { - return call('simpleclaw_start_run', { req }); -} - -/** - * @param {string} runId - * @returns {Promise} - */ -export async function getRun(runId) { - return call('simpleclaw_get_run', { runId }); -} - -/** - * Relay a run's events to `onEvent`. - * - * The stream itself is held by the Rust side, so this only attaches a listener: a page - * reload re-attaches without interrupting the run. Calling it twice for one run is safe - * (the Rust side is idempotent), but each call installs its own listener, so always - * await and keep the returned unsubscribe. - * - * @param {string} runId - * @param {(event: string, data: any) => void} onEvent - * @returns {Promise<() => void>} unsubscribe - */ -export async function followRun(runId, onEvent) { - const { listen } = await import('@tauri-apps/api/event'); - const unlisten = await listen('simpleclaw://run', (e) => { - const payload = /** @type {any} */ (e.payload); - if (payload?.runId !== runId) return; - onEvent(payload.event, payload.data); - }); - - // Attach the listener BEFORE asking Rust to start relaying, otherwise the opening - // `state` frame can be emitted before anyone is listening for it. - await call('simpleclaw_follow_run', { runId }); - - return () => { - unlisten(); - call('simpleclaw_unfollow_run', { runId }).catch(() => {}); - }; -} - -/** - * @param {string} runId - * @param {string} text - */ -export async function answerRun(runId, text) { - return call('simpleclaw_answer_run', { runId, text }); -} - -/** @param {string} runId */ -export async function stopRun(runId) { - return call('simpleclaw_stop_run', { runId }); -} - -/** Bring the SimpleClaw window forward — live view and takeover already live there. */ -export async function showSimpleClaw() { - return call('simpleclaw_show_window'); -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 61d9e516..8200a873 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -18,7 +18,7 @@ import { OverlayScrollbars } from 'overlayscrollbars'; import { addMessages, init, getLocaleFromNavigator } from 'svelte-i18n'; import { PUBLIC_PRIMARY_COLOR, PUBLIC_SECONDARY_COLOR } from '$env/static/public'; - import { isDesktop } from '$lib/services/simpleclaw-service'; + import { isDesktop } from '$lib/helpers/utils/desktop'; import en from '$lib/langs/en.json'; addMessages('en', en); diff --git a/src/routes/VerticalLayout/Index.svelte b/src/routes/VerticalLayout/Index.svelte index 1bbca6a4..d16f031a 100644 --- a/src/routes/VerticalLayout/Index.svelte +++ b/src/routes/VerticalLayout/Index.svelte @@ -5,9 +5,6 @@ import { getPluginMenu } from '$lib/services/plugin-service'; import { myInfo } from '$lib/services/auth-service'; import { globalMenuStore } from '$lib/helpers/store'; - import { getCleanUrl } from '$lib/helpers/utils/common'; - import { isDesktop } from '$lib/services/simpleclaw-service'; - import { SIMPLECLAW_ROUTE } from '$lib/helpers/constants/simpleclaw'; import LoadingToComplete from '$lib/common/spinners/LoadingToComplete.svelte'; import GlobalHeader from '$lib/common/shared/GlobalHeader.svelte'; import Header from './Header.svelte'; @@ -52,19 +49,16 @@ toggleRightBar(); }; - // Features that only work in the desktop shell. The backend serves one menu to both - // builds, so the web build has to drop these itself rather than show a dead entry. - // A second such feature should turn this into a lookup, not another condition. - const DESKTOP_ONLY_ROUTES = [SIMPLECLAW_ROUTE]; - - /** @param {import('$pluginTypes').PluginMenuDefModel[]} items */ - function applyPlatformVisibility(items) { - if (isDesktop()) return items; - return (items || []).filter((x) => !DESKTOP_ONLY_ROUTES.includes(getCleanUrl(x.link))); - } - + // The backend menu is the only source of truth for which entries appear; it already + // role-gates them, and nothing is filtered client-side. + // + // There was a platform filter here that dropped desktop-only routes in the browser build. + // It existed for a single entry whose page drove a service reachable only from the user's + // own machine. That page is gone and every remaining entry works in both builds, so the + // filter had nothing left to hide. Bring it back only for a feature that genuinely cannot + // work in a browser tab — not for one that merely has a nicer desktop experience. onMount(async () => { - menu = applyPlatformVisibility(await getPluginMenu()); + menu = await getPluginMenu(); globalMenuStore.set(menu || []); user = await myInfo(); if (browser) { diff --git a/src/routes/page/conversation/+page.svelte b/src/routes/page/conversation/+page.svelte index 13d2eadb..48c74a63 100644 --- a/src/routes/page/conversation/+page.svelte +++ b/src/routes/page/conversation/+page.svelte @@ -24,11 +24,8 @@ setUrlQueryParams, goToUrl, convertTimeRange, - formatNumber, - getCleanUrl + formatNumber } from '$lib/helpers/utils/common'; - import { globalMenuStore } from '$lib/helpers/store'; - import { SIMPLECLAW_ROUTE, SIMPLECLAW_PLANNER_AGENT_ID } from '$lib/helpers/constants/simpleclaw'; const duration = 3000; const firstPage = 1; @@ -61,19 +58,6 @@ /** @type {import('$commonTypes').LabelValuePair[]} */ let agentOptions = $state([]); - /** - * Whether the SimpleClaw module is available here. - * - * One check answers all three questions at once. The backend only puts this entry in - * the menu when the plugin is installed and the signed-in role is allowed it, and the - * layout then drops it in the web build, where the feature cannot work at all. Asking - * the menu we were actually given is therefore more accurate than testing any of the - * three separately — and it stays correct if any of them changes. - */ - let simpleClawEnabled = $derived( - ($globalMenuStore || []).some((x) => getCleanUrl(x.link) === SIMPLECLAW_ROUTE) - ); - /** @type {import('$commonTypes').LabelValuePair[]} */ const statusOptions = [ { value: 'open', label: 'Active' }, @@ -660,22 +644,6 @@ > - {#if simpleClawEnabled && conv.agent_id === SIMPLECLAW_PLANNER_AGENT_ID} - - - {/if} - {/each} -
-
- {/if} - - {#each messages as m (m.id)} - {#if m.kind === 'user'} -
-
- {m.text} -
-
- {:else if m.kind === 'assistant'} -
- {@render botAvatar()} -
- - -
-
- {:else if m.kind === 'plan'} - - {:else if m.kind === 'running'} - - {:else if m.kind === 'done'} - - {:else if m.kind === 'ask'} - sendAnswer(m.id, text)} - /> - {:else if m.kind === 'takeover'} - - {:else if m.kind === 'warning'} -
- -

{m.text}

-
- {:else if m.kind === 'result'} - - {/if} - {/each} - - {#if phase === 'planning'} -
- {@render botAvatar()} -
- - {#each [0, 150, 300] as delay} - - {/each} - Thinking -
-
- {/if} -
-
- - - {#if !pinned && messages.length > 0} - - {/if} -