mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 15:36:40 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454) fix(macos): skip single-instance lock in Mac App Store builds * Fix/backend optional sharp (#1455) Fix/backend optional sharp * feat: issue #1218 (#1218) https://github.com/Termix-SSH/Support/issues/1218 * feat: Add option to use saved global custom theme under Connection Defaults (#1209) https://github.com/Termix-SSH/Support/issues/1209 * feat: Add Fleet Sharing functionality to the UI (#1228) https://github.com/Termix-SSH/Support/issues/1228 * feat: Add a list view to the Docker management page (#1237) https://github.com/Termix-SSH/Support/issues/1237 * feat: issue #1264 (#1264) https://github.com/Termix-SSH/Support/issues/1264 * feat: 2FA With FortiToken (#1288) https://github.com/Termix-SSH/Support/issues/1288 * feat: Allow to disable showing paths to folders (#1274) https://github.com/Termix-SSH/Support/issues/1274 * feat: Sync Network Graph between Desktop and Remote Server (#1245) https://github.com/Termix-SSH/Support/issues/1245 * feat: Version Number on offline servers (#1291) https://github.com/Termix-SSH/Support/issues/1291 * feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302) https://github.com/Termix-SSH/Support/issues/1302 * feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289) https://github.com/Termix-SSH/Support/issues/1289 * fix: Search in side bar shows overlapping hosts when grouped by tags (#1303) https://github.com/Termix-SSH/Support/issues/1303 * fix: I can't send a file on its own. (#1304) https://github.com/Termix-SSH/Support/issues/1304 * fix: sudo password not autofilling (#1248) https://github.com/Termix-SSH/Support/issues/1248 * fix: node-MainThread (#1300) https://github.com/Termix-SSH/Support/issues/1300 * fix: Tunnel authentication with shared credentials (#1295) https://github.com/Termix-SSH/Support/issues/1295 * fix: host key updates not syncing, metrics 404 race on first connect Host key writes never bumped updatedAt so sync never picked them up. Also retry the first metrics fetch briefly instead of failing right away. * chore: increment ver * feat: add category and icon fields to plugin manifest schema * ci(deps): bump the github-actions group with 2 updates (#1458) Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script). Updates `crowdin/github-action` from 2 to 3 - [Release notes](https://github.com/crowdin/github-action/releases) - [Commits](https://github.com/crowdin/github-action/compare/v2...v3) Updates `actions/github-script` from 7 to 9 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: crowdin/github-action dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459) Bumps the dev-patch-updates group with 13 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` | | [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` | | [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` | | [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` | | [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` | | [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` | | [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` | Updates `@codemirror/commands` from 6.11.0 to 6.11.1 - [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/commands/commits) Updates `@codemirror/search` from 6.7.1 to 6.7.2 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.9 to 6.43.12 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@testing-library/dom` from 10.4.1 to 10.4.2 - [Release notes](https://github.com/testing-library/dom-testing-library/releases) - [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2) Updates `@testing-library/react` from 16.3.2 to 16.3.3 - [Release notes](https://github.com/testing-library/react-testing-library/releases) - [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3) Updates `@testing-library/user-event` from 14.6.5 to 14.6.7 - [Release notes](https://github.com/testing-library/user-event/releases) - [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7) Updates `@types/ssh2` from 1.15.5 to 1.15.6 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2) Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react) Updates `cytoscape` from 3.34.1 to 3.34.3 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3) Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7) Updates `i18next` from 26.4.0 to 26.4.2 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2) Updates `prettier` from 3.9.6 to 3.9.8 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8) Updates `react-i18next` from 17.0.12 to 17.0.14 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14) --- updated-dependencies: - dependency-name: "@codemirror/commands" dependency-version: 6.11.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/search" dependency-version: 6.7.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@testing-library/dom" dependency-version: 10.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@testing-library/react" dependency-version: 16.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@testing-library/user-event" dependency-version: 14.6.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@types/ssh2" dependency-version: 1.15.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.1.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: cytoscape dependency-version: 3.34.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.9.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 6 updates (#1461) Bumps the prod-patch-updates group with 6 updates: | Package | From | To | | --- | --- | --- | | [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` | | [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` | | [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` | | [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` | | [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` | | [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` | Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13 - [Release notes](https://github.com/TanStack/virtual/releases) - [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md) - [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual) Updates `compression` from 1.8.1 to 1.8.2 - [Release notes](https://github.com/expressjs/compression/releases) - [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2) Updates `jose` from 6.2.9 to 6.2.12 - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12) Updates `jszip` from 3.10.1 to 3.10.2 - [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md) - [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2) Updates `socks` from 2.8.9 to 2.8.10 - [Release notes](https://github.com/JoshGlazebrook/socks/releases) - [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10) Updates `undici` from 8.10.0 to 8.10.2 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2) --- updated-dependencies: - dependency-name: "@tanstack/react-virtual" dependency-version: 3.14.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: compression dependency-version: 1.8.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: jose dependency-version: 6.2.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: jszip dependency-version: 3.10.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: socks dependency-version: 2.8.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: undici dependency-version: 8.10.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add plugin worker protocol, bootstrap and plugin_storage table Worker-side ctx proxy plus the message envelope it talks over, and the per-plugin key/value table behind ctx.storage with migrations for all three dialects. Rows cascade with the plugin so uninstalling leaves nothing behind. * feat: add allowlisted host view for plugins An allowlist rather than a list of fields to strip. A blocklist rots: the moment a new secret-bearing column lands on SSHHost it starts leaking silently. With an allowlist a new field stays invisible to plugins until someone adds it here on purpose. * feat: add plugin loader with manifest validation and crash restart Validates an unpacked plugin, spawns a worker_threads worker and manages activate/deactivate/crash-restart with backoff, disabling after three tries. The crash counter clears only after a plugin stays up past a stability window, not on activation. Clearing it on activation let a plugin that activates cleanly and then dies restart forever, because every attempt looked like the first. loader.ts documents what the worker boundary does and does not buy you. Node's permission model is process-wide, not per-worker, so it cannot be enabled for plugin workers alone and is not claimed here. * feat: route internal events through a shared plugin event bus Formalises three ad-hoc publish paths that already existed: automation-events.ts, metrics/automation-bridge.ts and the hostSessionStatus singleton, now the host.session.status topic. Both invariants the old code relied on are preserved. Publishing stays fire-and-forget, so a failing subscriber cannot disturb a metrics poll or a host delete. The automations engine subscribes to the bus at scheduler start instead of being imported ad hoc, which keeps the edge one-directional: automations may import repositories, hosts modules must not import automations. * feat: add plugin ctx broker with permission gate and audit Every ctx call a plugin makes is checked against its granted capabilities, performed by the main thread, then audited. A capability must be both declared in the manifest and granted in the database, so widening a plugin's reach always needs a new manifest the user can see, never just a database row. Audit attribution is set by the broker, not the plugin. The plugin supplies only the details, so it cannot forge the actor. fix: stop plugin workers seeing plaintext credentials in error messages A failing ctx.ssh.connect handed the worker the raw error from deep in the SSH stack, which embeds the connect config. The worker received, in full: Authentication failed for root using password <SECRET> where <SECRET> was the host's real plaintext password. Any plugin with ssh.exec could read it by catching the error, defeating the whole point of the handle-based ssh API. Errors crossing the boundary are now sanitised. Only PluginFacingError and PluginPermissionError, both authored here, reach a plugin verbatim; anything else becomes a generic message and the real one goes to the log. This uses a marker class rather than matching on message text, because matching would start leaking again the moment an upstream error happened to contain a familiar phrase. Covered by credential-isolation.test.ts, which plants a sentinel in every secret-bearing field and asserts it appears in no message the worker ever receives. * feat: dispatch plugin HTTP routes and wire the runtime into startup ctx.http.route now registers a real router on the /plugin-api/:pluginId dispatcher, replacing its stub 404. A plugin never touches the Express req/res: it gets a plain summary and returns a plain object, so sockets and session cookies stay on this side of the boundary. Authorization and cookie headers are not forwarded. Plugins load last in the start-up sequence, after the server is fully wired, and are terminated before the database on shutdown so none can outlive it. A plugin that fails to load cannot stop the backend. fix: correct the nginx plugin-api location regex The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so never matched /plugin-api/<id>/<route>. Every other block in the file uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and 404 only behind Docker. * fix: update stale SFTP transfer test for single file destination paths The test still asserted transferToHost was called with the destination directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single file transfer to send the full destination path including the filename, so the correct expectation is "/srv/remote-1.txt". The code was right and the test was left behind. * feat: ship the ssh terminal as a first-party in-process plugin Adds a hardcoded process:transport-owner tier so the terminal can own its WebSocket server and ssh2 clients, which cannot cross the worker boundary. * feat: ship docker as a first-party in-process plugin Moves docker backend and frontend into plugins/docker/, alongside ssh-terminal. Adds a host editor tab seam, a dashboard card registry, and a build step to compile plugin backends into dist. * feat: ship host metrics as a first-party in-process plugin * fix: harden plugin auth and add per-request identity for ctx calls Require auth on /plugin-api, gate plugin enable/grant/revoke routes behind admin.plugins.manage, and thread the calling user through worker ctx.hosts/ctx.ssh calls instead of always using the install owner. Also adds the plugin permissions grant/revoke UI. * feat: ship the AI assistant as a first-party in-process plugin Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents. * fix: remove compiled plugin JS left behind in plugins/ after build copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in place, copied it to dist/plugins, but never deleted the .js it had just emitted next to the .ts sources. Those files shadowed the real source for vitest/tsx and had import paths rewritten for the dist layout, so tests failed with "Cannot find module" once a local build had run. Affects docker, host-metrics and ai, not just this one. * feat: add a permission-gated backend service registry for plugins * feat: add a permission-gated UI action registry and action slots for plugins Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal plugin offers and the ai plugin fills, gated on ai.services.use. * feat: add cross-plugin secret references gated by the provider's RBAC permission * feat: move proxmox discovery/import/sync into a first-party plugin * feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin Relocates guacamole backend/frontend source and tests into plugins/remote-desktop, following the ssh-terminal/docker pattern. * feat: wire remote-desktop plugin into core and rename i18n keys Repoints core consumers (dispatcher, collab, session-sharing, tab utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/ telnet, widens hostCapability to accept an array, and renames the guacamole i18n namespace to remoteDesktop. * feat: move fleets into a first-party plugin * feat: move automations into a first-party plugin * feat: remove legacy alert rules, superseded by automations Alert rules already auto-migrate into automations on boot and the old engine stands down after. Deletes the rule/firing tables, routes, UI, and AI tools; keeps notification channels since automations still use them. Also fixes automations' notify step, which still called the deleted repository. * feat: move network topology into a first-party plugin * feat: move tailscale device discovery and settings into a plugin Adds plugins/tailscale/ with a devices sidebar tab, admin settings section, and re-auth dialog. Host Metrics tailscale manager card and SSH re-auth banner parsing stay in core. * feat: move workspaces into a first-party plugin * feat: move web endpoint into a first-party plugin * feat: collapse plugin runtime to one tier behind a plugin SDK * feat: make each plugin an npm workspace built by a termix-plugin CLI * feat: give plugins their own tables, migrations and sync entities * feat: serve plugin http and websockets from one mount instead of ports * feat: namespace plugin permissions and keep them valid when a plugin is off * feat: give plugins their own settings and one settings screen * feat: load plugin frontends at runtime and remove plugin ids from the shell * fix: mock the ai status fetch in its activate test * feat: add auth extension points and one ssh connect pipeline * feat: rebuild workspaces as the reference plugin * fix: gate second factors after SSO/LDAP behind an admin setting * feat: convert network-topology to a full plugin (B1) * feat: move snippets backend and tables into a plugin (B2) * feat: point core frontend snippets pickers at the plugin (B2) * feat: add the snippets plugin (B2) * feat: wire ai, automations, fleets and ssh-terminal to snippets (B2) * feat: finish the tailscale plugin, including SSH auth (B3) * feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) * chore: update proxmox and host-metrics locale files for B5 * chore: regenerate drizzle migration journals for B5 * feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox * feat: move proxmox node stats and collectors into the proxmox plugin * feat: drop proxmox host columns, use plugin settings instead * feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) * feat: convert file manager into a plugin * feat: convert ssh tunnels into a plugin * feat: finish the web-endpoint plugin, add ctx.desktop (B8) * feat: move the terminal fully into the ssh-terminal plugin (B9) * feat: convert tmux monitoring into a plugin (B10) * feat: convert serial console into the serial plugin * feat: move session sharing and collab rooms into a plugin (B12) * feat: turn session recording into a plugin (B13) * feat: finish the remote-desktop plugin (B14) * feat: finish the host-metrics plugin, move host status into core (B16) * feat: finish the docker plugin (B15) * feat: finish the automations plugin (B17) * feat: finish the ai plugin (B18) * feat: finish the homepage plugin, move service links and canvas out of core (B19) * feat: convert wake-on-lan into a plugin (B20) * feat: convert secret-sources into a plugin (B20) * feat: move totp and passkeys into plugins (C1) * feat: move sso and ldap logins into plugins (C2) * feat: move opkssh and warpgate into plugins (C3) * feat: move step-ca into a plugin (C4) * feat: move vault ssh signing into a plugin (C5) * feat: move termix identity into a plugin (C6) * feat: move acme certificate automation into a plugin (C7) * fix: finish 2.9.0 plugin moves and get every check green * chore: lock the plugin boundary and remove 2.9.0 leftovers * fix: harden the plugin runtime after the 2.9.0 security review * feat: add plugin packing, signing, pinned trust and tmxplug bundling * test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught * fix: give bundled plugin backends a real require for CJS deps * feat: improve UI loading time and jitter * feat: show credential sharing inline in the sidebar like host sharing * feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning * feat: unify alerts into one plugin, channels, rules and inbox * feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling * fix: set blacksmith builder cache-key to stop broken gha cache fallback * fix: link plugin-sdk workspace before npm ci in docker deps stage * fix: link every plugin workspace before npm ci in docker deps stage * fix(host-metrics): exclude image and firmware filesystems (#1469) * fix(host-metrics): exclude image and firmware filesystems * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(hosts): normalize imported jump host references (#1470) * fix(hosts): normalize imported jump host references * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(ci): build plugin SDK and repair MySQL settings migration (#1471) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(file-manager): keep inline rename consistent (#1476) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(file-manager): keep inline rename consistent * fix(dashboard): reuse translated host status labels (#1475) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(dashboard): reuse translated host status labels * docs(api): clarify local and remote version fields (#1474) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * docs(api): clarify local and remote version fields * fix(file-manager): preserve selected backgrounds on hover (#1473) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(file-manager): preserve selection feedback on hover * test(ui): await dialog focus cleanup before teardown * fix(file-manager): restore Ctrl/Cmd+F file search (#1472) * fix(ci): build plugin SDK before dependent checks * fix(mysql): keep plugin settings scope indexes within limits * test(mysql): refresh reviewed plugin upgrade SQL * fix(file-manager): focus file search with Ctrl or Cmd F * fix: keep rounded-full circular in production builds * fix(file-manager): cancel uploads and stop remote writes (#1477) * fix(file-manager): cancel uploads and stop remote writes * test(css): exercise the configured PostCSS plugin chain * fix(file-manager): apply compact density to directory tree (#1478) * fix(file-manager): apply compact density to directory tree * test(css): exercise the configured PostCSS plugin chain * fix(file-manager): preserve font sizes in compact mode (#1480) * fix(file-manager): preserve font sizes in compact mode * test(css): exercise the configured PostCSS plugin chain * fix(file-manager): prioritize file names in narrow lists (#1483) * fix(file-manager): prioritize file names in narrow lists * test(css): exercise the configured PostCSS plugin chain * test(identity): preserve full Ed25519 public keys in fixtures * test(remote-desktop): always mutate ciphertext in tamper test * fix(remote-desktop): probe the saved host guacd endpoint (#1481) * fix(remote-desktop): probe the saved host guacd endpoint * test(css): exercise the configured PostCSS plugin chain * test(identity): preserve full Ed25519 public keys in fixtures * fix(homepage): check allowlisted private service reachability (#1479) * fix(homepage): check allowlisted private service reachability * test(css): exercise the configured PostCSS plugin chain * fix(remote-desktop): stop unsolicited clipboard read prompts (#1482) * fix(remote-desktop): read clipboard only on explicit paste * test(css): exercise the configured PostCSS plugin chain * test(identity): preserve full Ed25519 public keys in fixtures * test(remote-desktop): always mutate ciphertext in tamper test * chore: simplify plugin readmes, changelogs and manifest descriptions * fix: keep plugins loaded after signing in from the login screen * fix(file-manager): accept OS file drops over existing rows (#1484) * fix(file-manager): accept OS file drops over existing rows * test(file-manager): target the file label in drop tests * test(css): exercise the configured PostCSS plugin chain * test(identity): preserve full Ed25519 public keys in fixtures * test(remote-desktop): always mutate ciphertext in tamper test * fix(file-manager): pipeline SFTP download reads (#1485) * feat: add plugin sdk build to beta release * fix: make link-store secret check immune to random base64 matches * fix(ldap): preserve the directory-provided bind DN (#1486) * fix: compute beta changes across dev branches and move the beta tag * chore: untrack plugin sdk ARCHITECTURE.md * chore: remove references to plugin sdk ARCHITECTURE.md * feat: merge host editor terminal tabs into one owned by ssh-terminal * feat: add descriptions to plugin host enable toggles * fix(file-manager): show download progress in file viewers (#1487) * fix(electron): signal backend readiness over IPC (#1488) * fix(split-screen): allow layout creation without randomUUID (#1490) * fix(split-screen): allow layout creation without randomUUID * test(credentials): avoid flaky Ed25519 key generation * fix(sidebar): remeasure visible rows after density changes (#1489) * fix(sidebar): remeasure visible rows after density changes * test(credentials): avoid flaky Ed25519 key generation * feat(split-screen): redesign split screen with free-form panes and pane picker * feat(sidebar): add host click behavior setting, default to always opening a new tab * feat(snippets): redesign panel to match the host list * fix(session-recording): stop session logs panel from refetching in a loop * feat(snippets): bring back sharing, target hosts and drag reorder * feat(ssh-terminal): add toolbar position, start state, display and fade host settings * feat(ssh-terminal): add host setting to turn off password prompt auto-fill * fix(ai): stop streams getting cut off and keep tool steps across messages * feat: improve quick connect system using plugin api * fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport * feat: move terminal settings, look and ssh tools into ssh-terminal plugin * feat: move snippets, keybinding actions and macros out of core into plugins * feat: store rdp, vnc and telnet logins in a generic per-protocol table * feat: type the plugin host record, move plugin strings out of core and add lint checks * feat: replace core homepage widget registry with generic extension points * feat: plugins only see their own host settings in the browser * feat: harden plugin api for separate repos, signed updates and generic external login * fix: keep plugin css below core utilities and version plugin assets by their css * fix: load and save plugin host settings in the editor and tighten settings row spacing * feat: redesign user host defaults system * fix(remote-desktop): release held keys on focus loss (#1498) * fix(session-sharing): replace local presentation after takeover (#1500) * fix(file-manager): refresh disk usage with directory listing (#1503) * fix(ssh-terminal): preserve input order during local echo reconciliation (#1505) * feat(remote-desktop): expose display zoom controls for RDP (#1514) * fix(file-manager): restore text selection and copying in Markdown previews (#1518) * fix(sidebar): keep compact host addresses visible (#1524) * fix(hosts): preserve plugin settings when reopening the editor (#1499) * fix(ssh-terminal): close owner sockets when presentations unmount (#1501) * fix(session-sharing): synchronize shared SSH terminal dimensions (#1502) * fix(session-sharing): show participant presence in member terminals (#1504) * fix(ai): sync provider configuration and keys across linked devices (#1506) * feat(ssh-terminal): add a duplicate tab action (#1510) * feat(file-manager): allow disabling move-to-trash confirmation (#1511) * feat(file-manager): default to the remote login directory (#1512) * feat(homepage): synchronize canvas layouts across devices (#1515) * fix(shortcuts): prevent accidental double-Shift palette activation (#1519) * feat(tmux-monitor): add a unified multi-host view (#1520) * fix(electron): allow explicit quit with active connections (#1522) * feat: reconnect all disconnected terminal tabs (#1523) * fix(hosts): preserve recording and sharing settings in the dev editor (#1509) * fix(database): preserve RDP domain during startup backfill (#1497) * feat(sidebar): add keyboard type-ahead search for hosts (#1516) * feat(automations): add per-host maintenance mode and recurring schedules (#1521) * feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513) * fix(session-sharing): use the linked server for desktop meetings and links (#1507) * fix(tunnels): support TOTP for client tunnels and jump hosts (#1508) * fix: type errors and lint failures from merged plugin prs * feat(installer): make the Windows desktop shortcut optional (#1517) * fix: ship the plugin sdk and serialport in desktop builds * fix: prefer ipv4 when resolving ssh host names (#1365) * fix: readable terminal selection with the light theme (#1319) * fix: build desktop session share links on the linked server (#1350) * fix: show meeting guests to members and hide the roster from guests (#1353) * fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335) * fix: show disk usage for the mount being browsed (#1347) * fix: load the linux tray icon reliably and log tray failures (#1332) * fix: cover the gnome close path in the desktop quit test (#1332) * fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312) * feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: sync Crowdin translations for 2.9.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
co-authored by
Koi
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
parent
49fa488093
commit
3643e7af97
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { setAiOptIn } from "./ai-api";
|
||||
|
||||
/**
|
||||
* Asks once, plainly.
|
||||
*
|
||||
* "Leave it off" is preselected and applied on mount, so someone who clicks
|
||||
* straight through ends up without the assistant rather than with it. Both
|
||||
* options are described in the same flat register and neither is marked
|
||||
* recommended: this is a choice, not a pitch.
|
||||
*/
|
||||
export function AiAssistantStep() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
function apply(next: boolean) {
|
||||
setEnabled(next);
|
||||
// The local choice still shows if the write fails.
|
||||
setAiOptIn(next).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Skipping the step entirely still has to mean off, not unset.
|
||||
useEffect(() => {
|
||||
apply(false);
|
||||
}, []);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: false,
|
||||
title: t("onboarding.aiSkipTitle"),
|
||||
description: t("onboarding.aiSkipDesc"),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
title: t("onboarding.aiEnableTitle"),
|
||||
description: t("onboarding.aiEnableDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">{t("onboarding.aiIntro")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
onClick={() => apply(option.value)}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left ${
|
||||
enabled === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{option.title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{/*
|
||||
Always rendered and only made visible when selected, so picking an
|
||||
option never reflows the text beside it.
|
||||
*/}
|
||||
<Check
|
||||
size={14}
|
||||
className={`ml-auto mt-0.5 shrink-0 ${
|
||||
enabled === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.aiNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AiMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function AiMessage({ role, content }: AiMessageProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
setAiOptIn,
|
||||
type AiProposal,
|
||||
type AiProvider,
|
||||
} from "./ai-api";
|
||||
import { AiMessage } from "./AiMessage";
|
||||
import { AiProviderSettings } from "./AiProviderSettings";
|
||||
import { AiToolCall } from "./AiToolCall";
|
||||
import { ProposalCard } from "./ProposalCard";
|
||||
import {
|
||||
buildTimeline,
|
||||
finishedRunEntries,
|
||||
userEntry,
|
||||
type HistoryEntry,
|
||||
} from "./transcript";
|
||||
import { useAiStream, type ToolActivity } from "./use-ai-stream";
|
||||
import {
|
||||
activeMentionQuery,
|
||||
useMentions,
|
||||
type MentionItem,
|
||||
} from "./useMentions";
|
||||
import { mentionLabel } from "./labels";
|
||||
|
||||
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, send, stop } = useAiStream();
|
||||
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [providerId, setProviderId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const runCountRef = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [mention, setMention] = useState<{
|
||||
query: string;
|
||||
start: number;
|
||||
} | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const { search: searchMentions } = useMentions(Boolean(providerId));
|
||||
|
||||
const mentionMatches = mention ? searchMentions(mention.query) : [];
|
||||
|
||||
const updateMentionQuery = (value: string, caret: number) => {
|
||||
const next = activeMentionQuery(value, caret);
|
||||
setMention(next);
|
||||
setMentionIndex(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the "@partial" under the caret with the chosen name. The mention
|
||||
* is plain text: the assistant still has to call a read tool to look the
|
||||
* item up, so nothing is attached to the prompt behind the user's back.
|
||||
*/
|
||||
const insertMention = (item: MentionItem) => {
|
||||
if (!mention) return;
|
||||
const caret = textareaRef.current?.selectionStart ?? input.length;
|
||||
const before = input.slice(0, mention.start);
|
||||
const after = input.slice(caret);
|
||||
const inserted = `@${item.label} `;
|
||||
|
||||
setInput(`${before}${inserted}${after}`);
|
||||
setMention(null);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const position = before.length + inserted.length;
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(position, position);
|
||||
});
|
||||
};
|
||||
|
||||
const loadProviders = useCallback(async (selectId?: number) => {
|
||||
try {
|
||||
const list = await getAiProviders();
|
||||
setProviders(list);
|
||||
setProviderId((current) => {
|
||||
// A provider that was just added becomes the active one, so there is
|
||||
// no second step between creating it and being able to use it.
|
||||
if (selectId && list.some((entry) => entry.id === selectId)) {
|
||||
return selectId;
|
||||
}
|
||||
// Fall back to the first if the current selection has gone away.
|
||||
if (current && list.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return list[0]?.id ?? null;
|
||||
});
|
||||
return list;
|
||||
} catch {
|
||||
setProviders([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const status = await getAiStatus();
|
||||
if (cancelled) return;
|
||||
|
||||
// Opening the panel is consent to use it. Without this every write
|
||||
// 403s through the gate, which surfaces as a bare permission error
|
||||
// rather than anything the user can act on.
|
||||
if (status.globallyEnabled && !status.enabled) {
|
||||
await setAiOptIn(true);
|
||||
}
|
||||
if (!status.globallyEnabled) return;
|
||||
|
||||
const list = await loadProviders();
|
||||
// With nothing configured there is nothing to chat with, so go
|
||||
// straight to the form instead of showing a card that just vanishes.
|
||||
if (!cancelled && list.length === 0) setShowSettings(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
// The run's steps stay in the transcript, so the next message does not wipe
|
||||
// what the assistant already looked at.
|
||||
const keepRun = useCallback(
|
||||
(_conversationId: number | null, reply: string, tools: ToolActivity[]) => {
|
||||
const runId = runCountRef.current++;
|
||||
setHistory((prev) => [
|
||||
...prev,
|
||||
...finishedRunEntries(runId, tools, reply),
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function handleSend() {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setInput("");
|
||||
setHistory((prev) => [...prev, userEntry(message)]);
|
||||
|
||||
await send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: keepRun,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the outcome on the card and tells the assistant what happened.
|
||||
*
|
||||
* Without the follow-up the run just stops: the assistant proposed a
|
||||
* command, the user approved it, and the model never learns it produced
|
||||
* output, so it sits there as if the tool were still running.
|
||||
*/
|
||||
function handleProposalResolved(
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) {
|
||||
setResolvedProposals((prev) => ({
|
||||
...prev,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
|
||||
if (!providerId || state.streaming) return;
|
||||
|
||||
const proposal = state.proposals.find((entry) => entry.id === id);
|
||||
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
|
||||
|
||||
const followUp =
|
||||
status === "applied"
|
||||
? resultSummary
|
||||
? `I approved "${label}". Result:\n\n${resultSummary}`
|
||||
: `I approved "${label}". It completed.`
|
||||
: `I rejected "${label}". Do not retry it unless I ask.`;
|
||||
|
||||
setHistory((prev) => [...prev, userEntry(followUp)]);
|
||||
|
||||
void send({
|
||||
message: followUp,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: keepRun,
|
||||
});
|
||||
}
|
||||
|
||||
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
||||
const resolved = resolvedProposals[proposal.id];
|
||||
if (!resolved) return proposal;
|
||||
return {
|
||||
...proposal,
|
||||
status: resolved.status,
|
||||
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
||||
};
|
||||
});
|
||||
|
||||
const timeline = buildTimeline(history, state, proposals);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/*
|
||||
The panel also lives in the narrow sidebar, so the title and the
|
||||
provider picker get their own rows rather than competing for one line.
|
||||
*/}
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Sparkles size={16} className="shrink-0" />
|
||||
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/ai/overview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowSettings((value) => !value)}
|
||||
aria-label={t("ai.providerSettings")}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<Select
|
||||
value={providerId ? String(providerId) : undefined}
|
||||
onValueChange={(value) => setProviderId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
|
||||
<SelectValue placeholder={t("ai.selectProvider")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={String(provider.id)}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<div className="border-b border-border p-3">
|
||||
<AiProviderSettings
|
||||
providers={providers}
|
||||
onChanged={loadProviders}
|
||||
// Adding the first provider is the reason the form opened, so it
|
||||
// closes once that is done and the chat becomes usable.
|
||||
onAdded={() => setShowSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "message") {
|
||||
return (
|
||||
<AiMessage
|
||||
key={item.key}
|
||||
role={item.role}
|
||||
content={item.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "tool") {
|
||||
return (
|
||||
<AiToolCall
|
||||
key={item.key}
|
||||
tool={item.tool}
|
||||
streaming={item.live && state.streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
onResolved={handleProposalResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{state.error && (
|
||||
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative border-t border-border p-3">
|
||||
{mentionMatches.length > 0 && (
|
||||
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
|
||||
{mentionMatches.map((item, index) => (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
|
||||
index === mentionIndex ? "bg-muted" : ""
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
// mousedown, so the textarea does not lose the caret first.
|
||||
event.preventDefault();
|
||||
insertMention(item);
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
|
||||
{mentionLabel(item.kind)}
|
||||
</span>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.detail && (
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
|
||||
{item.detail}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className="min-h-[64px] resize-none rounded-none text-sm"
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
updateMentionQuery(
|
||||
event.target.value,
|
||||
event.target.selectionStart ?? 0,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (mentionMatches.length > 0) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setMentionIndex((i) => (i + 1) % mentionMatches.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setMentionIndex(
|
||||
(i) =>
|
||||
(i - 1 + mentionMatches.length) % mentionMatches.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
insertMention(mentionMatches[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMention(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.inputPlaceholder")}
|
||||
disabled={!providerId}
|
||||
/>
|
||||
{/*
|
||||
The hint gets its own line and wraps: sharing a row with the send
|
||||
button left it truncated to an ellipsis in the sidebar, where the
|
||||
panel is at its narrowest.
|
||||
*/}
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("ai.attachHint")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center justify-end gap-2">
|
||||
{state.streaming ? (
|
||||
<Button size="sm" variant="outline" onClick={stop}>
|
||||
<Square size={14} />
|
||||
{t("ai.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!input.trim() || !providerId}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
<Send size={14} />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
getAiProviderModels,
|
||||
probeAiModels,
|
||||
updateAiProvider,
|
||||
type AiProvider,
|
||||
type AiProviderType,
|
||||
} from "./ai-api";
|
||||
|
||||
const PROVIDER_TYPES: Array<{
|
||||
value: AiProviderType;
|
||||
labelKey: string;
|
||||
needsBaseUrl: boolean;
|
||||
needsApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
}> = [
|
||||
{
|
||||
value: "ollama",
|
||||
labelKey: "ai.providerOllama",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
labelKey: "ai.providerAnthropic",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai",
|
||||
labelKey: "ai.providerOpenai",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
labelKey: "ai.providerGemini",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai_compatible",
|
||||
labelKey: "ai.providerOpenaiCompatible",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
providers: AiProvider[];
|
||||
/** selectId names a provider that should become the active one. */
|
||||
onChanged: (selectId?: number) => void;
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
function AiProviderEditForm({
|
||||
provider,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
provider: AiProvider;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState(provider.label);
|
||||
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const detected = await getAiProviderModels(provider.id);
|
||||
setModels(detected);
|
||||
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||
} catch (error) {
|
||||
setModels([]);
|
||||
setCustomModel(true);
|
||||
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [provider.id, defaultModel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void detectModels();
|
||||
// The initial model value belongs to this provider. Subsequent edits must
|
||||
// not trigger a provider model-list request on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider.id]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAiProvider(provider.id, {
|
||||
label: label.trim(),
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
toast.success(t("ai.providerUpdated"));
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`ai-provider-label-${provider.id}`}>
|
||||
{t("ai.providerLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`ai-provider-label-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`ai-provider-model-${provider.id}`}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
{t("ai.defaultModel")}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
>
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-destructive">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiProviderSettings({
|
||||
providers,
|
||||
onChanged,
|
||||
onAdded,
|
||||
}: AiProviderSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||
const [label, setLabel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
|
||||
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(spec.defaultBaseUrl ?? "");
|
||||
setApiKey("");
|
||||
setModels([]);
|
||||
setDefaultModel("");
|
||||
setCustomModel(false);
|
||||
setDetectWarning(null);
|
||||
}, [providerType, spec.defaultBaseUrl]);
|
||||
|
||||
/**
|
||||
* Fetches the provider's own model list so nobody has to go and look model
|
||||
* names up. Falls back to a curated list when the endpoint is unreachable,
|
||||
* and a free-text field is always available for anything not listed.
|
||||
*/
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const result = await probeAiModels({
|
||||
providerType,
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
});
|
||||
setModels(result.models);
|
||||
if (result.source !== "live") {
|
||||
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
|
||||
}
|
||||
// Pick the first suggestion so the field is never left empty.
|
||||
setDefaultModel((current) => current || result.models[0] || "");
|
||||
} catch {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [providerType, baseUrl, apiKey, t]);
|
||||
|
||||
// Detect as soon as the provider has enough detail to be reachable.
|
||||
useEffect(() => {
|
||||
if (!adding) return;
|
||||
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
|
||||
if (!ready) return;
|
||||
const timer = setTimeout(() => void detectModels(), 400);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adding, providerType, baseUrl, apiKey]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await createAiProvider({
|
||||
providerType,
|
||||
label: label.trim(),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
setAdding(false);
|
||||
setLabel("");
|
||||
setApiKey("");
|
||||
setDefaultModel("");
|
||||
onChanged(created.id);
|
||||
onAdded?.();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteAiProvider(id);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) =>
|
||||
editingId === provider.id ? (
|
||||
<AiProviderEditForm
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onSaved={() => {
|
||||
setEditingId(null);
|
||||
onChanged(provider.id);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{provider.label}
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-hidden whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span className="shrink-0">{provider.providerType}</span>
|
||||
{provider.defaultModel && (
|
||||
<span className="truncate">{provider.defaultModel}</span>
|
||||
)}
|
||||
{provider.baseUrl && (
|
||||
<span className="truncate">{provider.baseUrl}</span>
|
||||
)}
|
||||
{provider.apiKeyPrefix && (
|
||||
<span className="shrink-0">{provider.apiKeyPrefix}…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setEditingId(provider.id);
|
||||
}}
|
||||
aria-label={t("ai.editProvider")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{!adding && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("ai.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerType")}</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={(value) =>
|
||||
setProviderType(value as AiProviderType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{t(entry.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerLabel")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t("ai.providerLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{spec.needsBaseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.baseUrl")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ai.privateEndpointHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(spec.needsApiKey || providerType === "openai_compatible") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.apiKey")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Anything the provider did not list is still reachable. */}
|
||||
<SelectItem value="__custom__">
|
||||
{t("ai.modelCustom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={saving}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => setAdding(false)}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
|
||||
import type { ToolActivity } from "./use-ai-stream";
|
||||
import { toolLabel } from "./labels";
|
||||
|
||||
/**
|
||||
* Every tool call is shown, so the user can always see what the assistant
|
||||
* looked at rather than having to trust the summary.
|
||||
*/
|
||||
export function AiToolCall({
|
||||
tool,
|
||||
streaming = false,
|
||||
}: {
|
||||
tool: ToolActivity;
|
||||
/** False once the turn has ended, whatever ended it. */
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A tool can only still be running while the turn is. An aborted or failed
|
||||
// stream leaves calls without results, and treating those as pending left a
|
||||
// spinner running forever after the conversation had finished.
|
||||
const pending = !("result" in tool) && streaming;
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted/50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{pending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={12} />
|
||||
)}
|
||||
<span className="truncate">{toolLabel(tool.name)}</span>
|
||||
{pending && (
|
||||
<span className="text-muted-foreground">{t("ai.running")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 border-t border-border px-2 py-2">
|
||||
{/* The header reads as an action, so the actual tool id lives here. */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{tool.name}
|
||||
</div>
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolArguments")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Gated on the result existing, not on !pending: an interrupted turn
|
||||
leaves a call with no result at all, which would otherwise print
|
||||
"undefined" under a Result heading.
|
||||
*/}
|
||||
{"result" in tool && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolResult")}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
|
||||
import { fieldLabel, toolLabel } from "./labels";
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function isDestructive(kind: string): boolean {
|
||||
return kind.includes("delete");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields shown in their own block rather than the generic row list, so the
|
||||
* card does not print the same command twice.
|
||||
*/
|
||||
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
|
||||
|
||||
/** Renders the payload as readable field rows rather than raw JSON. */
|
||||
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = [];
|
||||
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return;
|
||||
if (PROMOTED_FIELDS.has(key)) return;
|
||||
const text =
|
||||
typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
rows.push([key, text]);
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "changes" && value && typeof value === "object") {
|
||||
for (const [childKey, childValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
push(childKey, childValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
push(key, value);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const rows = fieldRows(payload);
|
||||
const destructive = isDestructive(proposal.kind);
|
||||
const resolved = proposal.status !== "pending";
|
||||
|
||||
const command = asText(payload.command);
|
||||
const explanation = asText(payload.explanation) ?? asText(payload.reason);
|
||||
|
||||
// The stored summary repeats the command for run_command proposals, so a
|
||||
// short action label is used instead of printing it twice.
|
||||
const title =
|
||||
command !== null
|
||||
? t("ai.runCommandTitle")
|
||||
: (proposal.summary ?? toolLabel(proposal.kind));
|
||||
|
||||
async function handleApply() {
|
||||
setBusy("apply");
|
||||
try {
|
||||
const result = await applyAiProposal(proposal.id);
|
||||
// The result goes into the card, not a toast: command output can be
|
||||
// hundreds of lines, which covered the screen.
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{destructive && (
|
||||
<TriangleAlert size={13} className="shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{resolved && (
|
||||
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{proposal.status === "applied"
|
||||
? t("ai.statusApplied")
|
||||
: t("ai.statusRejected")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A command is the payload, not a field of it, so it gets its own block
|
||||
rather than a label column. In the sidebar a fixed label gutter left
|
||||
barely any room for the command itself.
|
||||
*/}
|
||||
{command && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
{rows.map(([key, value]) => (
|
||||
// Stacked, not a two-column row: the value is what matters and it
|
||||
// needs the full width to stay readable.
|
||||
<div key={key} className="min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{fieldLabel(key)}
|
||||
</span>
|
||||
<div className="break-all font-mono text-[11px] leading-snug">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.resultSummary && (
|
||||
// Command output comes back here, so it keeps the monospace treatment.
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{proposal.resultSummary}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!resolved && (
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
destructive
|
||||
? ""
|
||||
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
}`}
|
||||
variant={destructive ? "destructive" : "outline"}
|
||||
disabled={busy !== null}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{busy === "apply" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { aiApp } from "./app-ref";
|
||||
|
||||
/** Fired when the AI status may have changed, so every surface re-reads it. */
|
||||
export const AI_STATUS_CHANGED_EVENT = "termix-ai:status-changed";
|
||||
|
||||
export function notifyAiStatusChanged(): void {
|
||||
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/** The server's own message when it sent one, so the user sees why. */
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
if (typeof data?.error === "string" && data.error) {
|
||||
return new Error(data.error);
|
||||
}
|
||||
if (error instanceof Error && error.message) return error;
|
||||
return new Error(`Failed to ${action}`);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Response bodies are typed per function below, at the edge.
|
||||
interface LooseClient {
|
||||
get(url: string): Promise<{ data: any }>;
|
||||
delete(url: string): Promise<{ data: any }>;
|
||||
post(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
put(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
patch(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const api = () => aiApp().api as unknown as LooseClient;
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export interface AiProvider {
|
||||
id: number;
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
/** The first few characters only; the key itself never leaves the server. */
|
||||
apiKeyPrefix: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: number;
|
||||
title: string | null;
|
||||
providerId: number | null;
|
||||
model: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiProposal {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
kind: string;
|
||||
summary: string | null;
|
||||
payload: string;
|
||||
status: "pending" | "applied" | "rejected" | "expired";
|
||||
resultSummary: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiStatus {
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function getAiStatus(): Promise<AiStatus> {
|
||||
try {
|
||||
return (await api().get("/status")).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "get AI status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||
try {
|
||||
return (await api().get("/providers")).data.providers;
|
||||
} catch (error) {
|
||||
throw apiError(error, "list AI providers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiProvider(input: {
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
}): Promise<AiProvider> {
|
||||
try {
|
||||
return (await api().post("/providers", input)).data.provider;
|
||||
} catch (error) {
|
||||
throw apiError(error, "create AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiProvider(
|
||||
id: number,
|
||||
input: Partial<{
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
): Promise<AiProvider> {
|
||||
try {
|
||||
return (await api().patch(`/providers/${id}`, input)).data.provider;
|
||||
} catch (error) {
|
||||
throw apiError(error, "update AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await api().delete(`/providers/${id}`);
|
||||
} catch (error) {
|
||||
throw apiError(error, "delete AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list for a provider that may not be saved yet, so the add form can
|
||||
* fill its picker before anything is persisted.
|
||||
*/
|
||||
export async function probeAiModels(input: {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerId?: number | null;
|
||||
}): Promise<{
|
||||
models: string[];
|
||||
source: "live" | "fallback" | "none";
|
||||
warning?: string;
|
||||
}> {
|
||||
try {
|
||||
return (await api().post("/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "detect models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||
try {
|
||||
return (await api().get(`/providers/${id}/models`)).data.models;
|
||||
} catch (error) {
|
||||
throw apiError(error, "list provider models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||
try {
|
||||
return (await api().get("/conversations")).data.conversations;
|
||||
} catch (error) {
|
||||
throw apiError(error, "list AI conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversation(id: number): Promise<{
|
||||
conversation: AiConversation;
|
||||
messages: AiMessage[];
|
||||
proposals: AiProposal[];
|
||||
}> {
|
||||
try {
|
||||
return (await api().get(`/conversations/${id}`)).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "load AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: number): Promise<void> {
|
||||
try {
|
||||
await api().delete(`/conversations/${id}`);
|
||||
} catch (error) {
|
||||
throw apiError(error, "delete AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAiProposal(
|
||||
id: number,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (await api().post(`/proposals/${id}/apply`)).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "apply AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectAiProposal(id: number): Promise<void> {
|
||||
try {
|
||||
await api().post(`/proposals/${id}/reject`);
|
||||
} catch (error) {
|
||||
throw apiError(error, "reject AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For the terminal-docked assistant: records that a run_command proposal was
|
||||
* typed into the user's open terminal instead of run over a pooled connection.
|
||||
*/
|
||||
export async function markAiProposalRunInTerminal(
|
||||
id: number,
|
||||
hostId: number,
|
||||
summary?: string,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (
|
||||
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
|
||||
hostId,
|
||||
summary,
|
||||
})
|
||||
).data;
|
||||
} catch (error) {
|
||||
throw apiError(error, "mark AI proposal run in terminal");
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns the assistant on or off for the current user. */
|
||||
export async function setAiOptIn(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await api().put("/opt-in", { enabled });
|
||||
} catch (error) {
|
||||
throw apiError(error, "update the AI setting");
|
||||
} finally {
|
||||
notifyAiStatusChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
let current: TermixApp | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setAiApp(app: TermixApp | null): void {
|
||||
current = app;
|
||||
}
|
||||
|
||||
export function aiApp(): TermixApp {
|
||||
if (!current) throw new Error("The AI plugin is not active");
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** A readable message from anything thrown, or the fallback. */
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Bot, Sparkles } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
TerminalSidePanelProps,
|
||||
TerminalSlotApi,
|
||||
} from "./terminal/terminal-slot-types";
|
||||
import { AiPanel } from "./AiPanel";
|
||||
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
||||
import { AiAssistantStep } from "./AiAssistantStep";
|
||||
import { ProvidersSetting } from "./settings/ProvidersSetting";
|
||||
import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api";
|
||||
import { setAiApp } from "./app-ref";
|
||||
|
||||
/** The rail id the assistant registers under. */
|
||||
const AI_RAIL_ID = "ai";
|
||||
const ASSISTANT_ACTION = "ai.openWithContext";
|
||||
const SIDE_PANEL_ID = "ai.assistant";
|
||||
|
||||
function AssistantPanel({ activeTabType }: PanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<AiPanel activeTab={activeTabType ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantTab() {
|
||||
return <AiPanel />;
|
||||
}
|
||||
|
||||
/** The assistant beside a terminal, in its "terminal.sidePanel" slot. */
|
||||
function TerminalSidePanel({
|
||||
host,
|
||||
hostId,
|
||||
hostLabel,
|
||||
panelProps,
|
||||
onClose,
|
||||
onRunInTerminal,
|
||||
}: TerminalSidePanelProps) {
|
||||
if (!hostId) return null;
|
||||
return (
|
||||
<TerminalAiPanel
|
||||
hostLabel={hostLabel}
|
||||
hostId={hostId}
|
||||
activeTab={`terminal:${host?.name || host?.ip || ""}`}
|
||||
initialContext={
|
||||
typeof panelProps.context === "string" ? panelProps.context : ""
|
||||
}
|
||||
onClose={onClose}
|
||||
onRunInTerminal={onRunInTerminal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setAiApp(app);
|
||||
app.onDispose(() => setAiApp(null));
|
||||
|
||||
let globallyEnabled = false;
|
||||
let userEnabled = false;
|
||||
const surface: (() => void)[] = [];
|
||||
|
||||
const clearSurface = () => {
|
||||
while (surface.length > 0) surface.pop()!();
|
||||
};
|
||||
|
||||
// What the assistant shows depends on the admin switch and the user's own
|
||||
// choice, so it is re-registered whenever either changes.
|
||||
const applyStatus = () => {
|
||||
clearSurface();
|
||||
|
||||
surface.push(
|
||||
app.registerRailItem({
|
||||
id: AI_RAIL_ID,
|
||||
icon: Sparkles,
|
||||
titleKey: "nav.ai",
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
after: "macros",
|
||||
order: 40,
|
||||
// Off for everyone: not even a Navigation toggle.
|
||||
hidden: !globallyEnabled,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!globallyEnabled) {
|
||||
app.tabs.closeTab(AI_RAIL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
surface.push(
|
||||
app.registerTab(AI_RAIL_ID, AssistantTab as ComponentType<TabProps>, {
|
||||
icon: Sparkles,
|
||||
titleKey: "nav.ai",
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
panelFrame: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// The per-host switch is this plugin's host setting.
|
||||
const offeredOnHost = (context: Record<string, unknown>) =>
|
||||
userEnabled &&
|
||||
(
|
||||
context.host as
|
||||
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
|
||||
| undefined
|
||||
)?.pluginSettings?.ai?.enableAiAssistant === true;
|
||||
|
||||
surface.push(
|
||||
app.registerSlotContribution("terminal.toolbar", {
|
||||
actionId: ASSISTANT_ACTION,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "button",
|
||||
when: offeredOnHost,
|
||||
}),
|
||||
);
|
||||
surface.push(
|
||||
app.registerSlotContribution("terminal.sidePanel", {
|
||||
actionId: SIDE_PANEL_ID,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "component",
|
||||
component: TerminalSidePanel as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
when: offeredOnHost,
|
||||
}),
|
||||
);
|
||||
surface.push(
|
||||
app.registerSlotContribution("onboarding.steps", {
|
||||
actionId: "ai.onboarding",
|
||||
titleKey: "onboarding.aiTitle",
|
||||
kind: "component",
|
||||
component: AiAssistantStep as unknown as ComponentType<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
getAiStatus()
|
||||
.then((status) => {
|
||||
globallyEnabled = status.globallyEnabled;
|
||||
userEnabled = status.globallyEnabled && status.enabled;
|
||||
})
|
||||
.catch(() => {
|
||||
globallyEnabled = false;
|
||||
userEnabled = false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) applyStatus();
|
||||
});
|
||||
};
|
||||
|
||||
let disposed = false;
|
||||
app.onDispose(() => {
|
||||
disposed = true;
|
||||
clearSurface();
|
||||
});
|
||||
|
||||
app.registerPanel(AI_RAIL_ID, AssistantPanel);
|
||||
|
||||
// Opens the docked assistant on the terminal it was clicked from, seeded
|
||||
// with what is on screen.
|
||||
app.registerAction(
|
||||
ASSISTANT_ACTION,
|
||||
(terminal: TerminalSlotApi) =>
|
||||
terminal?.openSidePanel(SIDE_PANEL_ID, {
|
||||
context: terminal.getBufferText(),
|
||||
}),
|
||||
{ permission: "services.use" },
|
||||
);
|
||||
|
||||
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||
|
||||
applyStatus();
|
||||
refresh();
|
||||
// The admin switch and the user's own choice are settings, saved from
|
||||
// the settings screen.
|
||||
app.onSettingsChanged(() => refresh());
|
||||
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
|
||||
for (const event of events) window.addEventListener(event, refresh);
|
||||
app.onDispose(() => {
|
||||
for (const event of events) window.removeEventListener(event, refresh);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Turns the internal identifiers the assistant works in into text a user can
|
||||
* read. Tool names, proposal kinds and payload keys all surface in the panel,
|
||||
* and raw ids like "propose_create_automation" or "thresholddurationseconds"
|
||||
* are not something to show anyone.
|
||||
*/
|
||||
|
||||
/** What each tool is doing, phrased as an action rather than a function name. */
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
list_hosts: "Reading hosts",
|
||||
get_host: "Reading host",
|
||||
list_fleets: "Reading fleets",
|
||||
list_snippets: "Reading snippets",
|
||||
list_automations: "Reading automations",
|
||||
get_automation: "Reading automation",
|
||||
list_workspaces: "Reading workspaces",
|
||||
list_notification_channels: "Reading notification channels",
|
||||
list_homepage_items: "Reading homepage items",
|
||||
get_command_history: "Reading command history",
|
||||
get_network_topology: "Reading network topology",
|
||||
propose_create_host: "Add host",
|
||||
propose_update_host: "Update host",
|
||||
propose_delete_host: "Delete host",
|
||||
propose_create_snippet: "Create snippet",
|
||||
propose_update_snippet: "Update snippet",
|
||||
propose_delete_snippet: "Delete snippet",
|
||||
propose_create_automation: "Create automation",
|
||||
propose_create_fleet: "Create fleet",
|
||||
propose_run_command: "Run command",
|
||||
};
|
||||
|
||||
/** Readable names for the payload keys the propose tools emit. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
hostId: "Host",
|
||||
hostIds: "Hosts",
|
||||
snippetId: "Snippet",
|
||||
automationId: "Automation",
|
||||
name: "Name",
|
||||
ip: "Address",
|
||||
port: "Port",
|
||||
username: "Username",
|
||||
folder: "Folder",
|
||||
tags: "Tags",
|
||||
content: "Command",
|
||||
description: "Description",
|
||||
definition: "Definition",
|
||||
enabled: "Enabled",
|
||||
triggerType: "Trigger",
|
||||
thresholdValue: "Threshold",
|
||||
thresholdDurationSeconds: "Duration (seconds)",
|
||||
cooldownMinutes: "Cooldown (minutes)",
|
||||
};
|
||||
|
||||
const MENTION_LABELS: Record<string, string> = {
|
||||
host: "Host",
|
||||
snippet: "Snippet",
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits snake_case and camelCase into words and capitalises the first.
|
||||
* Used whenever an identifier has no explicit label, so something added later
|
||||
* still reads as words rather than one run-on token.
|
||||
*/
|
||||
export function humanize(value: string): string {
|
||||
const spaced = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim();
|
||||
if (!spaced) return value;
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return TOOL_LABELS[name] ?? humanize(name);
|
||||
}
|
||||
|
||||
export function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? humanize(key);
|
||||
}
|
||||
|
||||
export function mentionLabel(kind: string): string {
|
||||
return MENTION_LABELS[kind] ?? humanize(kind);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { AiProviderSettings } from "../AiProviderSettings";
|
||||
import {
|
||||
AI_STATUS_CHANGED_EVENT,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
type AiProvider,
|
||||
} from "../ai-api";
|
||||
|
||||
/**
|
||||
* The "providers" custom field in the plugin's user settings: a list with
|
||||
* per-provider forms, which a schema field cannot express.
|
||||
*/
|
||||
export function ProvidersSetting() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<{
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
} | null>(null);
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
// Re-read when the "enabled" switch above this field is saved.
|
||||
const optedIn = useSettings("user").values.enabled;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await getAiStatus();
|
||||
setStatus(next);
|
||||
if (next.enabled) setProviders(await getAiProviders());
|
||||
} catch {
|
||||
setStatus({ globallyEnabled: false, enabled: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, optedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, load);
|
||||
return () => window.removeEventListener(AI_STATUS_CHANGED_EVENT, load);
|
||||
}, [load]);
|
||||
|
||||
if (status === null) return null;
|
||||
|
||||
if (!status.enabled) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.globallyEnabled
|
||||
? t("settings.providersNeedOptIn")
|
||||
: t("settings.assistantUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AiProviderSettings providers={providers} onChanged={() => void load()} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
|
||||
import { AiMessage } from "../AiMessage";
|
||||
import { AiToolCall } from "../AiToolCall";
|
||||
import {
|
||||
buildTimeline,
|
||||
finishedRunEntries,
|
||||
userEntry,
|
||||
type HistoryEntry,
|
||||
} from "../transcript";
|
||||
import { useAiStream } from "../use-ai-stream";
|
||||
import { TerminalProposalCard } from "./TerminalProposalCard";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
persistAiPanelPosition,
|
||||
readStoredAiPanelPosition,
|
||||
type ToolbarPosition,
|
||||
} from "./panel-geometry";
|
||||
|
||||
interface TerminalAiPanelProps {
|
||||
hostLabel: string;
|
||||
hostId: number;
|
||||
activeTab?: string | null;
|
||||
/** Terminal output to open with, from a contributed action. */
|
||||
initialContext?: string;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slimmed-down version of AiPanel docked to a terminal tab. Same backend
|
||||
* (/ai/chat/stream, propose_run_command, ProposalCard) as the main assistant,
|
||||
* so it respects the same admin/user gates and never runs a command without
|
||||
* an explicit approve.
|
||||
*/
|
||||
export function TerminalAiPanel({
|
||||
hostLabel,
|
||||
hostId,
|
||||
activeTab,
|
||||
initialContext,
|
||||
onClose,
|
||||
onRunInTerminal,
|
||||
}: TerminalAiPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, send, stop } = useAiStream();
|
||||
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [providerId, setProviderId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const conversationIdRef = useRef<number | null>(null);
|
||||
const runCountRef = useRef(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prefilled rather than sent: the user sees exactly what the terminal is
|
||||
// about to hand the model, and can trim it or add a question first.
|
||||
useEffect(() => {
|
||||
if (!initialContext) return;
|
||||
setInput((current) =>
|
||||
current
|
||||
? current
|
||||
: t("ai.terminalContextPrefill", { output: initialContext }),
|
||||
);
|
||||
}, [initialContext, t]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredAiPanelPosition,
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
positionRef.current,
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (next.x !== positionRef.current.x || next.y !== positionRef.current.y) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
}
|
||||
// Only re-clamp when the panel first mounts; drag handles the rest.
|
||||
}, []);
|
||||
|
||||
const handleDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const next = clampToolbarPosition(
|
||||
{ x: drag.basePosition.x + deltaX, y: drag.basePosition.y + deltaY },
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistAiPanelPosition(positionRef.current);
|
||||
dragRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiProviders()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setProviders(list);
|
||||
setProviderId((current) => current ?? list[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProviders([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
const handleResolved = useCallback(
|
||||
(id: number, status: "applied" | "rejected", resultSummary?: string) => {
|
||||
setResolvedProposals((current) => ({
|
||||
...current,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setHistory((current) => [...current, userEntry(message)]);
|
||||
setInput("");
|
||||
|
||||
void send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: conversationIdRef.current,
|
||||
activeTab: activeTab ?? `terminal:${hostLabel}`,
|
||||
onComplete: (conversationId, reply, tools) => {
|
||||
conversationIdRef.current = conversationId;
|
||||
const runId = runCountRef.current++;
|
||||
setHistory((current) => [
|
||||
...current,
|
||||
...finishedRunEntries(runId, tools, reply),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}, [activeTab, hostLabel, input, providerId, send, state.streaming]);
|
||||
|
||||
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
||||
const resolved = resolvedProposals[proposal.id];
|
||||
if (!resolved) return proposal;
|
||||
return {
|
||||
...proposal,
|
||||
status: resolved.status,
|
||||
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
||||
};
|
||||
});
|
||||
|
||||
const timeline = buildTimeline(history, state, proposals);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-2 top-12 z-[120] flex max-h-[calc(100%-4rem)] w-[min(420px,calc(100vw-1rem))] flex-col border border-border bg-background shadow-xl"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center justify-between gap-2 border-b border-border px-3 py-2 active:cursor-grabbing"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerEnd}
|
||||
onPointerCancel={handleDragPointerEnd}
|
||||
onLostPointerCapture={handleDragPointerEnd}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Bot className="size-4 shrink-0 text-accent-brand" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">
|
||||
{t("ai.assistant")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{hostLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={stop}
|
||||
disabled={!state.streaming}
|
||||
title={t("ai.stop")}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
|
||||
>
|
||||
{timeline.length === 0 && !loading && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("ai.terminalPanelEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div className="border border-border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("ai.noProvidersConfigured")}
|
||||
</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "message") {
|
||||
return (
|
||||
<AiMessage
|
||||
key={item.key}
|
||||
role={item.role}
|
||||
content={item.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "tool") {
|
||||
return (
|
||||
<AiToolCall
|
||||
key={item.key}
|
||||
tool={item.tool}
|
||||
streaming={item.live && state.streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TerminalProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
hostId={hostId}
|
||||
onRunInTerminal={onRunInTerminal}
|
||||
onResolved={handleResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.terminalPanelPlaceholder")}
|
||||
rows={3}
|
||||
className="min-h-20 resize-none border border-input bg-background px-2 py-1.5 text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={state.streaming || providers.length === 0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={state.streaming || !input.trim() || !providerId}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getErrorMessage } from "../errors";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
markAiProposalRunInTerminal,
|
||||
rejectAiProposal,
|
||||
type AiProposal,
|
||||
} from "../ai-api";
|
||||
import { ProposalCard } from "../ProposalCard";
|
||||
|
||||
interface TerminalProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
hostId: number;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run_command proposal in the terminal-docked assistant runs in the user's
|
||||
* already-open session instead of a hidden pooled connection, so approving it
|
||||
* here is a different action from the main AiPanel's apply button. Every
|
||||
* other proposal kind still goes through the normal apply flow.
|
||||
*/
|
||||
export function TerminalProposalCard({
|
||||
proposal,
|
||||
hostId,
|
||||
onRunInTerminal,
|
||||
onResolved,
|
||||
}: TerminalProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"run" | "reject" | null>(null);
|
||||
|
||||
if (
|
||||
proposal.kind !== "propose_run_command" ||
|
||||
proposal.status !== "pending"
|
||||
) {
|
||||
return <ProposalCard proposal={proposal} onResolved={onResolved} />;
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const command = typeof payload.command === "string" ? payload.command : "";
|
||||
const explanation =
|
||||
typeof payload.explanation === "string" ? payload.explanation : "";
|
||||
|
||||
async function handleRun() {
|
||||
if (!command) return;
|
||||
setBusy("run");
|
||||
try {
|
||||
onRunInTerminal(command);
|
||||
const result = await markAiProposalRunInTerminal(
|
||||
proposal.id,
|
||||
hostId,
|
||||
`$ ${command}`,
|
||||
);
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{t("ai.runCommandTitle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<TriangleAlert size={11} className="shrink-0" />
|
||||
{t("ai.runsInOpenTerminal")}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
variant="outline"
|
||||
disabled={busy !== null || !command}
|
||||
onClick={handleRun}
|
||||
>
|
||||
{busy === "run" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.runInTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Where the docked assistant panel sits over a terminal. Same clamping rules
|
||||
* as the terminal's own toolbar, kept here so the panel owns its geometry.
|
||||
*/
|
||||
|
||||
export interface ToolbarPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const AI_PANEL_POSITION_STORAGE_KEY = "termix-terminal-ai-panel-position";
|
||||
const TOOLBAR_MARGIN = 8;
|
||||
const RECOVERY_SIZE = 44;
|
||||
|
||||
function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
|
||||
if (!value || typeof value !== "object") return { x: 0, y: 0 };
|
||||
const candidate = value as Partial<ToolbarPosition>;
|
||||
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
|
||||
? { x: Number(candidate.x), y: Number(candidate.y) }
|
||||
: { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
export function readStoredAiPanelPosition(): ToolbarPosition {
|
||||
if (typeof window === "undefined") return { x: 0, y: 0 };
|
||||
try {
|
||||
return sanitizeToolbarPosition(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(AI_PANEL_POSITION_STORAGE_KEY) ?? "null",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAiPanelPosition(position: ToolbarPosition): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
AI_PANEL_POSITION_STORAGE_KEY,
|
||||
JSON.stringify(sanitizeToolbarPosition(position)),
|
||||
);
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function clampToolbarPosition(
|
||||
position: ToolbarPosition,
|
||||
toolbarRect: RectLike,
|
||||
hostRect: RectLike,
|
||||
renderedPosition: ToolbarPosition = position,
|
||||
): ToolbarPosition {
|
||||
if (
|
||||
!toolbarRect.width ||
|
||||
!toolbarRect.height ||
|
||||
!hostRect.width ||
|
||||
!hostRect.height
|
||||
)
|
||||
return sanitizeToolbarPosition(position);
|
||||
const left = hostRect.left + TOOLBAR_MARGIN;
|
||||
const right = hostRect.right - TOOLBAR_MARGIN;
|
||||
const top = hostRect.top + TOOLBAR_MARGIN;
|
||||
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
|
||||
const baseLeft = toolbarRect.left - renderedPosition.x;
|
||||
const baseRight = toolbarRect.right - renderedPosition.x;
|
||||
const baseTop = toolbarRect.top - renderedPosition.y;
|
||||
const baseBottom = toolbarRect.bottom - renderedPosition.y;
|
||||
const clamp = (min: number, max: number, value: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const xMin =
|
||||
toolbarRect.width <= right - left
|
||||
? left - baseLeft
|
||||
: left + RECOVERY_SIZE - baseRight;
|
||||
const xMax = right - baseRight;
|
||||
const yMin =
|
||||
toolbarRect.height <= bottom - top
|
||||
? top - baseTop
|
||||
: top + RECOVERY_SIZE - baseBottom;
|
||||
const yMax = bottom - baseBottom;
|
||||
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* What the ssh-terminal plugin hands its slots, as that plugin documents it.
|
||||
* Kept as a local copy: a plugin never imports another plugin's source.
|
||||
*/
|
||||
|
||||
interface TerminalHost {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** What a "terminal.toolbar" action is invoked with. */
|
||||
export interface TerminalSlotApi {
|
||||
host: TerminalHost | undefined;
|
||||
getBufferText: () => string;
|
||||
sessionId: () => string | null;
|
||||
openSidePanel: (panelId: string, props?: Record<string, unknown>) => void;
|
||||
runCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
/** What a "terminal.sidePanel" component is rendered with. */
|
||||
export interface TerminalSidePanelProps {
|
||||
host: TerminalHost | undefined;
|
||||
hostId: number | undefined;
|
||||
hostLabel: string;
|
||||
panelProps: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AiProposal } from "./ai-api";
|
||||
import type { StreamState, ToolActivity } from "./use-ai-stream";
|
||||
|
||||
/** A finished part of the conversation, kept after the stream resets. */
|
||||
export type HistoryEntry =
|
||||
| { kind: "message"; role: "user" | "assistant"; content: string }
|
||||
| { kind: "tool"; tool: ToolActivity };
|
||||
|
||||
/** One entry in the rendered conversation, in the order it happened. */
|
||||
export type TimelineItem =
|
||||
| {
|
||||
kind: "message";
|
||||
key: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
/** live is false for a past run's step, which can never still be running. */
|
||||
| { kind: "tool"; key: string; tool: ToolActivity; live: boolean }
|
||||
| { kind: "proposal"; key: string; proposal: AiProposal };
|
||||
|
||||
export function userEntry(content: string): HistoryEntry {
|
||||
return { kind: "message", role: "user", content };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a finished run leaves in the transcript: its tool steps, then its
|
||||
* reply. Tool ids are only unique within one run, so they get a run prefix.
|
||||
*/
|
||||
export function finishedRunEntries(
|
||||
runId: number,
|
||||
tools: ToolActivity[],
|
||||
reply: string,
|
||||
): HistoryEntry[] {
|
||||
const entries: HistoryEntry[] = tools.map((tool) => ({
|
||||
kind: "tool",
|
||||
tool: { ...tool, id: `run-${runId}-${tool.id}` },
|
||||
}));
|
||||
if (reply)
|
||||
entries.push({ kind: "message", role: "assistant", content: reply });
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Past runs, then the run in progress, then its proposals. */
|
||||
export function buildTimeline(
|
||||
history: HistoryEntry[],
|
||||
state: Pick<StreamState, "tools" | "assistantText">,
|
||||
proposals: AiProposal[],
|
||||
): TimelineItem[] {
|
||||
const timeline: TimelineItem[] = history.map((entry, index) =>
|
||||
entry.kind === "tool"
|
||||
? { kind: "tool", key: entry.tool.id, tool: entry.tool, live: false }
|
||||
: {
|
||||
kind: "message",
|
||||
key: `history-${index}`,
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
},
|
||||
);
|
||||
|
||||
for (const tool of state.tools) {
|
||||
timeline.push({ kind: "tool", key: tool.id, tool, live: true });
|
||||
}
|
||||
|
||||
if (state.assistantText) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: "streaming",
|
||||
role: "assistant",
|
||||
content: state.assistantText,
|
||||
});
|
||||
}
|
||||
|
||||
for (const proposal of proposals) {
|
||||
timeline.push({
|
||||
kind: "proposal",
|
||||
key: `proposal-${proposal.id}`,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { aiApp } from "./app-ref";
|
||||
import type { AiProposal } from "./ai-api";
|
||||
|
||||
/**
|
||||
* Drives the chat stream.
|
||||
*
|
||||
* EventSource cannot POST, and the request carries a message body, so this
|
||||
* reads the SSE frames off a fetch response by hand.
|
||||
*/
|
||||
|
||||
export interface ToolActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface StreamState {
|
||||
streaming: boolean;
|
||||
assistantText: string;
|
||||
tools: ToolActivity[];
|
||||
proposals: AiProposal[];
|
||||
error: string | null;
|
||||
conversationId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL: StreamState = {
|
||||
streaming: false,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export function useAiStream() {
|
||||
const [state, setState] = useState<StreamState>(INITIAL);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
// The run's own cleanup clears abortRef, after it has handed its steps over.
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (input: {
|
||||
message: string;
|
||||
providerId: number;
|
||||
model?: string;
|
||||
conversationId?: number | null;
|
||||
activeTab?: string | null;
|
||||
/**
|
||||
* Called however the run ends, with its steps, which are then cleared
|
||||
* from the live state. Keeping them is up to the caller.
|
||||
*/
|
||||
onComplete?: (
|
||||
conversationId: number | null,
|
||||
reply: string,
|
||||
tools: ToolActivity[],
|
||||
) => void;
|
||||
}) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({
|
||||
streaming: true,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
});
|
||||
|
||||
let conversationId = input.conversationId ?? null;
|
||||
let replyText = "";
|
||||
let toolSequence = 0;
|
||||
const runTools: ToolActivity[] = [];
|
||||
|
||||
const finish = (error?: string | null) => {
|
||||
// A newer send took over; this run's state is already gone.
|
||||
if (abortRef.current !== controller) return;
|
||||
input.onComplete?.(conversationId, replyText, runTools);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
...(error ? { error } : {}),
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await aiApp().fetch("chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
conversationId: input.conversationId ?? undefined,
|
||||
activeTab: input.activeTab ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "The assistant could not be reached";
|
||||
try {
|
||||
message = (await response.json()).error ?? message;
|
||||
} catch {
|
||||
// Keep the generic message.
|
||||
}
|
||||
setState((prev) => ({ ...prev, streaming: false, error: message }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: "The assistant returned an empty response",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "conversation") {
|
||||
conversationId = event.conversationId;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationId: event.conversationId,
|
||||
}));
|
||||
} else if (event.type === "token") {
|
||||
replyText += event.text;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
assistantText: prev.assistantText + event.text,
|
||||
}));
|
||||
} else if (event.type === "tool_call") {
|
||||
// The id is assigned here rather than inside the updater: React
|
||||
// may run an updater more than once, and deriving the id from
|
||||
// prev.tools.length appended a duplicate entry that no result
|
||||
// could ever match, leaving it stuck on "running".
|
||||
const activityId = `tool-${toolSequence++}`;
|
||||
runTools.push({
|
||||
id: activityId,
|
||||
name: event.name,
|
||||
arguments: event.arguments ?? {},
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tools: [
|
||||
...prev.tools,
|
||||
{
|
||||
id: activityId,
|
||||
name: event.name,
|
||||
arguments: event.arguments ?? {},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else if (event.type === "tool_result") {
|
||||
for (let i = runTools.length - 1; i >= 0; i -= 1) {
|
||||
if (
|
||||
runTools[i].name === event.name &&
|
||||
!("result" in runTools[i])
|
||||
) {
|
||||
runTools[i] = { ...runTools[i], result: event.result };
|
||||
break;
|
||||
}
|
||||
}
|
||||
setState((prev) => {
|
||||
const tools = [...prev.tools];
|
||||
// Attach to the most recent call of this tool awaiting a result.
|
||||
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
||||
if (tools[i].name === event.name && !("result" in tools[i])) {
|
||||
tools[i] = { ...tools[i], result: event.result };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...prev, tools };
|
||||
});
|
||||
} else if (event.type === "proposal") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
proposals: [...prev.proposals, event.proposal],
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setState((prev) => ({ ...prev, error: event.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
} catch (error) {
|
||||
finish(
|
||||
controller.signal.aborted
|
||||
? null
|
||||
: getErrorMessage(error, "The assistant stopped unexpectedly"),
|
||||
);
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, send, stop, reset, setState };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
*
|
||||
* A mention is only ever a name the assistant reads in the message text; it
|
||||
* does not grant access to anything. The assistant still has to call a read
|
||||
* tool to see the referenced item, so every access stays visible in the
|
||||
* transcript rather than being silently attached to the prompt.
|
||||
*/
|
||||
|
||||
export type MentionKind = "host" | "snippet" | "automation";
|
||||
|
||||
export interface MentionItem {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Extracts the "@partial" the caret currently sits in, if any. */
|
||||
export function activeMentionQuery(
|
||||
value: string,
|
||||
caret: number,
|
||||
): { query: string; start: number } | null {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return null;
|
||||
|
||||
// Only trigger at a word boundary, so an email address does not open it.
|
||||
if (at > 0 && !/\s/.test(before[at - 1])) return null;
|
||||
|
||||
const query = before.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
|
||||
return { query, start: at };
|
||||
}
|
||||
|
||||
/**
|
||||
* Another plugin's list through its action, or nothing when it is not
|
||||
* running, so the assistant works without it and never imports its code.
|
||||
*/
|
||||
async function listFromAction(
|
||||
actionId: string,
|
||||
): Promise<{ id: number; name: string }[]> {
|
||||
const result = await invokeAction(actionId);
|
||||
return Array.isArray(result)
|
||||
? (result as { id: number; name: string }[])
|
||||
: [];
|
||||
}
|
||||
|
||||
export function useMentions(enabled: boolean) {
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { hosts } = useHosts();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [snippets, automations] = await Promise.allSettled([
|
||||
listFromAction("snippets.list"),
|
||||
listFromAction("automations.list"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const collected: MentionItem[] = hosts.map((host) => ({
|
||||
kind: "host" as const,
|
||||
id: Number(host.id),
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
}));
|
||||
|
||||
if (snippets.status === "fulfilled") {
|
||||
for (const snippet of snippets.value) {
|
||||
collected.push({
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
label: snippet.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (automations.status === "fulfilled") {
|
||||
for (const automation of automations.value) {
|
||||
collected.push({
|
||||
kind: "automation",
|
||||
id: automation.id,
|
||||
label: automation.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(collected);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, hosts]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): MentionItem[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = needle
|
||||
? items.filter((item) => item.label.toLowerCase().includes(needle))
|
||||
: items;
|
||||
return matches.slice(0, 8);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ items, search }), [items, search]);
|
||||
}
|
||||
Reference in New Issue
Block a user