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:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Adds an AI assistant that can read your hosts, snippets, fleets, alerts and automations and suggest changes for you to approve. Works with Ollama, Anthropic, OpenAI, Google Gemini and any OpenAI compatible provider.
+50
View File
@@ -0,0 +1,50 @@
# AI Assistant
An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and suggest changes for you to approve.
## Features
- Chat with an assistant that can read your hosts, snippets, fleets, alerts, automations, workspaces, network graph and homepage.
- The assistant suggests changes, and nothing is applied until you approve it.
- Can run read-only diagnostic commands on a host if you allow it.
- Open the assistant from the terminal toolbar with the current session as context.
- Works with Ollama, Anthropic, OpenAI, Google Gemini and any OpenAI compatible provider.
## Settings
### Admin
- AI assistant: let users turn the assistant on. While this is off it is hidden for everyone.
- Allowed private AI hosts: hosts on your private network a provider may point at, such as a self-hosted Ollama.
### User
- Enable the AI assistant: turn the assistant on for yourself.
- Allow read-only diagnostic commands: let the assistant run safe commands on your hosts.
- Providers: the AI providers, models and API keys you use.
### Host
- Enable AI Assistant: allow the assistant to work with this host.
## Permissions
- `ai.use`: Open the assistant and ask it things. Admins and users have it by default.
- `ai.manage_providers`: Add, edit and remove AI providers and their API keys. Admins and users have it by default.
- `ai.apply_proposals`: Let the assistant carry out the changes it suggests. Admins and users have it by default.
- `ai.services.use`: Let other features call the assistant for you. Admins and users have it by default.
- `ai.secrets.share`: Let other features use the stored AI API key without seeing it. Admins and users have it by default.
## Services
Uses from other plugins:
- `workspaces.saved`, `network-topology.graph`, `snippets.access`, `fleets.access`, `terminal.history`, `automations.access` and `homepage.items` to read your data. Each one is optional.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Gebruik die assistent",
"description": "Maak die assistent oop en vra dit dinge."
},
"manage_providers": {
"title": "Bestuur verskaffers",
"description": "Voeg die KI-verskaffers en hul API-sleutels by, wysig en verwyder hulle."
},
"apply_proposals": {
"title": "Pas voorstelle toe",
"description": "Laat die assistent die veranderinge wat dit voorstel, uitvoer."
},
"services.use": {
"title": "Gebruik KI van ander funksies",
"description": "Ander funksies kan die assistent namens jou skakel."
},
"secrets.share": {
"title": "Deel die API-sleutel",
"description": "Ander funksies mag die gestoorde KI API-sleutel gebruik sonder om dit te sien."
}
},
"settings": {
"assistantUnavailable": "Die KI-assistent is afgeskakel op hierdie bediener.",
"providersNeedOptIn": "Skakel die KI-assistent hierbo aan om verskaffers by te voeg.",
"admin": {
"globallyEnabled": {
"label": "KI-assistent",
"description": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer."
},
"privateEndpoints": {
"label": "Toegelate private KI-gashere",
"description": "Gashere op jou privaat netwerk waarna gebruikers 'n verskaffer kan verwys, soos 'n self-gehoste Ollama. Plaas een gasheer op elke lyn."
}
},
"host": {
"enableAiAssistant": {
"label": "Aktiveer KI-assistent",
"description": "Laat die KI-assistent toe om in hierdie gasheer se terminaal te help. Standaard afgeskakel, en KI moet steeds vir jou rekening geaktiveer word."
}
}
},
"ai": {
"title": "Assistent",
"send": "Stuur",
"stop": "Stop",
"save": "Stoor",
"cancel": "Kanselleer",
"running": "hardloop",
"approve": "Goedkeur",
"reject": "Verwerp",
"inputPlaceholder": "Vra oor jou bedieners, of beskryf 'n verandering om voor te stel",
"selectProvider": "Kies 'n verskaffer",
"providerSettings": "Verskafferinstellings",
"addProvider": "Voeg verskaffer by",
"editProvider": "Wysig verskaffer",
"removeProvider": "Verwyder verskaffer",
"providerType": "Verskaffer",
"providerLabel": "Naam",
"providerLabelPlaceholder": "Byvoorbeeld: Plaaslike Ollama",
"baseUrl": "Bedieneradres",
"apiKey": "API-sleutel",
"defaultModel": "Standaardmodel",
"defaultModelPlaceholder": "Byvoorbeeld: llama3.1 of claude-opus-5",
"labelRequired": "Gee hierdie verskaffer 'n naam",
"providerSaveFailed": "Kon nie die verskaffer stoor nie",
"providerUpdated": "Verskaffer opgedateer",
"providerDeleteFailed": "Kon nie die verskaffer verwyder nie",
"privateEndpointHint": "Adresse op jou private netwerk benodig eers 'n administrateur om die gasheer toe te laat.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropiese",
"providerOpenai": "OpenAI",
"providerGemini": "Google Tweeling",
"providerOpenaiCompatible": "OpenAI-versoenbaar",
"toolArguments": "Invoer",
"toolResult": "Resultaat",
"statusApplied": "Toegepas",
"statusRejected": "Verwerp",
"proposalApplied": "Verandering toegepas",
"proposalApplyFailed": "Kon nie die verandering toepas nie",
"proposalRejectFailed": "Kon nie die verandering verwerp nie",
"enableTitle": "Aktiveer die KI-assistent",
"enableDescription": "Laat die assistent jou opstelling lees en veranderinge voorstel wat jy kan goedkeur.",
"readOnlyCommands": "Laat leesalleen-diagnostiese bevele toe",
"readOnlyCommandsDescription": "Die assistent kan 'n kort lys veilige opdragte soos df en uptime uitvoer sonder om elke keer te vra. Enigiets wat 'n bediener verander, word altyd eerste voorgestel.",
"modelDetectFailed": "Kon nie die verskaffer bereik nie, so dit is 'n algemene lys. Jy kan enige modelnaam tik.",
"modelCustom": "Voer 'n modelnaam in",
"modelRefresh": "Verfris lys",
"modelPlaceholder": "Kies of tik 'n model",
"attachHint": "Tik @ om na 'n gasheer, brokkie of outomatisering te verwys. Veranderinge word voorgestel vir jou om goed te keur.",
"runCommandTitle": "Voer opdrag uit",
"assistant": "KI-assistent",
"terminalPanelEmpty": "Vra die assistent oor hierdie gasheer. Enigiets wat van status verander, benodig eers jou goedkeuring.",
"terminalPanelPlaceholder": "Vra oor hierdie gasheer...",
"noProvidersConfigured": "Geen KI-verskaffer is nog gekonfigureer nie. Voeg een by in die KI-assistent-oortjie.",
"runsInOpenTerminal": "Loop in jou oop terminaalsessie.",
"runInTerminal": "Hardloop",
"terminalContextPrefill": "Hier is my terminaaluitvoer:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Laat ander funksies jou KI-verskaffer API-sleutel vir hul eie versoeke gebruik. Die sleutel word nooit gekopieer nie, en jy kan dit te eniger tyd herroep."
}
},
"nav": {
"ai": "KI"
},
"onboarding": {
"aiTitle": "KI-assistent",
"aiIntro": "Termix kan 'n opsionele KI-assistent insluit. Jy kan dit later in jou instellings verander.",
"aiSkipTitle": "Los dit af",
"aiSkipDesc": "Termix werk soos dit nou doen. Die assistent bly versteek.",
"aiEnableTitle": "Skakel dit aan",
"aiEnableDesc": "Voeg 'n assistent by wat jou opstelling lees en veranderinge voorstel wat jy goedkeur. Benodig 'n verskaffer wat jy verskaf, soos 'n plaaslike Ollama.",
"aiNote": "Niks word êrens heen gestuur totdat jy 'n verskaffer byvoeg en 'n boodskap stuur nie."
},
"hosts": {
"docsLink": "Bekyk dokumente"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "استخدم المساعد",
"description": "افتح المساعد واسأله ما تريد."
},
"manage_providers": {
"title": "إدارة الموفر",
"description": "أضف، عدّل، واحذف مزودي خدمات الذكاء الاصطناعي ومفاتيح واجهة برمجة التطبيقات الخاصة بهم."
},
"apply_proposals": {
"title": "تقديم المقترحات",
"description": "دع المساعد يقوم بتنفيذ التغييرات التي يقترحها."
},
"services.use": {
"title": "استخدم الذكاء الاصطناعي من ميزات أخرى",
"description": "قد تقوم ميزات أخرى بالاتصال بالمساعد نيابةً عنك."
},
"secrets.share": {
"title": "شارك مفتاح API",
"description": "قد تستخدم ميزات أخرى مفتاح واجهة برمجة تطبيقات الذكاء الاصطناعي المخزن دون رؤيته."
}
},
"settings": {
"assistantUnavailable": "تم إيقاف تشغيل مساعد الذكاء الاصطناعي على هذا الخادم.",
"providersNeedOptIn": "قم بتشغيل مساعد الذكاء الاصطناعي أعلاه لإضافة مزودي الخدمة.",
"admin": {
"globallyEnabled": {
"label": "مساعد الذكاء الاصطناعي",
"description": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع."
},
"privateEndpoints": {
"label": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي",
"description": "المضيفون على شبكتك الخاصة الذين يمكن للمستخدمين توجيه مزود الخدمة إليهم، مثل Ollama المُستضاف ذاتيًا. ضع مضيفًا واحدًا على كل سطر."
}
},
"host": {
"enableAiAssistant": {
"label": "تفعيل مساعد الذكاء الاصطناعي",
"description": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك."
}
}
},
"ai": {
"title": "مساعد",
"send": "يرسل",
"stop": "التوقف",
"save": "حفظ",
"cancel": "إلغاء",
"running": "قيد التشغيل",
"approve": "يعتمد",
"reject": "يرفض",
"inputPlaceholder": "استفسر عن خوادمك، أو صف تغييرًا ترغب في اقتراحه.",
"selectProvider": "اختر مزود الخدمة",
"providerSettings": "إعدادات المزوّد",
"addProvider": "إضافة موفر",
"editProvider": "موفر التحرير",
"removeProvider": "إزالة الموفر",
"providerType": "مزود",
"providerLabel": "الاسم",
"providerLabelPlaceholder": "على سبيل المثال: أولاما المحلي",
"baseUrl": "عنوان الخادم",
"apiKey": "مفتاح API",
"defaultModel": "النموذج الافتراضي",
"defaultModelPlaceholder": "على سبيل المثال: llama3.1 أو claude-opus-5",
"labelRequired": "أعطِ هذا المزوّد اسمًا",
"providerSaveFailed": "تعذر حفظ بيانات المزوّد",
"providerUpdated": "تم تحديث المزوّد",
"providerDeleteFailed": "تعذر إزالة الموفر",
"privateEndpointHint": "تحتاج العناوين الموجودة على شبكتك الخاصة إلى موافقة المسؤول أولاً للسماح للمضيف بالوصول إليها.",
"providerOllama": "أولاما",
"providerAnthropic": "الأنثروبيك",
"providerOpenai": "أوبن إيه آي",
"providerGemini": "جوجل جيميني",
"providerOpenaiCompatible": "متوافق مع OpenAI",
"toolArguments": "إدخال",
"toolResult": "نتيجة",
"statusApplied": "مُطبَّق",
"statusRejected": "مرفوض",
"proposalApplied": "تم تطبيق التغيير",
"proposalApplyFailed": "تعذر تطبيق التغيير",
"proposalRejectFailed": "لم يكن بالإمكان رفض التغيير",
"enableTitle": "قم بتفعيل مساعد الذكاء الاصطناعي",
"enableDescription": "دع المساعد يقرأ إعداداتك ويقترح تغييرات لتوافق عليها.",
"readOnlyCommands": "السماح بأوامر التشخيص للقراءة فقط",
"readOnlyCommandsDescription": "قد يقوم المساعد بتشغيل قائمة مختصرة من الأوامر الآمنة مثل df و uptime دون الحاجة إلى طلب ذلك في كل مرة. ويتم اقتراح أي أمر يُغيّر حالة الخادم أولاً.",
"modelDetectFailed": "تعذر الوصول إلى المورّد، لذا فهذه قائمة عامة. يمكنك كتابة أي اسم طراز.",
"modelCustom": "أدخل اسم النموذج",
"modelRefresh": "تحديث القائمة",
"modelPlaceholder": "اختر أو اكتب طرازًا",
"attachHint": "اكتب @ للإشارة إلى مضيف أو مقتطف أو عملية آلية. سيتم اقتراح تغييرات عليك الموافقة عليها.",
"runCommandTitle": "تشغيل الأمر",
"assistant": "مساعد الذكاء الاصطناعي",
"terminalPanelEmpty": "استفسر من المساعد عن هذا المضيف. أي تغيير في حالته يتطلب موافقتك أولاً.",
"terminalPanelPlaceholder": "اسأل عن هذا المضيف...",
"noProvidersConfigured": "لم يتم إعداد أي موفر للذكاء الاصطناعي حتى الآن. أضف واحداً في علامة تبويب مساعد الذكاء الاصطناعي.",
"runsInOpenTerminal": "يتم تشغيله في جلسة الطرفية المفتوحة لديك.",
"runInTerminal": "تشغيل",
"terminalContextPrefill": "هذا هو ناتج الطرفية الخاص بي:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "اسمح للميزات الأخرى باستخدام مفتاح واجهة برمجة التطبيقات (API) الخاص بمزود الذكاء الاصطناعي لطلباتها الخاصة. لا يتم نسخ المفتاح مطلقًا، ويمكنك إلغاء هذا في أي وقت."
}
},
"nav": {
"ai": "الذكاء الاصطناعي"
},
"onboarding": {
"aiTitle": "مساعد الذكاء الاصطناعي",
"aiIntro": "يمكن أن يتضمن تطبيق Termix مساعدًا ذكيًا اختياريًا. يمكنك تغيير هذا الخيار لاحقًا من الإعدادات.",
"aiSkipTitle": "اتركه مغلقًا",
"aiSkipDesc": "يعمل برنامج Termix كما هو الآن. ويبقى المساعد مخفياً.",
"aiEnableTitle": "قم بتشغيله",
"aiEnableDesc": "يُضيف هذا البرنامج مساعدًا يقرأ إعداداتك ويقترح تغييرات توافق عليها. يتطلب ذلك مزود خدمة تُحدده بنفسك، مثل مزود خدمة Ollama محلي.",
"aiNote": "لن يتم إرسال أي شيء إلى أي مكان حتى تقوم بإضافة مزود خدمة وإرسال رسالة."
},
"hosts": {
"docsLink": "عرض المستندات"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Използвайте асистента",
"description": "Отворете асистента и го попитайте нещо."
},
"manage_providers": {
"title": "Управление на доставчици",
"description": "Добавяне, редактиране и премахване на доставчици на изкуствен интелект и техните API ключове."
},
"apply_proposals": {
"title": "Прилагане на предложения",
"description": "Нека асистентът извърши предложените от него промени."
},
"services.use": {
"title": "Използвайте изкуствен интелект от други функции",
"description": "Други функции може да извикат асистента от ваше име."
},
"secrets.share": {
"title": "Споделете API ключа",
"description": "Други функции могат да използват съхранения AI API ключ, без да го виждат."
}
},
"settings": {
"assistantUnavailable": "Асистентът с изкуствен интелект е изключен на този сървър.",
"providersNeedOptIn": "Включете AI асистента по-горе, за да добавите доставчици.",
"admin": {
"globallyEnabled": {
"label": "Асистент с изкуствен интелект",
"description": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички."
},
"privateEndpoints": {
"label": "Разрешени частни хостове с изкуствен интелект",
"description": "Хостове във вашата частна мрежа, към които потребителите могат да насочат доставчик, като например самостоятелно хостван Ollama. Поставете по един хост на всеки ред."
}
},
"host": {
"enableAiAssistant": {
"label": "Активиране на AI асистент",
"description": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт."
}
}
},
"ai": {
"title": "Асистент",
"send": "Изпрати",
"stop": "Спри",
"save": "Запазване",
"cancel": "Отказ",
"running": "бягане",
"approve": "Одобряване",
"reject": "Отхвърляне",
"inputPlaceholder": "Попитайте за вашите сървъри или опишете промяна, която да предложите",
"selectProvider": "Изберете доставчик",
"providerSettings": "Настройки на доставчика",
"addProvider": "Добавяне на доставчик",
"editProvider": "Редактиране на доставчика",
"removeProvider": "Премахване на доставчика",
"providerType": "Доставчик",
"providerLabel": "Име",
"providerLabelPlaceholder": "Например: Местна Олама",
"baseUrl": "Адрес на сървъра",
"apiKey": "API ключ",
"defaultModel": "Модел по подразбиране",
"defaultModelPlaceholder": "Например: llama3.1 или claude-opus-5",
"labelRequired": "Дайте име на този доставчик",
"providerSaveFailed": "Доставчикът не можа да бъде запазен",
"providerUpdated": "Доставчикът е актуализиран",
"providerDeleteFailed": "Доставчикът не можа да бъде премахнат",
"privateEndpointHint": "Адресите във вашата частна мрежа се нуждаят първо от администратор, който да разреши хоста.",
"providerOllama": "Олама",
"providerAnthropic": "Антропен",
"providerOpenai": "OpenAI",
"providerGemini": "Google Джемини",
"providerOpenaiCompatible": "Съвместим с OpenAI",
"toolArguments": "Вход",
"toolResult": "Резултат",
"statusApplied": "Приложено",
"statusRejected": "Отхвърлено",
"proposalApplied": "Промяната е приложена",
"proposalApplyFailed": "Промяната не можа да се приложи",
"proposalRejectFailed": "Промяната не можа да бъде отхвърлена",
"enableTitle": "Активирайте AI асистента",
"enableDescription": "Нека асистентът прочете настройката ви и предложи промени, които да одобрите.",
"readOnlyCommands": "Разрешаване на диагностични команди само за четене",
"readOnlyCommandsDescription": "Асистентът може да изпълни кратък списък с безопасни команди като df и uptime, без да пита всеки път. Всичко, което променя сървър, винаги се предлага първо.",
"modelDetectFailed": "Не можахме да се свържем с доставчика, така че това е общ списък. Можете да въведете име на произволен модел.",
"modelCustom": "Въведете име на модел",
"modelRefresh": "Обновяване на списъка",
"modelPlaceholder": "Изберете или въведете модел",
"attachHint": "Въведете @, за да се обърнете към хост, фрагмент или автоматизация. Предлагат се промени за одобрение.",
"runCommandTitle": "Изпълнете командата",
"assistant": "Асистент с изкуствен интелект",
"terminalPanelEmpty": "Попитайте асистента за този хост. Всичко, което променя състоянието, първо изисква вашето одобрение.",
"terminalPanelPlaceholder": "Попитайте за този домакин...",
"noProvidersConfigured": "Все още не е конфигуриран доставчик на ИИ. Добавете такъв в раздела „Асистент за ИИ“.",
"runsInOpenTerminal": "Изпълнява се във вашата отворена терминална сесия.",
"runInTerminal": "Бягане",
"terminalContextPrefill": "Ето изхода на моя терминал:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Позволете на други функции да използват вашия API ключ на доставчика на изкуствен интелект за свои собствени заявки. Ключът никога не се копира и можете да го отмените по всяко време."
}
},
"nav": {
"ai": "Изкуствен интелект"
},
"onboarding": {
"aiTitle": "Асистент с изкуствен интелект",
"aiIntro": "Termix може да включва опционален AI асистент. Можете да промените това по-късно в настройките си.",
"aiSkipTitle": "Остави го изключено",
"aiSkipDesc": "Termix работи както сега. Асистентът остава скрит.",
"aiEnableTitle": "Включете го",
"aiEnableDesc": "Добавя асистент, който чете настройките ви и предлага промени, които одобрявате. Необходим е доставчик, когото вие предоставяте, например местен Ollama.",
"aiNote": "Нищо не се изпраща никъде, докато не добавите доставчик и не изпратите съобщение."
},
"hosts": {
"docsLink": "Преглед на документи"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Utilitza l'assistent",
"description": "Obre l'assistent i fes-li preguntes."
},
"manage_providers": {
"title": "Gestiona els proveïdors",
"description": "Afegiu, editeu i elimineu els proveïdors d'IA i les seves claus API."
},
"apply_proposals": {
"title": "Aplicar propostes",
"description": "Deixa que l'assistent dugui a terme els canvis que suggereixi."
},
"services.use": {
"title": "Utilitza la IA d'altres funcions",
"description": "Altres funcions poden trucar a l'assistent en nom teu."
},
"secrets.share": {
"title": "Comparteix la clau de l'API",
"description": "Altres funcions poden utilitzar la clau de l'API d'IA emmagatzemada sense veure-la."
}
},
"settings": {
"assistantUnavailable": "L'assistent d'IA està desactivat en aquest servidor.",
"providersNeedOptIn": "Activa l'assistent d'IA que hi ha a sobre per afegir proveïdors.",
"admin": {
"globallyEnabled": {
"label": "Assistent d'IA",
"description": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom."
},
"privateEndpoints": {
"label": "Amfitrions d'IA privats permesos",
"description": "Amfitrions de la vostra xarxa privada als quals els usuaris poden dirigir un proveïdor, com ara un Ollama autoallotjat. Poseu un amfitrió a cada línia."
}
},
"host": {
"enableAiAssistant": {
"label": "Activa l'Assistent d'IA",
"description": "Permet que l'assistent d'IA ajudi al terminal d'aquest amfitrió. Desactivat per defecte, però encara cal que la IA estigui activada per al teu compte."
}
}
},
"ai": {
"title": "Assistent",
"send": "Enviar",
"stop": "Atura",
"save": "Desa",
"cancel": "Cancel·la",
"running": "corrent",
"approve": "Aprova",
"reject": "Rebutja",
"inputPlaceholder": "Pregunteu pels vostres cambrers o descriviu un canvi que vulgueu proposar",
"selectProvider": "Seleccioneu un proveïdor",
"providerSettings": "Configuració del proveïdor",
"addProvider": "Afegeix un proveïdor",
"editProvider": "Edita el proveïdor",
"removeProvider": "Elimina el proveïdor",
"providerType": "Proveïdor",
"providerLabel": "Nom",
"providerLabelPlaceholder": "Per exemple: Ollama local",
"baseUrl": "Adreça del servidor",
"apiKey": "Clau API",
"defaultModel": "Model per defecte",
"defaultModelPlaceholder": "Per exemple: llama3.1 o claude-opus-5",
"labelRequired": "Doneu un nom a aquest proveïdor",
"providerSaveFailed": "No s'ha pogut desar el proveïdor",
"providerUpdated": "Proveïdor actualitzat",
"providerDeleteFailed": "No s'ha pogut eliminar el proveïdor",
"privateEndpointHint": "Les adreces de la vostra xarxa privada necessiten primer que un administrador permeti l'accés a l'amfitrió.",
"providerOllama": "Ollama",
"providerAnthropic": "Antròpic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Bessons",
"providerOpenaiCompatible": "Compatible amb OpenAI",
"toolArguments": "Entrada",
"toolResult": "Resultat",
"statusApplied": "Aplicat",
"statusRejected": "Rebutjat",
"proposalApplied": "Canvi aplicat",
"proposalApplyFailed": "No s'ha pogut aplicar el canvi",
"proposalRejectFailed": "No s'ha pogut rebutjar el canvi",
"enableTitle": "Activa l'assistent d'IA",
"enableDescription": "Deixa que l'assistent llegeixi la teva configuració i et suggereixi canvis perquè els aprovis.",
"readOnlyCommands": "Permetre ordres de diagnòstic de només lectura",
"readOnlyCommandsDescription": "L'assistent pot executar una llista curta d'ordres segures com ara df i uptime sense preguntar cada vegada. Qualsevol cosa que canviï un servidor sempre es proposa primer.",
"modelDetectFailed": "No s'ha pogut contactar amb el proveïdor, per tant, aquesta és una llista general. Podeu escriure qualsevol nom de model.",
"modelCustom": "Introduïu un nom de model",
"modelRefresh": "Actualitza la llista",
"modelPlaceholder": "Trieu o escriviu un model",
"attachHint": "Escriviu @ per fer referència a un host, fragment o automatització. Es proposen canvis perquè els aprovi.",
"runCommandTitle": "Executa l'ordre",
"assistant": "Assistent d'IA",
"terminalPanelEmpty": "Pregunta a l'assistent sobre aquest amfitrió. Qualsevol cosa que canviï d'estat necessita primer la teva aprovació.",
"terminalPanelPlaceholder": "Pregunta per aquest amfitrió...",
"noProvidersConfigured": "Encara no hi ha cap proveïdor d'IA configurat. Afegeix-ne un a la pestanya Assistent d'IA.",
"runsInOpenTerminal": "S'executa a la sessió de terminal oberta.",
"runInTerminal": "Corre",
"terminalContextPrefill": "Aquí teniu la sortida del meu terminal:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Permet que altres funcions utilitzin la clau de l'API del proveïdor d'IA per a les seves pròpies sol·licituds. La clau no es copia mai i pots revocar-la en qualsevol moment."
}
},
"nav": {
"ai": "IA"
},
"onboarding": {
"aiTitle": "Assistent d'IA",
"aiIntro": "Termix pot incloure un assistent d'IA opcional. Pots canviar-ho més tard a la configuració.",
"aiSkipTitle": "Deixa-ho fora",
"aiSkipDesc": "Termix funciona com ho fa ara. L'assistent roman ocult.",
"aiEnableTitle": "Engega-ho",
"aiEnableDesc": "Afegeix un assistent que llegeix la configuració i suggereix els canvis que aproveu. Necessita un proveïdor que subministreu, com ara un Ollama local.",
"aiNote": "No s'envia res enlloc fins que no afegiu un proveïdor i envieu un missatge."
},
"hosts": {
"docsLink": "Veure documents"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Použijte asistenta",
"description": "Otevřete asistenta a zeptejte se ho na něco."
},
"manage_providers": {
"title": "Spravovat poskytovatele",
"description": "Přidávejte, upravujte a odebírejte poskytovatele umělé inteligence a jejich klíče API."
},
"apply_proposals": {
"title": "Použít návrhy",
"description": "Nechte asistenta provést změny, které navrhuje."
},
"services.use": {
"title": "Používejte umělou inteligenci z jiných funkcí",
"description": "Jiné funkce mohou zavolat asistenta za vás."
},
"secrets.share": {
"title": "Sdílení klíče API",
"description": "Jiné funkce mohou používat uložený klíč API AI, aniž by ho viděly."
}
},
"settings": {
"assistantUnavailable": "Asistent s umělou inteligencí je na tomto serveru vypnutý.",
"providersNeedOptIn": "Zapněte výše uvedeného asistenta s umělou inteligencí a přidejte poskytovatele.",
"admin": {
"globallyEnabled": {
"label": "Asistent s umělou inteligencí",
"description": "Umožněte uživatelům zapnout asistenta s umělou inteligencí. Pokud je tato funkce vypnutá, asistent je skrytý a blokovaný pro všechny."
},
"privateEndpoints": {
"label": "Povolení soukromí hostitelé s umělou inteligencí",
"description": "Hostitelé ve vaší privátní síti, na které mohou uživatelé odkazovat poskytovatele, například Ollama hostovaná na vlastním serveru. Na každý řádek uveďte jednoho hostitele."
}
},
"host": {
"enableAiAssistant": {
"label": "Povolit asistenta s umělou inteligencí",
"description": "Povolit asistentovi s umělou inteligencí pomáhat v terminálu tohoto hostitele. Ve výchozím nastavení vypnuto, ale stále vyžaduje, aby byla pro váš účet povolena umělá inteligence."
}
}
},
"ai": {
"title": "Asistent",
"send": "Poslat",
"stop": "Zastávka",
"save": "Uložit",
"cancel": "Zrušit",
"running": "běh",
"approve": "Schvalovat",
"reject": "Odmítnout",
"inputPlaceholder": "Zeptejte se na své servery nebo popište změnu, kterou chcete navrhnout",
"selectProvider": "Vyberte poskytovatele",
"providerSettings": "Nastavení poskytovatele",
"addProvider": "Přidat poskytovatele",
"editProvider": "Upravit poskytovatele",
"removeProvider": "Odebrat poskytovatele",
"providerType": "Poskytovatel",
"providerLabel": "Jméno",
"providerLabelPlaceholder": "Například: Místní Ollama",
"baseUrl": "Adresa serveru",
"apiKey": "Klíč API",
"defaultModel": "Výchozí model",
"defaultModelPlaceholder": "Například: llama3.1 nebo claude-opus-5",
"labelRequired": "Zadejte tomuto poskytovateli název",
"providerSaveFailed": "Nepodařilo se uložit poskytovatele",
"providerUpdated": "Poskytovatel aktualizován",
"providerDeleteFailed": "Nepodařilo se odebrat poskytovatele",
"privateEndpointHint": "Adresy ve vaší privátní síti vyžadují, aby je správce nejprve povolil hostiteli.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropický",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Kompatibilní s OpenAI",
"toolArguments": "Vstup",
"toolResult": "Výsledek",
"statusApplied": "Aplikovaný",
"statusRejected": "Odmítnuto",
"proposalApplied": "Změna použita",
"proposalApplyFailed": "Změnu se nepodařilo použít",
"proposalRejectFailed": "Změnu se nepodařilo odmítnout",
"enableTitle": "Povolte asistenta s umělou inteligencí",
"enableDescription": "Nechte asistenta přečíst si vaše nastavení a navrhnout změny ke schválení.",
"readOnlyCommands": "Povolit diagnostické příkazy pouze pro čtení",
"readOnlyCommandsDescription": "Asistent může spustit krátký seznam bezpečných příkazů, jako například df a uptime, aniž by se pokaždé ptal. Vše, co mění server, je vždy navrženo jako první.",
"modelDetectFailed": "Nepodařilo se mi spojit s poskytovatelem, toto je obecný seznam. Můžete zadat libovolný název modelu.",
"modelCustom": "Zadejte název modelu",
"modelRefresh": "Obnovit seznam",
"modelPlaceholder": "Vyberte nebo zadejte model",
"attachHint": "Zadejte @ pro odkaz na hostitele, úryvek kódu nebo automatizaci. Změny jsou navrženy ke schválení.",
"runCommandTitle": "Spustit příkaz",
"assistant": "Asistent umělé inteligence",
"terminalPanelEmpty": "Zeptejte se asistenta na tohoto hostitele. Jakákoli změna stavu vyžaduje váš první souhlas.",
"terminalPanelPlaceholder": "Zeptejte se na tohoto hostitele...",
"noProvidersConfigured": "Zatím není nakonfigurován žádný poskytovatel AI. Přidejte ho na kartě Asistent AI.",
"runsInOpenTerminal": "Spustí se ve vaší otevřené terminálové relaci.",
"runInTerminal": "Běh",
"terminalContextPrefill": "Zde je můj terminálový výstup:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Umožněte ostatním funkcím používat váš klíč API poskytovatele AI pro jejich vlastní požadavky. Klíč se nikdy nekopíruje a jeho použití můžete kdykoli zrušit."
}
},
"nav": {
"ai": "Umělá inteligence"
},
"onboarding": {
"aiTitle": "Asistent s umělou inteligencí",
"aiIntro": "Termix může obsahovat volitelného asistenta s umělou inteligencí. Toto můžete později změnit v nastavení.",
"aiSkipTitle": "Nech to být",
"aiSkipDesc": "Termix funguje stejně jako teď. Asistent zůstává skrytý.",
"aiEnableTitle": "Zapněte to",
"aiEnableDesc": "Přidá asistenta, který si přečte vaše nastavení a navrhne změny, které schválíte. Vyžaduje poskytovatele, kterého dodáte, například místní Ollamu.",
"aiNote": "Nic se nikam neodesílá, dokud nepřidáte poskytovatele a neodešlete zprávu."
},
"hosts": {
"docsLink": "Zobrazit dokumenty"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Brug assistenten",
"description": "Åbn assistenten og stil den spørgsmål."
},
"manage_providers": {
"title": "Administrer udbydere",
"description": "Tilføj, rediger og fjern AI-udbyderne og deres API-nøgler."
},
"apply_proposals": {
"title": "Anvend forslag",
"description": "Lad assistenten udføre de foreslåede ændringer."
},
"services.use": {
"title": "Brug AI fra andre funktioner",
"description": "Andre funktioner kan ringe til assistenten på dine vegne."
},
"secrets.share": {
"title": "Del API-nøglen",
"description": "Andre funktioner kan bruge den gemte AI API-nøgle uden at se den."
}
},
"settings": {
"assistantUnavailable": "AI-assistenten er slået fra på denne server.",
"providersNeedOptIn": "Slå AI-assistenten ovenfor til for at tilføje udbydere.",
"admin": {
"globallyEnabled": {
"label": "AI-assistent",
"description": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle."
},
"privateEndpoints": {
"label": "Tilladte private AI-værter",
"description": "Værter på dit private netværk, som brugerne kan henvise en udbyder til, f.eks. en selvhostet Ollama. Sæt én vært på hver linje."
}
},
"host": {
"enableAiAssistant": {
"label": "Aktivér AI-assistent",
"description": "Tillad AI-assistenten at hjælpe i denne værts terminal. Deaktiveret som standard, og kræver stadig, at AI er aktiveret for din konto."
}
}
},
"ai": {
"title": "Assistent",
"send": "Sende",
"stop": "Stop",
"save": "Spare",
"cancel": "Ophæve",
"running": "løb",
"approve": "Godkende",
"reject": "Afvise",
"inputPlaceholder": "Spørg om dine servere, eller beskriv en ændring, du vil foreslå",
"selectProvider": "Vælg en udbyder",
"providerSettings": "Udbyderindstillinger",
"addProvider": "Tilføj udbyder",
"editProvider": "Rediger udbyder",
"removeProvider": "Fjern udbyder",
"providerType": "Udbyder",
"providerLabel": "Navn",
"providerLabelPlaceholder": "For eksempel: Lokal Ollama",
"baseUrl": "Serveradresse",
"apiKey": "API-nøgle",
"defaultModel": "Standardmodel",
"defaultModelPlaceholder": "For eksempel: llama3.1 eller claude-opus-5",
"labelRequired": "Giv denne udbyder et navn",
"providerSaveFailed": "Udbyderen kunne ikke gemmes",
"providerUpdated": "Udbyder opdateret",
"providerDeleteFailed": "Udbyderen kunne ikke fjernes",
"privateEndpointHint": "Adresser på dit private netværk kræver først tilladelse fra en administrator til værten.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropisk",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI-kompatibel",
"toolArguments": "Input",
"toolResult": "Resultat",
"statusApplied": "Anvendt",
"statusRejected": "Afvist",
"proposalApplied": "Ændring anvendt",
"proposalApplyFailed": "Ændringen kunne ikke anvendes",
"proposalRejectFailed": "Kunne ikke afvise ændringen",
"enableTitle": "Aktivér AI-assistenten",
"enableDescription": "Lad assistenten læse din opsætning og foreslå ændringer, som du kan godkende.",
"readOnlyCommands": "Tillad skrivebeskyttede diagnosticeringskommandoer",
"readOnlyCommandsDescription": "Assistenten kan køre en kort liste med sikre kommandoer som f.eks. df og uptime uden at spørge hver gang. Alt, der ændrer en server, foreslås altid først.",
"modelDetectFailed": "Kunne ikke få kontakt med udbyderen, så dette er en generel liste. Du kan skrive et hvilket som helst modelnavn.",
"modelCustom": "Indtast et modelnavn",
"modelRefresh": "Opdater listen",
"modelPlaceholder": "Vælg eller skriv en model",
"attachHint": "Skriv @ for at referere til en vært, et kodestykke eller en automatisering. Ændringer foreslås, som du skal godkende.",
"runCommandTitle": "Kør kommando",
"assistant": "AI-assistent",
"terminalPanelEmpty": "Spørg assistenten om denne vært. Alt, der ændrer status, kræver din godkendelse først.",
"terminalPanelPlaceholder": "Spørg om denne vært...",
"noProvidersConfigured": "Der er endnu ikke konfigureret nogen AI-udbyder. Tilføj en i fanen AI-assistent.",
"runsInOpenTerminal": "Kører i din åbne terminalsession.",
"runInTerminal": "Løbe",
"terminalContextPrefill": "Her er mit terminaloutput:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Lad andre funktioner bruge din AI-udbyders API-nøgle til deres egne anmodninger. Nøglen kopieres aldrig, og du kan til enhver tid tilbagekalde den."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI-assistent",
"aiIntro": "Termix kan inkludere en valgfri AI-assistent. Du kan ændre dette senere i dine indstillinger.",
"aiSkipTitle": "Lad det være slukket",
"aiSkipDesc": "Termix fungerer som den gør nu. Assistenten forbliver skjult.",
"aiEnableTitle": "Tænd den",
"aiEnableDesc": "Tilføjer en assistent, der læser din opsætning og foreslår ændringer, du godkender. Kræver en udbyder, du angiver, f.eks. en lokal Ollama.",
"aiNote": "Intet sendes nogen steder, før du tilføjer en udbyder og sender en besked."
},
"hosts": {
"docsLink": "Se dokumenter"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Nutzen Sie den Assistenten",
"description": "Öffne den Assistenten und stelle ihm Fragen."
},
"manage_providers": {
"title": "Anbieter verwalten",
"description": "KI-Anbieter und ihre API-Schlüssel hinzufügen, bearbeiten und entfernen."
},
"apply_proposals": {
"title": "Vorschläge einreichen",
"description": "Lassen Sie den Assistenten die von ihm vorgeschlagenen Änderungen durchführen."
},
"services.use": {
"title": "Nutzen Sie KI aus anderen Funktionen",
"description": "Andere Funktionen rufen möglicherweise in Ihrem Namen den Assistenten auf."
},
"secrets.share": {
"title": "Teilen Sie den API-Schlüssel",
"description": "Andere Funktionen verwenden möglicherweise den gespeicherten KI-API-Schlüssel, ohne ihn anzuzeigen."
}
},
"settings": {
"assistantUnavailable": "Der KI-Assistent ist auf diesem Server deaktiviert.",
"providersNeedOptIn": "Aktivieren Sie den obenstehenden KI-Assistenten, um Anbieter hinzuzufügen.",
"admin": {
"globallyEnabled": {
"label": "KI-Assistent",
"description": "Ermöglichen Sie den Nutzern, den KI-Assistenten zu aktivieren. Solange diese Option deaktiviert ist, ist der Assistent für alle ausgeblendet und gesperrt."
},
"privateEndpoints": {
"label": "Zulässige private KI-Hosts",
"description": "Hosts in Ihrem privaten Netzwerk, auf die Benutzer einen Provider verweisen können, wie z. B. ein selbst gehostetes Ollama. Fügen Sie in jede Zeile einen Host ein."
}
},
"host": {
"enableAiAssistant": {
"label": "KI-Assistent aktivieren",
"description": "Erlauben Sie dem KI-Assistenten, im Terminal dieses Hosts Hilfe zu leisten. Standardmäßig deaktiviert; die KI muss weiterhin für Ihr Konto aktiviert sein."
}
}
},
"ai": {
"title": "Assistent",
"send": "Schicken",
"stop": "Stoppen",
"save": "Speichern",
"cancel": "Abbrechen",
"running": "laufend",
"approve": "Genehmigen",
"reject": "Ablehnen",
"inputPlaceholder": "Erkundigen Sie sich nach Ihren Servern oder beschreiben Sie einen Änderungsvorschlag.",
"selectProvider": "Wählen Sie einen Anbieter aus",
"providerSettings": "Anbietereinstellungen",
"addProvider": "Anbieter hinzufügen",
"editProvider": "Anbieter bearbeiten",
"removeProvider": "Anbieter entfernen",
"providerType": "Anbieter",
"providerLabel": "Name",
"providerLabelPlaceholder": "Zum Beispiel: Lokaler Ollama",
"baseUrl": "Serveradresse",
"apiKey": "API-Schlüssel",
"defaultModel": "Standardmodell",
"defaultModelPlaceholder": "Zum Beispiel: llama3.1 oder claude-opus-5",
"labelRequired": "Geben Sie diesem Anbieter einen Namen",
"providerSaveFailed": "Der Anbieter konnte nicht gespeichert werden.",
"providerUpdated": "Anbieter aktualisiert",
"providerDeleteFailed": "Der Anbieter konnte nicht entfernt werden.",
"privateEndpointHint": "Adressen in Ihrem privaten Netzwerk benötigen eine vorherige Genehmigung durch einen Administrator.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropisch",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI-kompatibel",
"toolArguments": "Eingabe",
"toolResult": "Ergebnis",
"statusApplied": "Angewandt",
"statusRejected": "Abgelehnt",
"proposalApplied": "Änderung angewendet",
"proposalApplyFailed": "Die Änderung konnte nicht angewendet werden.",
"proposalRejectFailed": "Die Änderung konnte nicht abgelehnt werden.",
"enableTitle": "Aktivieren Sie den KI-Assistenten",
"enableDescription": "Lassen Sie den Assistenten Ihre Einstellungen lesen und Ihnen Änderungen zur Genehmigung vorschlagen.",
"readOnlyCommands": "Zulassen von schreibgeschützten Diagnosebefehlen",
"readOnlyCommandsDescription": "Der Assistent kann eine kurze Liste sicherer Befehle wie df und uptime ausführen, ohne jedes Mal nachzufragen. Alle Änderungen, die einen Server betreffen, werden immer zuerst vorgeschlagen.",
"modelDetectFailed": "Der Anbieter konnte nicht erreicht werden, daher handelt es sich um eine allgemeine Liste. Sie können einen beliebigen Modellnamen eingeben.",
"modelCustom": "Geben Sie einen Modellnamen ein",
"modelRefresh": "Liste aktualisieren",
"modelPlaceholder": "Wählen Sie ein Modell aus oder geben Sie es ein.",
"attachHint": "Geben Sie @ ein, um auf einen Host, ein Code-Snippet oder eine Automatisierung zu verweisen. Die Änderungen werden Ihnen zur Genehmigung vorgeschlagen.",
"runCommandTitle": "Befehl ausführen",
"assistant": "KI-Assistent",
"terminalPanelEmpty": "Fragen Sie den Assistenten nach diesem Gastgeber. Jede Statusänderung bedarf Ihrer vorherigen Zustimmung.",
"terminalPanelPlaceholder": "Erkundigen Sie sich nach diesem Gastgeber...",
"noProvidersConfigured": "Es ist noch kein KI-Anbieter konfiguriert. Fügen Sie einen im Tab „KI-Assistent“ hinzu.",
"runsInOpenTerminal": "Wird in Ihrer geöffneten Terminal-Sitzung ausgeführt.",
"runInTerminal": "Ausführen",
"terminalContextPrefill": "Hier ist meine Terminalausgabe:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Erlauben Sie anderen Funktionen, Ihren API-Schlüssel Ihres KI-Anbieters für ihre eigenen Anfragen zu verwenden. Der Schlüssel wird niemals kopiert, und Sie können diese Berechtigung jederzeit widerrufen."
}
},
"nav": {
"ai": "KI"
},
"onboarding": {
"aiTitle": "KI-Assistent",
"aiIntro": "Termix kann optional einen KI-Assistenten beinhalten. Sie können dies später in Ihren Einstellungen ändern.",
"aiSkipTitle": "Lass es weg",
"aiSkipDesc": "Termix funktioniert wie bisher. Der Assistent bleibt verborgen.",
"aiEnableTitle": "Schalte es ein",
"aiEnableDesc": "Fügt einen Assistenten hinzu, der Ihre Einstellungen analysiert und Ihnen von Ihnen genehmigte Änderungen vorschlägt. Benötigt einen von Ihnen angegebenen Anbieter, z. B. einen lokalen Ollama-Händler.",
"aiNote": "Es wird nichts gesendet, bis Sie einen Anbieter hinzufügen und eine Nachricht senden."
},
"hosts": {
"docsLink": "Dokumentation anzeigen"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Χρησιμοποιήστε τον βοηθό",
"description": "Άνοιξε τον βοηθό και ρώτα τον πράγματα."
},
"manage_providers": {
"title": "Διαχείριση παρόχων",
"description": "Προσθήκη, επεξεργασία και κατάργηση των παρόχων τεχνητής νοημοσύνης και των κλειδιών API τους."
},
"apply_proposals": {
"title": "Εφαρμογή προτάσεων",
"description": "Αφήστε τον βοηθό να εκτελέσει τις αλλαγές που προτείνει."
},
"services.use": {
"title": "Χρησιμοποιήστε την Τεχνητή Νοημοσύνη από άλλες λειτουργίες",
"description": "Άλλες λειτουργίες ενδέχεται να καλούν τον βοηθό εκ μέρους σας."
},
"secrets.share": {
"title": "Κοινή χρήση του κλειδιού API",
"description": "Άλλες λειτουργίες ενδέχεται να χρησιμοποιούν το αποθηκευμένο κλειδί API AI χωρίς να το βλέπουν."
}
},
"settings": {
"assistantUnavailable": "Ο βοηθός τεχνητής νοημοσύνης είναι απενεργοποιημένος σε αυτόν τον διακομιστή.",
"providersNeedOptIn": "Ενεργοποιήστε τον βοηθό τεχνητής νοημοσύνης παραπάνω για να προσθέσετε παρόχους.",
"admin": {
"globallyEnabled": {
"label": "Βοηθός Τεχνητής Νοημοσύνης",
"description": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους."
},
"privateEndpoints": {
"label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης",
"description": "Κεντρικοί υπολογιστές στο ιδιωτικό σας δίκτυο στους οποίους οι χρήστες μπορούν να υποδείξουν έναν πάροχο, όπως ένα αυτο-φιλοξενούμενο Ollama. Τοποθετήστε έναν κεντρικό υπολογιστή σε κάθε γραμμή."
}
},
"host": {
"enableAiAssistant": {
"label": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης",
"description": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας."
}
}
},
"ai": {
"title": "Βοηθός",
"send": "Στέλνω",
"stop": "Στάση",
"save": "Εκτός",
"cancel": "Ματαίωση",
"running": "τρέξιμο",
"approve": "Εγκρίνω",
"reject": "Απορρίπτω",
"inputPlaceholder": "Ρωτήστε για τους διακομιστές σας ή περιγράψτε μια αλλαγή που προτείνετε",
"selectProvider": "Επιλέξτε έναν πάροχο",
"providerSettings": "Ρυθμίσεις παρόχου",
"addProvider": "Προσθήκη παρόχου",
"editProvider": "Επεξεργασία παρόχου",
"removeProvider": "Κατάργηση παρόχου",
"providerType": "Προμηθευτής",
"providerLabel": "Ονομα",
"providerLabelPlaceholder": "Για παράδειγμα: Τοπικό Ollama",
"baseUrl": "Διεύθυνση διακομιστή",
"apiKey": "Κλειδί API",
"defaultModel": "Προεπιλεγμένο μοντέλο",
"defaultModelPlaceholder": "Για παράδειγμα: llama3.1 ή claude-opus-5",
"labelRequired": "Δώστε σε αυτόν τον πάροχο ένα όνομα",
"providerSaveFailed": "Δεν ήταν δυνατή η αποθήκευση του παρόχου",
"providerUpdated": "Ο πάροχος ενημερώθηκε",
"providerDeleteFailed": "Δεν ήταν δυνατή η κατάργηση του παρόχου",
"privateEndpointHint": "Οι διευθύνσεις στο ιδιωτικό σας δίκτυο χρειάζονται πρώτα έναν διαχειριστή για να επιτρέψει στον κεντρικό υπολογιστή.",
"providerOllama": "Ολάμα",
"providerAnthropic": "Ανθρώπινο",
"providerOpenai": "Ανοιχτή Τεχνητή Νοημοσύνη",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Συμβατό με OpenAI",
"toolArguments": "Εισαγωγή",
"toolResult": "Αποτέλεσμα",
"statusApplied": "Εφαρμοσμένος",
"statusRejected": "Απορρίφθηκε",
"proposalApplied": "Η αλλαγή εφαρμόστηκε",
"proposalApplyFailed": "Δεν ήταν δυνατή η εφαρμογή της αλλαγής",
"proposalRejectFailed": "Δεν ήταν δυνατή η απόρριψη της αλλαγής",
"enableTitle": "Ενεργοποίηση του βοηθού τεχνητής νοημοσύνης",
"enableDescription": "Αφήστε τον βοηθό να διαβάσει τη ρύθμισή σας και να σας προτείνει αλλαγές για έγκριση.",
"readOnlyCommands": "Να επιτρέπονται διαγνωστικές εντολές μόνο για ανάγνωση",
"readOnlyCommandsDescription": "Ο βοηθός μπορεί να εκτελέσει μια σύντομη λίστα ασφαλών εντολών όπως df και uptime χωρίς να ρωτάει κάθε φορά. Οτιδήποτε αλλάζει έναν διακομιστή προτείνεται πάντα πρώτο.",
"modelDetectFailed": "Δεν ήταν δυνατή η επικοινωνία με τον πάροχο, επομένως αυτή είναι μια γενική λίστα. Μπορείτε να πληκτρολογήσετε οποιοδήποτε όνομα μοντέλου.",
"modelCustom": "Εισαγάγετε ένα όνομα μοντέλου",
"modelRefresh": "Ανανέωση λίστας",
"modelPlaceholder": "Επιλέξτε ή πληκτρολογήστε ένα μοντέλο",
"attachHint": "Πληκτρολογήστε @ για να αναφέρετε έναν κεντρικό υπολογιστή, ένα απόσπασμα κώδικα ή έναν αυτοματισμό. Προτείνονται αλλαγές για έγκριση.",
"runCommandTitle": "Εκτέλεση εντολής",
"assistant": "Βοηθός Τεχνητής Νοημοσύνης",
"terminalPanelEmpty": "Ρωτήστε τον βοηθό σχετικά με αυτόν τον οικοδεσπότη. Οτιδήποτε αλλάζει κατάσταση χρειάζεται πρώτα την έγκρισή σας.",
"terminalPanelPlaceholder": "Ρωτήστε για αυτόν τον οικοδεσπότη...",
"noProvidersConfigured": "Δεν έχει διαμορφωθεί ακόμη πάροχος τεχνητής νοημοσύνης. Προσθέστε έναν στην καρτέλα βοηθού τεχνητής νοημοσύνης.",
"runsInOpenTerminal": "Εκτελείται στην ανοιχτή συνεδρία τερματικού σας.",
"runInTerminal": "Τρέξιμο",
"terminalContextPrefill": "Εδώ είναι η έξοδος του τερματικού μου:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Επιτρέψτε σε άλλες λειτουργίες να χρησιμοποιούν το κλειδί API του παρόχου τεχνητής νοημοσύνης για τα δικά τους αιτήματα. Το κλειδί δεν αντιγράφεται ποτέ και μπορείτε να το ανακαλέσετε ανά πάσα στιγμή."
}
},
"nav": {
"ai": "Όλα συμπεριλαμβάνονται"
},
"onboarding": {
"aiTitle": "Βοηθός Τεχνητής Νοημοσύνης",
"aiIntro": "Το Termix μπορεί να περιλαμβάνει έναν προαιρετικό βοηθό τεχνητής νοημοσύνης. Μπορείτε να το αλλάξετε αυτό αργότερα στις ρυθμίσεις σας.",
"aiSkipTitle": "Αφήστε το μακριά",
"aiSkipDesc": "Το Termix λειτουργεί όπως τώρα. Ο βοηθός παραμένει κρυφός.",
"aiEnableTitle": "Ενεργοποίησέ το",
"aiEnableDesc": "Προσθέτει έναν βοηθό που διαβάζει τη ρύθμισή σας και προτείνει αλλαγές που εγκρίνετε. Χρειάζεται έναν πάροχο που παρέχετε, όπως ένα τοπικό Ollama.",
"aiNote": "Τίποτα δεν αποστέλλεται πουθενά μέχρι να προσθέσετε έναν πάροχο και να στείλετε ένα μήνυμα."
},
"hosts": {
"docsLink": "Προβολή εγγράφων"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Utilice el asistente",
"description": "Abre el asistente y hazle preguntas."
},
"manage_providers": {
"title": "Gestionar proveedores",
"description": "Agregue, edite y elimine los proveedores de IA y sus claves API."
},
"apply_proposals": {
"title": "Presentar propuestas",
"description": "Deja que el asistente realice los cambios que sugiere."
},
"services.use": {
"title": "Utilizar la IA de otras funciones",
"description": "Otras funciones pueden llamar al asistente en tu nombre."
},
"secrets.share": {
"title": "Comparta la clave API",
"description": "Es posible que otras funciones utilicen la clave API de IA almacenada sin tenerla en cuenta."
}
},
"settings": {
"assistantUnavailable": "El asistente de IA está desactivado en este servidor.",
"providersNeedOptIn": "Activa el asistente de IA que aparece arriba para añadir proveedores.",
"admin": {
"globallyEnabled": {
"label": "Asistente de IA",
"description": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos."
},
"privateEndpoints": {
"label": "Hosts de IA privados permitidos",
"description": "Hosts en tu red privada a los que los usuarios pueden dirigir un proveedor, como por ejemplo un servidor Ollama autogestionado. Coloca un host en cada línea."
}
},
"host": {
"enableAiAssistant": {
"label": "Habilitar el asistente de IA",
"description": "Permite que el asistente de IA te ayude en la terminal de este host. Está desactivado por defecto y aún así necesitas tener la IA habilitada para tu cuenta."
}
}
},
"ai": {
"title": "Asistente",
"send": "Enviar",
"stop": "Parada",
"save": "Guardar",
"cancel": "Cancelar",
"running": "en ejecución",
"approve": "Aprobar",
"reject": "Rechazar",
"inputPlaceholder": "Pregunte sobre sus servidores o describa un cambio para proponer.",
"selectProvider": "Seleccione un proveedor",
"providerSettings": "Configuración del proveedor",
"addProvider": "Agregar proveedor",
"editProvider": "Editar proveedor",
"removeProvider": "Eliminar proveedor",
"providerType": "Proveedor",
"providerLabel": "Nombre",
"providerLabelPlaceholder": "Por ejemplo: Ollama local",
"baseUrl": "Dirección del servidor",
"apiKey": "clave API",
"defaultModel": "Modelo predeterminado",
"defaultModelPlaceholder": "Por ejemplo: llama3.1 o claude-opus-5",
"labelRequired": "Dale un nombre a este proveedor.",
"providerSaveFailed": "No se pudo guardar el proveedor",
"providerUpdated": "Proveedor actualizado",
"providerDeleteFailed": "No se pudo eliminar al proveedor.",
"privateEndpointHint": "Las direcciones de su red privada necesitan que un administrador autorice el acceso al host previamente.",
"providerOllama": "Ollama",
"providerAnthropic": "Antrópico",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Compatible con OpenAI",
"toolArguments": "Entrada",
"toolResult": "Resultado",
"statusApplied": "Aplicado",
"statusRejected": "Rechazado",
"proposalApplied": "Cambio aplicado",
"proposalApplyFailed": "No se pudo aplicar el cambio.",
"proposalRejectFailed": "No se pudo rechazar el cambio",
"enableTitle": "Habilitar el asistente de IA",
"enableDescription": "Deja que el asistente lea tu configuración y te sugiera cambios para que los apruebes.",
"readOnlyCommands": "Permitir comandos de diagnóstico de solo lectura",
"readOnlyCommandsDescription": "El asistente puede ejecutar una breve lista de comandos seguros, como df y uptime, sin preguntar cada vez. Cualquier cambio que afecte al servidor siempre se propone primero.",
"modelDetectFailed": "No se pudo contactar con el proveedor, por lo que esta es una lista general. Puede escribir cualquier nombre de modelo.",
"modelCustom": "Introduzca un nombre de modelo",
"modelRefresh": "Actualizar lista",
"modelPlaceholder": "Seleccione o escriba un modelo.",
"attachHint": "Escribe @ para hacer referencia a un host, fragmento de código o automatización. Se proponen cambios para su aprobación.",
"runCommandTitle": "Ejecutar comando",
"assistant": "Asistente de IA",
"terminalPanelEmpty": "Consulta con el asistente sobre este anfitrión. Cualquier cambio de estado requiere tu aprobación previa.",
"terminalPanelPlaceholder": "Pregunta por este anfitrión...",
"noProvidersConfigured": "Aún no hay ningún proveedor de IA configurado. Añada uno en la pestaña del asistente de IA.",
"runsInOpenTerminal": "Se ejecuta en tu sesión de terminal abierta.",
"runInTerminal": "Ejecutar",
"terminalContextPrefill": "Aquí está la salida de mi terminal:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Permite que otras funciones utilicen la clave API de tu proveedor de IA para sus propias solicitudes. La clave nunca se copia y puedes revocarla en cualquier momento."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "Asistente de IA",
"aiIntro": "Termix puede incluir un asistente de IA opcional. Puedes cambiar esto más adelante en la configuración.",
"aiSkipTitle": "Déjalo apagado",
"aiSkipDesc": "Termix funciona como hasta ahora. El asistente permanece oculto.",
"aiEnableTitle": "Enciéndelo",
"aiEnableDesc": "Agrega un asistente que lee tu configuración y sugiere cambios que apruebas. Requiere un proveedor que tú proporciones, como un Ollama local.",
"aiNote": "No se envía nada a ningún sitio hasta que añadas un proveedor y envíes un mensaje."
},
"hosts": {
"docsLink": "Ver documentación"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Käytä avustajaa",
"description": "Avaa avustaja ja kysy siltä asioita."
},
"manage_providers": {
"title": "Hallinnoi palveluntarjoajia",
"description": "Lisää, muokkaa ja poista tekoälypalveluntarjoajia ja niiden API-avaimia."
},
"apply_proposals": {
"title": "Käytä ehdotuksia",
"description": "Anna avustajan suorittaa ehdottamansa muutokset."
},
"services.use": {
"title": "Käytä tekoälyä muista ominaisuuksista",
"description": "Muut ominaisuudet saattavat soittaa avustajalle puolestasi."
},
"secrets.share": {
"title": "Jaa API-avain",
"description": "Muut ominaisuudet saattavat käyttää tallennettua tekoälyn API-avainta näkemättä sitä."
}
},
"settings": {
"assistantUnavailable": "Tekoälyavustaja on pois päältä tällä palvelimella.",
"providersNeedOptIn": "Ota yllä oleva tekoälyavustaja käyttöön lisätäksesi palveluntarjoajia.",
"admin": {
"globallyEnabled": {
"label": "Tekoälyavustaja",
"description": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta."
},
"privateEndpoints": {
"label": "Sallitut yksityiset tekoälyisännät",
"description": "Yksityisen verkon isännät, joihin käyttäjät voivat ohjata palveluntarjoajan, kuten itse isännöity Ollama. Aseta yksi isäntä kullekin riville."
}
},
"host": {
"enableAiAssistant": {
"label": "Ota tekoälyavustaja käyttöön",
"description": "Salli tekoälyavustaja auttaa tämän isännän terminaalissa. Oletusarvoisesti pois päältä, mutta tekoälyn on silti oltava käytössä tililläsi."
}
}
},
"ai": {
"title": "Assistentti",
"send": "Lähetä",
"stop": "Stop",
"save": "Tallentaa",
"cancel": "Peruuttaa",
"running": "juokseminen",
"approve": "Hyväksyä",
"reject": "Hylätä",
"inputPlaceholder": "Kysy tarjoilijoistasi tai kuvaile ehdottamaasi muutosta",
"selectProvider": "Valitse palveluntarjoaja",
"providerSettings": "Palveluntarjoajan asetukset",
"addProvider": "Lisää palveluntarjoaja",
"editProvider": "Muokkaa palveluntarjoajaa",
"removeProvider": "Poista palveluntarjoaja",
"providerType": "Palveluntarjoaja",
"providerLabel": "Nimi",
"providerLabelPlaceholder": "Esimerkiksi: Paikallinen Ollama",
"baseUrl": "Palvelimen osoite",
"apiKey": "API-avain",
"defaultModel": "Oletusmalli",
"defaultModelPlaceholder": "Esimerkiksi: llama3.1 tai claude-opus-5",
"labelRequired": "Anna tälle palveluntarjoajalle nimi",
"providerSaveFailed": "Palveluntarjoajaa ei voitu tallentaa",
"providerUpdated": "Palveluntarjoaja päivitetty",
"providerDeleteFailed": "Palveluntarjoajaa ei voitu poistaa",
"privateEndpointHint": "Yksityisen verkon osoitteiden ylläpitäjän on ensin sallittava isäntä.",
"providerOllama": "Ollama",
"providerAnthropic": "Antrooppinen",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI-yhteensopiva",
"toolArguments": "Syöttö",
"toolResult": "Tulos",
"statusApplied": "Käytetty",
"statusRejected": "Hylätty",
"proposalApplied": "Muutos otettu käyttöön",
"proposalApplyFailed": "Muutosta ei voitu ottaa käyttöön",
"proposalRejectFailed": "Muutosta ei voitu hylätä",
"enableTitle": "Ota tekoälyavustaja käyttöön",
"enableDescription": "Anna avustajan lukea asetuksesi ja ehdottaa muutoksia hyväksyttäväksi.",
"readOnlyCommands": "Salli vain luettavat diagnostiikkakomennot",
"readOnlyCommandsDescription": "Assistantti voi suorittaa lyhyen listan turvallisia komentoja, kuten df ja uptime, kysymättä joka kerta. Kaikki palvelinta muuttavat ehdotukset esitetään aina ensin.",
"modelDetectFailed": "Palveluntarjoajaan ei saatu yhteyttä, joten tämä on yleisluettelo. Voit kirjoittaa minkä tahansa mallin nimen.",
"modelCustom": "Anna mallin nimi",
"modelRefresh": "Päivitä lista",
"modelPlaceholder": "Valitse tai kirjoita malli",
"attachHint": "Kirjoita @ viitataksesi isäntään, koodinpätkään tai automaatioon. Muutoksia ehdotetaan hyväksyttäväksi.",
"runCommandTitle": "Suorita komento",
"assistant": "Tekoälyavustaja",
"terminalPanelEmpty": "Kysy avustajalta tästä majoittajasta. Kaikki osavaltion muutokset vaativat ensin hyväksyntäsi.",
"terminalPanelPlaceholder": "Kysy tästä isännöitsijästä...",
"noProvidersConfigured": "Tekoälypalveluntarjoajaa ei ole vielä määritetty. Lisää sellainen tekoälyavustaja-välilehdellä.",
"runsInOpenTerminal": "Suoritetaan avoimessa pääteistunnossa.",
"runInTerminal": "Juokse",
"terminalContextPrefill": "Tässä on päätteen tuloste:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Anna muiden ominaisuuksien käyttää tekoälypalveluntarjoajasi API-avainta omiin pyyntöihinsä. Avainta ei koskaan kopioida, ja voit peruuttaa tämän milloin tahansa."
}
},
"nav": {
"ai": "Tekoäly"
},
"onboarding": {
"aiTitle": "Tekoälyavustaja",
"aiIntro": "Termix voi sisältää valinnaisen tekoälyavustaja. Voit muuttaa tätä myöhemmin asetuksissasi.",
"aiSkipTitle": "Jätä se pois",
"aiSkipDesc": "Termix toimii kuten nytkin. Avustaja pysyy piilossa.",
"aiEnableTitle": "Käynnistä se",
"aiEnableDesc": "Lisää avustajan, joka lukee asetuksesi ja ehdottaa hyväksymiäsi muutoksia. Edellyttää toimittamaasi palveluntarjoajaa, kuten paikallisen Ollaman.",
"aiNote": "Mitään ei lähetetä mihinkään ennen kuin lisäät palveluntarjoajan ja lähetät viestin."
},
"hosts": {
"docsLink": "Näytä dokumentit"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Utilisez l'assistant",
"description": "Ouvrez l'assistant et posez-lui des questions."
},
"manage_providers": {
"title": "Gérer les fournisseurs",
"description": "Ajoutez, modifiez et supprimez les fournisseurs d'IA et leurs clés API."
},
"apply_proposals": {
"title": "Soumettre les propositions",
"description": "Laissez l'assistant effectuer les modifications qu'il suggère."
},
"services.use": {
"title": "Utiliser l'IA d'autres fonctionnalités",
"description": "D'autres fonctionnalités peuvent appeler l'assistant en votre nom."
},
"secrets.share": {
"title": "Partagez la clé API",
"description": "D'autres fonctionnalités peuvent utiliser la clé API IA stockée sans la voir."
}
},
"settings": {
"assistantUnavailable": "L'assistant IA est désactivé sur ce serveur.",
"providersNeedOptIn": "Activez l'assistant IA ci-dessus pour ajouter des fournisseurs.",
"admin": {
"globallyEnabled": {
"label": "Assistant IA",
"description": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous."
},
"privateEndpoints": {
"label": "Hôtes d'IA privés autorisés",
"description": "Sur votre réseau privé, configurez des hôtes vers lesquels les utilisateurs peuvent orienter un fournisseur, comme un serveur Ollama auto-hébergé. Placez un hôte sur chaque ligne."
}
},
"host": {
"enableAiAssistant": {
"label": "Activer l'assistant IA",
"description": "Autoriser l'assistant IA à vous aider dans le terminal de cet hôte. Désactivé par défaut ; l'IA doit être activée pour votre compte."
}
}
},
"ai": {
"title": "Assistant",
"send": "Envoyer",
"stop": "Stop",
"save": "Enregistrer",
"cancel": "Annuler",
"running": "en cours",
"approve": "Approuver",
"reject": "Rejeter",
"inputPlaceholder": "Renseignez-vous sur vos serveurs ou décrivez un changement à proposer.",
"selectProvider": "Sélectionnez un fournisseur",
"providerSettings": "Paramètres du fournisseur",
"addProvider": "Ajouter un fournisseur",
"editProvider": "Modifier le fournisseur",
"removeProvider": "Supprimer le fournisseur",
"providerType": "Fournisseur",
"providerLabel": "Nom",
"providerLabelPlaceholder": "Par exemple : Ollama local",
"baseUrl": "Adresse du serveur",
"apiKey": "Clé API",
"defaultModel": "Modèle par défaut",
"defaultModelPlaceholder": "Par exemple : llama3.1 ou claude-opus-5",
"labelRequired": "Donnez un nom à ce fournisseur",
"providerSaveFailed": "Impossible de sauver le fournisseur",
"providerUpdated": "Mise à jour du fournisseur",
"providerDeleteFailed": "Impossible de supprimer le fournisseur",
"privateEndpointHint": "Les adresses de votre réseau privé nécessitent l'autorisation préalable d'un administrateur pour accéder à l'hôte.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropique",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Compatible avec OpenAI",
"toolArguments": "Entrée",
"toolResult": "Résultat",
"statusApplied": "Appliqué",
"statusRejected": "Rejeté",
"proposalApplied": "Changement appliqué",
"proposalApplyFailed": "Impossible d'appliquer la modification",
"proposalRejectFailed": "Impossible de refuser la modification",
"enableTitle": "Activer l'assistant IA",
"enableDescription": "Laissez l'assistant analyser votre configuration et vous suggérer des modifications à approuver.",
"readOnlyCommands": "Autoriser les commandes de diagnostic en lecture seule",
"readOnlyCommandsDescription": "L'assistant peut exécuter une courte liste de commandes sûres, comme `df` et `uptime`, sans demander l'autorisation à chaque fois. Toute modification apportée au serveur est toujours proposée au préalable.",
"modelDetectFailed": "Impossible de joindre le fournisseur ; voici donc une liste générale. Vous pouvez saisir n’importe quel nom de modèle.",
"modelCustom": "Saisissez le nom du modèle",
"modelRefresh": "Actualiser la liste",
"modelPlaceholder": "Choisissez ou saisissez un modèle",
"attachHint": "Saisissez @ pour faire référence à un hôte, un extrait de code ou une automatisation. Les modifications proposées doivent être approuvées.",
"runCommandTitle": "Exécuter la commande",
"assistant": "Assistant IA",
"terminalPanelEmpty": "Renseignez-vous auprès de l'assistant concernant cet hôte. Tout changement d'état nécessite votre approbation préalable.",
"terminalPanelPlaceholder": "Renseignez-vous sur cet hôte...",
"noProvidersConfigured": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez-en un dans l'onglet Assistant IA.",
"runsInOpenTerminal": "S'exécute dans votre session de terminal ouverte.",
"runInTerminal": "Exécuter",
"terminalContextPrefill": "Voici la sortie de mon terminal :\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Autorisez d'autres fonctionnalités à utiliser votre clé API de fournisseur d'IA pour leurs propres requêtes. Cette clé n'est jamais copiée et vous pouvez la révoquer à tout moment."
}
},
"nav": {
"ai": "IA"
},
"onboarding": {
"aiTitle": "Assistant IA",
"aiIntro": "Termix peut inclure un assistant IA optionnel. Vous pouvez modifier ce paramètre ultérieurement.",
"aiSkipTitle": "Laissez-le de côté",
"aiSkipDesc": "Termix fonctionne comme actuellement. L'assistant reste caché.",
"aiEnableTitle": "Allumez-le",
"aiEnableDesc": "Ajoute un assistant qui analyse votre configuration et suggère des modifications que vous approuvez. Nécessite un fournisseur que vous désignez, comme un Ollama local.",
"aiNote": "Aucun message n'est envoyé tant que vous n'avez pas ajouté de fournisseur et envoyé un message."
},
"hosts": {
"docsLink": "Voir la documentation"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "השתמש בעוזר",
"description": "פתח את העוזר ושאל אותו דברים."
},
"manage_providers": {
"title": "ניהול ספקים",
"description": "הוספה, עריכה והסרה של ספקי הבינה המלאכותית ואת מפתחות ה-API שלהם."
},
"apply_proposals": {
"title": "החלת הצעות",
"description": "תן לעוזר לבצע את השינויים שהוא מציע."
},
"services.use": {
"title": "השתמש בבינה מלאכותית מתכונות אחרות",
"description": "ייתכן שתכונות אחרות יתקשרו לעוזר בשמך."
},
"secrets.share": {
"title": "שתף את מפתח ה-API",
"description": "תכונות אחרות עשויות להשתמש במפתח ה-API של בינה מלאכותית המאוחסן מבלי לראות אותו."
}
},
"settings": {
"assistantUnavailable": "עוזר הבינה המלאכותית כבוי בשרת הזה.",
"providersNeedOptIn": "הפעל את עוזר הבינה המלאכותית למעלה כדי להוסיף ספקים.",
"admin": {
"globallyEnabled": {
"label": "עוזר בינה מלאכותית",
"description": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם."
},
"privateEndpoints": {
"label": "מארחים פרטיים של בינה מלאכותית מורשים",
"description": "מארחים ברשת הפרטית שלך שמשתמשים יכולים להפנות אליהם ספק, כגון Ollama המאוחסן בעצמו. שים מארח אחד בכל שורה."
}
},
"host": {
"enableAiAssistant": {
"label": "הפעל את עוזר הבינה המלאכותית",
"description": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך."
}
}
},
"ai": {
"title": "עוֹזֵר",
"send": "לִשְׁלוֹחַ",
"stop": "לְהַפְסִיק",
"save": "לְהַצִיל",
"cancel": "לְבַטֵל",
"running": "רִיצָה",
"approve": "לְאַשֵׁר",
"reject": "לִדחוֹת",
"inputPlaceholder": "שאל לגבי המלצרים שלך, או תאר שינוי להציע",
"selectProvider": "בחר ספק",
"providerSettings": "הגדרות ספק",
"addProvider": "הוסף ספק",
"editProvider": "עריכת ספק",
"removeProvider": "הסר ספק",
"providerType": "ספק",
"providerLabel": "שֵׁם",
"providerLabelPlaceholder": "לדוגמה: אולמה מקומית",
"baseUrl": "כתובת השרת",
"apiKey": "מפתח API",
"defaultModel": "מודל ברירת מחדל",
"defaultModelPlaceholder": "לדוגמה: llama3.1 או claude-opus-5",
"labelRequired": "תן שם לספק הזה",
"providerSaveFailed": "לא ניתן היה לשמור את הספק",
"providerUpdated": "הספק עודכן",
"providerDeleteFailed": "לא ניתן היה להסיר את הספק",
"privateEndpointHint": "כתובות ברשת הפרטית שלך דורשות תחילה אישור ממנהל מערכת למארח.",
"providerOllama": "אולמה",
"providerAnthropic": "אנתרופי",
"providerOpenai": "בינה מלאכותית פתוחה",
"providerGemini": "גוגל ג'מיני",
"providerOpenaiCompatible": "תואם ל-OpenAI",
"toolArguments": "קֶלֶט",
"toolResult": "תוֹצָאָה",
"statusApplied": "יישומי",
"statusRejected": "נִדחֶה",
"proposalApplied": "השינוי הוחל",
"proposalApplyFailed": "לא ניתן היה להחיל את השינוי",
"proposalRejectFailed": "לא ניתן היה לדחות את השינוי",
"enableTitle": "הפעל את עוזר הבינה המלאכותית",
"enableDescription": "תן לעוזר לקרוא את ההגדרות שלך ולהציע שינויים לאישור.",
"readOnlyCommands": "אפשר פקודות אבחון לקריאה בלבד",
"readOnlyCommandsDescription": "העוזר יכול להריץ רשימה קצרה של פקודות בטוחות כמו df ו-uptime מבלי לשאול בכל פעם. כל דבר שמשנה שרת תמיד מוצע ראשון.",
"modelDetectFailed": "לא ניתן היה להגיע לספק, לכן זוהי רשימה כללית. ניתן להקליד כל שם דגם.",
"modelCustom": "הזן שם דגם",
"modelRefresh": "רענון הרשימה",
"modelPlaceholder": "בחר או הקלד מודל",
"attachHint": "הקלד @ כדי להפנות למארח, קטע טקסט או אוטומציה. שינויים מוצעים לאישורך.",
"runCommandTitle": "הפעלה של הפקודה",
"assistant": "עוזר בינה מלאכותית",
"terminalPanelEmpty": "שאל את העוזר לגבי המארח הזה. כל שינוי במצב דורש תחילה את אישורך.",
"terminalPanelPlaceholder": "שאל על המארח הזה...",
"noProvidersConfigured": "עדיין לא הוגדר ספק בינה מלאכותית. הוסף אחד בכרטיסייה עוזר בינה מלאכותית.",
"runsInOpenTerminal": "פועל בסשן הטרמינל הפתוח שלך.",
"runInTerminal": "לָרוּץ",
"terminalContextPrefill": "הנה פלט הטרמינל שלי:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "אפשרו לתכונות אחרות להשתמש במפתח ה-API של ספק הבינה המלאכותית שלכם עבור בקשות משלהן. המפתח לעולם לא יועתק, ותוכלו לבטל אותו בכל עת."
}
},
"nav": {
"ai": "בינה מלאכותית"
},
"onboarding": {
"aiTitle": "עוזר בינה מלאכותית",
"aiIntro": "טרמיקס יכול לכלול עוזר בינה מלאכותית אופציונלי. ניתן לשנות זאת מאוחר יותר בהגדרות.",
"aiSkipTitle": "תעזוב את זה",
"aiSkipDesc": "טרמיקס עובד כמו שהוא עובד עכשיו. העוזר נשאר מוסתר.",
"aiEnableTitle": "הפעל אותו",
"aiEnableDesc": "מוסיף עוזר שקורא את ההגדרות שלך ומציע שינויים שאתה מאשר. נדרש ספק שאתה מספק, כגון אולמה מקומי.",
"aiNote": "שום דבר לא נשלח לשום מקום עד שתוסיף ספק ותשלח הודעה."
},
"hosts": {
"docsLink": "הצג מסמכים"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "स्टॉप",
"save": "सहेजें",
"cancel": "रद्द करें",
"running": "चल रहे",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "नाम",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "इनपुट",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "चलाएं",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "दस्तावेज़ देखें"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Használja az asszisztenst",
"description": "Nyisd meg az asszisztenst, és kérdezz tőle dolgokat."
},
"manage_providers": {
"title": "Szolgáltatók kezelése",
"description": "MI-szolgáltatók és API-kulcsaik hozzáadása, szerkesztése és eltávolítása."
},
"apply_proposals": {
"title": "Javaslatok alkalmazása",
"description": "Hagyd, hogy az asszisztens végrehajtsa a javasolt módosításokat."
},
"services.use": {
"title": "Használja a mesterséges intelligenciát más funkciókból",
"description": "Más funkciók felhívhatják az asszisztenst az Ön nevében."
},
"secrets.share": {
"title": "API-kulcs megosztása",
"description": "Más funkciók használhatják a tárolt AI API-kulcsot anélkül, hogy látnák azt."
}
},
"settings": {
"assistantUnavailable": "A mesterséges intelligencia asszisztens ki van kapcsolva ezen a szerveren.",
"providersNeedOptIn": "Kapcsolja be a fenti AI asszisztenst a szolgáltatók hozzáadásához.",
"admin": {
"globallyEnabled": {
"label": "MI asszisztens",
"description": "Engedélyezheti a felhasználóknak a mesterséges intelligencia asszisztens bekapcsolását. Amíg ez ki van kapcsolva, az asszisztens rejtett és blokkolt mindenki számára."
},
"privateEndpoints": {
"label": "Engedélyezett privát AI-gazdagépek",
"description": "Privát hálózatán található hosztok, amelyekre a felhasználók szolgáltatót irányíthatnak, például egy saját hosztot futtató Ollama. Minden sorba egy hosztot írjon be."
}
},
"host": {
"enableAiAssistant": {
"label": "AI asszisztens engedélyezése",
"description": "Engedélyezi a mesterséges intelligencia asszisztensének a gazdagép terminálján való segítséget. Alapértelmezés szerint ki van kapcsolva, de továbbra is engedélyeznie kell a mesterséges intelligenciát a fiókjában."
}
}
},
"ai": {
"title": "Helyettes",
"send": "Elküld",
"stop": "Stop",
"save": "Megtakarítás",
"cancel": "Mégsem",
"running": "futás",
"approve": "Jóváhagyás",
"reject": "Elutasít",
"inputPlaceholder": "Kérdezzen a felszolgálókról, vagy írjon le egy javasolt változtatást",
"selectProvider": "Válasszon szolgáltatót",
"providerSettings": "Szolgáltatói beállítások",
"addProvider": "Szolgáltató hozzáadása",
"editProvider": "Szolgáltató szerkesztése",
"removeProvider": "Szolgáltató eltávolítása",
"providerType": "Szolgáltató",
"providerLabel": "Név",
"providerLabelPlaceholder": "Például: Helyi Ollama",
"baseUrl": "Szerver címe",
"apiKey": "API-kulcs",
"defaultModel": "Alapértelmezett modell",
"defaultModelPlaceholder": "Például: llama3.1 vagy claude-opus-5",
"labelRequired": "Adjon nevet ennek a szolgáltatónak",
"providerSaveFailed": "Nem sikerült menteni a szolgáltatót",
"providerUpdated": "Szolgáltató frissítve",
"providerDeleteFailed": "Nem sikerült eltávolítani a szolgáltatót",
"privateEndpointHint": "A privát hálózaton lévő címekhez először egy rendszergazdának kell engedélyeznie a gazdagépet.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropikus",
"providerOpenai": "Nyílt mesterséges intelligencia",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI-kompatibilis",
"toolArguments": "Bemenet",
"toolResult": "Eredmény",
"statusApplied": "Alkalmazott",
"statusRejected": "Elutasítva",
"proposalApplied": "Módosítás alkalmazva",
"proposalApplyFailed": "Nem sikerült alkalmazni a módosítást",
"proposalRejectFailed": "Nem sikerült elutasítani a módosítást",
"enableTitle": "Engedélyezze a mesterséges intelligencia asszisztenst",
"enableDescription": "Hagyd, hogy az asszisztens felolvassa a beállításokat, és javasoljon módosításokat jóváhagyásra.",
"readOnlyCommands": "Csak olvasható diagnosztikai parancsok engedélyezése",
"readOnlyCommandsDescription": "Az asszisztens lefuttathat egy rövid listát a biztonságos parancsokról, mint például a df és az uptime, anélkül, hogy minden alkalommal rákérdezne. Bármi, ami megváltoztat egy szervert, mindig elsőként kerül felkérésre.",
"modelDetectFailed": "Nem sikerült elérni a szolgáltatót, ezért ez egy általános lista. Bármelyik modellnevet beírhatja.",
"modelCustom": "Adjon meg egy modellnevet",
"modelRefresh": "Lista frissítése",
"modelPlaceholder": "Válasszon vagy írjon be egy modellt",
"attachHint": "Írjon be @ jelet egy gazdagépre, kódrészletre vagy automatizálásra való hivatkozáshoz. A rendszer módosításokat javasol jóváhagyásra.",
"runCommandTitle": "Parancs futtatása",
"assistant": "MI asszisztens",
"terminalPanelEmpty": "Kérdezd meg az asszisztenst erről a házigazdáról. Bármi, ami megváltoztatja az állapotát, először a jóváhagyásodra van szükség.",
"terminalPanelPlaceholder": "Kérdezz rá erre a házigazdára...",
"noProvidersConfigured": "Még nincs konfigurálva AI-szolgáltató. Adjon hozzá egyet az AI-asszisztens fülön.",
"runsInOpenTerminal": "A megnyitott terminál munkamenetben fut.",
"runInTerminal": "Fut",
"terminalContextPrefill": "Itt a terminál kimenete:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Engedélyezd más funkcióknak a mesterséges intelligencia szolgáltató API-kulcsának használatát a saját kéréseikhez. A kulcs soha nem kerül másolásra, és ezt bármikor visszavonhatod."
}
},
"nav": {
"ai": "MI"
},
"onboarding": {
"aiTitle": "MI asszisztens",
"aiIntro": "A Termix tartalmazhat opcionális mesterséges intelligencia asszisztenst. Ezt később módosíthatja a beállításokban.",
"aiSkipTitle": "Hagyd ki",
"aiSkipDesc": "A Termix ugyanúgy működik, mint most. Az asszisztens rejtve marad.",
"aiEnableTitle": "Kapcsold be",
"aiEnableDesc": "Hozzáad egy asszisztenst, amely beolvassa a beállításait, és javaslatokat tesz az Ön által jóváhagyott módosításokra. Szükség van egy Ön által biztosított szolgáltatóra, például egy helyi Ollama szakemberre.",
"aiNote": "Semmi sem kerül sehova, amíg nem adsz hozzá egy szolgáltatót és nem küldesz üzenetet."
},
"hosts": {
"docsLink": "Dokumentumok megtekintése"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Utilizzare l'assistente",
"description": "Apri l'assistente e ponigli delle domande."
},
"manage_providers": {
"title": "Gestire i fornitori",
"description": "Aggiungi, modifica e rimuovi i provider di intelligenza artificiale e le relative chiavi API."
},
"apply_proposals": {
"title": "Presentare proposte",
"description": "Lascia che l'assistente esegua le modifiche suggerite."
},
"services.use": {
"title": "Utilizzare l'intelligenza artificiale da altre funzionalità",
"description": "Altre funzioni potrebbero attivare l'assistente per tuo conto."
},
"secrets.share": {
"title": "Condividi la chiave API",
"description": "Altre funzionalità potrebbero utilizzare la chiave API AI memorizzata senza visualizzarla."
}
},
"settings": {
"assistantUnavailable": "L'assistente IA è disattivato su questo server.",
"providersNeedOptIn": "Attiva l'assistente AI qui sopra per aggiungere i fornitori.",
"admin": {
"globallyEnabled": {
"label": "Assistente basato sull'intelligenza artificiale",
"description": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti."
},
"privateEndpoints": {
"label": "Host di IA privati consentiti",
"description": "Host sulla tua rete privata a cui gli utenti possono indirizzare un provider, come ad esempio un'installazione self-hosted di Ollama. Assegna un host a ciascuna linea."
}
},
"host": {
"enableAiAssistant": {
"label": "Assistente AI per l'innovazione",
"description": "Consenti all'assistente IA di fornire assistenza nel terminale di questo host. Disattivato per impostazione predefinita, ma è necessario abilitare l'IA per il tuo account."
}
}
},
"ai": {
"title": "Assistente",
"send": "Inviare",
"stop": "Stop",
"save": "Salva",
"cancel": "Annulla",
"running": "in esecuzione",
"approve": "Approvare",
"reject": "Rifiutare",
"inputPlaceholder": "Chiedi informazioni sui tuoi server o descrivi una modifica da proporre",
"selectProvider": "Seleziona un fornitore",
"providerSettings": "Impostazioni del provider",
"addProvider": "Aggiungi fornitore",
"editProvider": "Fornitore di modifica",
"removeProvider": "Rimuovi il fornitore",
"providerType": "Fornitore",
"providerLabel": "Nome",
"providerLabelPlaceholder": "Ad esempio: Ollama locale",
"baseUrl": "Indirizzo del server",
"apiKey": "Chiave API",
"defaultModel": "Modello predefinito",
"defaultModelPlaceholder": "Ad esempio: llama3.1 o claude-opus-5",
"labelRequired": "Assegna un nome a questo fornitore",
"providerSaveFailed": "Impossibile salvare il provider",
"providerUpdated": "Fornitore aggiornato",
"providerDeleteFailed": "Impossibile rimuovere il provider",
"privateEndpointHint": "Gli indirizzi sulla tua rete privata richiedono prima l'autorizzazione di un amministratore per l'host.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropico",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Compatibile con OpenAI",
"toolArguments": "Input",
"toolResult": "Risultato",
"statusApplied": "Applicato",
"statusRejected": "Respinto",
"proposalApplied": "Modifica applicata",
"proposalApplyFailed": "Impossibile applicare la modifica",
"proposalRejectFailed": "Impossibile rifiutare la modifica",
"enableTitle": "Abilita l'assistente IA",
"enableDescription": "Lascia che l'assistente legga la tua configurazione e ti suggerisca delle modifiche da approvare.",
"readOnlyCommands": "Consenti comandi diagnostici di sola lettura",
"readOnlyCommandsDescription": "L'assistente può eseguire un breve elenco di comandi sicuri come df e uptime senza chiedere conferma ogni volta. Qualsiasi operazione che modifichi un server viene sempre proposta in anticipo.",
"modelDetectFailed": "Non è stato possibile contattare il fornitore, quindi questo è un elenco generico. È possibile digitare qualsiasi nome di modello.",
"modelCustom": "Inserisci il nome del modello",
"modelRefresh": "Aggiorna elenco",
"modelPlaceholder": "Scegli o digita un modello",
"attachHint": "Digita @ per fare riferimento a un host, uno snippet o un'automazione. Le modifiche proposte sono da approvare.",
"runCommandTitle": "Esegui il comando",
"assistant": "Assistente AI",
"terminalPanelEmpty": "Chiedi all'assistente informazioni su questo host. Qualsiasi modifica allo stato richiede la tua approvazione preventiva.",
"terminalPanelPlaceholder": "Chiedi informazioni su questo host...",
"noProvidersConfigured": "Non è ancora configurato alcun provider di IA. Aggiungine uno nella scheda Assistente IA.",
"runsInOpenTerminal": "Viene eseguito nella sessione del terminale aperta.",
"runInTerminal": "Esegui",
"terminalContextPrefill": "Ecco l'output del mio terminale:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Consenti ad altre funzionalità di utilizzare la tua chiave API del provider AI per le proprie richieste. La chiave non viene mai copiata e puoi revocarla in qualsiasi momento."
}
},
"nav": {
"ai": "IA"
},
"onboarding": {
"aiTitle": "Assistente basato sull'intelligenza artificiale",
"aiIntro": "Termix può includere un assistente AI opzionale. Puoi modificare questa impostazione in seguito.",
"aiSkipTitle": "Lascialo spento",
"aiSkipDesc": "Termix funziona come ora. L'assistente rimane nascosto.",
"aiEnableTitle": "Accendilo",
"aiEnableDesc": "Aggiunge un assistente che legge la tua configurazione e suggerisce modifiche che approvi. Richiede un provider che tu fornisca, come ad esempio un Ollama locale.",
"aiNote": "Non viene inviato nulla finché non si aggiunge un provider e si invia un messaggio."
},
"hosts": {
"docsLink": "Visualizza documentazione"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "アシスタントを使用する",
"description": "アシスタントを開いて、いろいろ質問してみてください。"
},
"manage_providers": {
"title": "プロバイダーを管理する",
"description": "AIプロバイダーとそのAPIキーを追加、編集、削除できます。"
},
"apply_proposals": {
"title": "提案を適用する",
"description": "アシスタントに提案された変更を実行させましょう。"
},
"services.use": {
"title": "他の機能からAIを活用する",
"description": "その他の機能によっては、あなたに代わってアシスタントを呼び出す場合があります。"
},
"secrets.share": {
"title": "APIキーを共有する",
"description": "他の機能は、保存されたAI APIキーを認識せずに使用する場合があります。"
}
},
"settings": {
"assistantUnavailable": "このサーバーではAIアシスタントは無効になっています。",
"providersNeedOptIn": "上記のAIアシスタントをオンにして、プロバイダーを追加してください。",
"admin": {
"globallyEnabled": {
"label": "AIアシスタント",
"description": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。"
},
"privateEndpoints": {
"label": "プライベートAIホストを許可",
"description": "ユーザーがプロバイダに指定できる、プライベートネットワーク上のホスト(自己ホスト型のOllamaなど)。各行にホストを1つずつ記述してください。"
}
},
"host": {
"enableAiAssistant": {
"label": "AIアシスタントを有効にする",
"description": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。"
}
}
},
"ai": {
"title": "アシスタント",
"send": "送信",
"stop": "ストップ",
"save": "保存",
"cancel": "キャンセル",
"running": "実行中",
"approve": "承認する",
"reject": "拒否する",
"inputPlaceholder": "サーバーについて質問するか、変更案を説明する",
"selectProvider": "プロバイダーを選択してください",
"providerSettings": "プロバイダー設定",
"addProvider": "プロバイダーを追加",
"editProvider": "プロバイダーを編集",
"removeProvider": "プロバイダーを削除",
"providerType": "プロバイダー",
"providerLabel": "名前",
"providerLabelPlaceholder": "例えば:地元のオラマ",
"baseUrl": "サーバーアドレス",
"apiKey": "APIキー",
"defaultModel": "デフォルトモデル",
"defaultModelPlaceholder": "例えば、llama3.1 や claude-opus-5 など。",
"labelRequired": "このプロバイダーに名前を付けてください",
"providerSaveFailed": "プロバイダーを保存できませんでした",
"providerUpdated": "プロバイダーが更新されました",
"providerDeleteFailed": "プロバイダーを削除できませんでした",
"privateEndpointHint": "プライベートネットワーク上のアドレスを使用するには、まず管理者がホストを許可する必要があります。",
"providerOllama": "オラマ",
"providerAnthropic": "人間",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI対応",
"toolArguments": "受信",
"toolResult": "結果",
"statusApplied": "適用済み",
"statusRejected": "拒否されました",
"proposalApplied": "変更が適用されました",
"proposalApplyFailed": "変更を適用できませんでした",
"proposalRejectFailed": "変更を拒否できませんでした",
"enableTitle": "AIアシスタントを有効にする",
"enableDescription": "アシスタントにあなたの設定を読み取らせ、承認が必要な変更案を提案させましょう。",
"readOnlyCommands": "読み取り専用の診断コマンドを許可する",
"readOnlyCommandsDescription": "アシスタントは、dfやuptimeといった安全なコマンドを毎回確認することなく実行する場合があります。サーバーに変更を加える操作は、常に最初に提案されます。",
"modelDetectFailed": "プロバイダーに連絡が取れなかったため、これは一般的なリストです。任意のモデル名を入力してください。",
"modelCustom": "モデル名を入力してください",
"modelRefresh": "リストを更新",
"modelPlaceholder": "モデルを選択または入力してください",
"attachHint": "ホスト、スニペット、またはオートメーションを参照するには、「@」を入力してください。変更案が表示され、承認が必要となります。",
"runCommandTitle": "コマンドを実行する",
"assistant": "AIアシスタント",
"terminalPanelEmpty": "このホストについてアシスタントにお問い合わせください。状態変更を伴う変更はすべて、事前にあなたの承認が必要です。",
"terminalPanelPlaceholder": "このホストについて質問する...",
"noProvidersConfigured": "AIプロバイダーがまだ設定されていません。AIアシスタントタブで追加してください。",
"runsInOpenTerminal": "開いているターミナルセッションで実行されます。",
"runInTerminal": "実行",
"terminalContextPrefill": "これが私のターミナル出力です:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "他の機能が、独自の要求にあなたのAIプロバイダーAPIキーを使用できるようにします。キーは決して複製されず、いつでも取り消すことができます。"
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AIアシスタント",
"aiIntro": "TermixにはオプションでAIアシスタントを搭載できます。これは後で設定画面で変更できます。",
"aiSkipTitle": "オフにしておく",
"aiSkipDesc": "Termixは現状のまま動作します。アシスタントは非表示のままです。",
"aiEnableTitle": "電源を入れる",
"aiEnableDesc": "設定を読み取り、承認した変更を提案するアシスタント機能を追加します。ローカルのOllamaなど、お客様ご自身で手配するプロバイダーが必要です。",
"aiNote": "プロバイダーを追加してメッセージを送信するまで、データはどこにも送信されません。"
},
"hosts": {
"docsLink": "ドキュメントを表示"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "도우미를 사용하세요",
"description": "어시스턴트를 열고 질문을 해보세요."
},
"manage_providers": {
"title": "공급자 관리",
"description": "AI 제공업체와 해당 API 키를 추가, 편집 및 삭제할 수 있습니다."
},
"apply_proposals": {
"title": "제안서를 제출하세요",
"description": "도우미가 제안하는 변경 사항을 실행하도록 하세요."
},
"services.use": {
"title": "다른 기능의 AI를 활용하세요",
"description": "다른 기능들이 사용자를 대신하여 어시스턴트에게 전화를 걸 수도 있습니다."
},
"secrets.share": {
"title": "API 키를 공유하세요",
"description": "다른 기능들이 저장된 AI API 키를 직접 보지 않고도 사용할 수 있습니다."
}
},
"settings": {
"assistantUnavailable": "이 서버에서는 AI 비서 기능이 꺼져 있습니다.",
"providersNeedOptIn": "위의 AI 도우미를 켜서 제공업체를 추가하세요.",
"admin": {
"globallyEnabled": {
"label": "AI 비서",
"description": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다."
},
"privateEndpoints": {
"label": "개인 AI 호스트 허용",
"description": "사용자가 공급자를 지정할 수 있는 개인 네트워크의 호스트(예: 자체 호스팅 Ollama)를 입력합니다. 각 줄에 호스트를 하나씩 입력하십시오."
}
},
"host": {
"enableAiAssistant": {
"label": "AI 어시스턴트 활성화",
"description": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다."
}
}
},
"ai": {
"title": "어시스턴트",
"send": "보내다",
"stop": "스톱",
"save": "저장",
"cancel": "취소",
"running": "실행 중",
"approve": "승인하다",
"reject": "거부하다",
"inputPlaceholder": "서버에 대해 문의하거나, 변경 사항을 제안해 보세요.",
"selectProvider": "제공업체를 선택하세요",
"providerSettings": "공급자 설정",
"addProvider": "공급자 추가",
"editProvider": "제공자 수정",
"removeProvider": "제공업체를 제거하세요",
"providerType": "공급자",
"providerLabel": "이름",
"providerLabelPlaceholder": "예: 지역 올라마",
"baseUrl": "서버 주소",
"apiKey": "API 키",
"defaultModel": "기본 모델",
"defaultModelPlaceholder": "예를 들어: llama3.1 또는 claude-opus-5",
"labelRequired": "이 제공업체에 이름을 지정하세요",
"providerSaveFailed": "공급자를 저장할 수 없습니다.",
"providerUpdated": "제공업체 업데이트됨",
"providerDeleteFailed": "공급자를 제거할 수 없습니다.",
"privateEndpointHint": "개인 네트워크의 주소에 접근하려면 관리자가 먼저 호스트를 허용해야 합니다.",
"providerOllama": "올라마",
"providerAnthropic": "인류",
"providerOpenai": "오픈아이",
"providerGemini": "구글 제미니",
"providerOpenaiCompatible": "OpenAI와 호환 가능",
"toolArguments": "입력",
"toolResult": "결과",
"statusApplied": "적용된",
"statusRejected": "거절됨",
"proposalApplied": "변경 사항이 적용되었습니다",
"proposalApplyFailed": "변경 사항을 적용할 수 없습니다.",
"proposalRejectFailed": "변경을 거부할 수 없었습니다",
"enableTitle": "AI 비서를 활성화하세요",
"enableDescription": "도우미가 사용자의 설정을 읽고 승인할 변경 사항을 제안하도록 하세요.",
"readOnlyCommands": "읽기 전용 진단 명령을 허용합니다.",
"readOnlyCommandsDescription": "도우미는 매번 묻지 않고 df 및 uptime과 같은 안전한 명령어 목록을 실행할 수 있습니다. 서버를 변경하는 작업은 항상 먼저 제안됩니다.",
"modelDetectFailed": "공급업체에 연결할 수 없어서 일반적인 목록을 제공합니다. 모델명을 입력하셔도 됩니다.",
"modelCustom": "모델명을 입력하세요",
"modelRefresh": "목록 새로 고침",
"modelPlaceholder": "모델을 선택하거나 입력하세요.",
"attachHint": "호스트, 코드 조각 또는 자동화를 참조하려면 @를 입력하세요. 승인을 위해 변경 사항이 제안됩니다.",
"runCommandTitle": "명령어를 실행하세요",
"assistant": "AI 어시스턴트",
"terminalPanelEmpty": "이 호스트에 대해 담당자에게 문의하십시오. 상태가 변경되는 모든 사항은 먼저 사용자의 승인이 필요합니다.",
"terminalPanelPlaceholder": "이 호스트에 대해 문의해 보세요...",
"noProvidersConfigured": "아직 AI 제공업체가 구성되지 않았습니다. AI 도우미 탭에서 추가하세요.",
"runsInOpenTerminal": "열려 있는 터미널 세션에서 실행됩니다.",
"runInTerminal": "실행",
"terminalContextPrefill": "다음은 제 터미널 출력 결과입니다:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "다른 기능에서 AI 제공업체 API 키를 사용하여 자체 요청을 처리할 수 있도록 허용합니다. 키는 절대 복사되지 않으며 언제든지 취소할 수 있습니다."
}
},
"nav": {
"ai": "일체 포함"
},
"onboarding": {
"aiTitle": "AI 비서",
"aiIntro": "Termix에는 선택적으로 AI 비서 기능이 포함될 수 있습니다. 이 기능은 나중에 설정에서 변경할 수 있습니다.",
"aiSkipTitle": "꺼두세요",
"aiSkipDesc": "Termix는 현재와 같이 작동합니다. 어시스턴트는 숨겨진 상태로 유지됩니다.",
"aiEnableTitle": "켜세요",
"aiEnableDesc": "설정을 읽고 사용자가 승인하는 변경 사항을 제안하는 도우미를 추가합니다. 사용자가 제공하는 공급업체(예: 지역 Ollama)가 필요합니다.",
"aiNote": "공급자를 추가하고 메시지를 보내기 전까지는 아무것도 전송되지 않습니다."
},
"hosts": {
"docsLink": "문서 보기"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Gebruik de assistent",
"description": "Open de assistent en stel er vragen aan."
},
"manage_providers": {
"title": "Beheer aanbieders",
"description": "AI-providers en hun API-sleutels toevoegen, bewerken en verwijderen."
},
"apply_proposals": {
"title": "Dien voorstellen in",
"description": "Laat de assistent de voorgestelde wijzigingen doorvoeren."
},
"services.use": {
"title": "Gebruik AI van andere functies",
"description": "Andere functies kunnen de assistent namens u inschakelen."
},
"secrets.share": {
"title": "Deel de API-sleutel",
"description": "Andere functies kunnen de opgeslagen AI API-sleutel gebruiken zonder deze te zien."
}
},
"settings": {
"assistantUnavailable": "De AI-assistent is op deze server uitgeschakeld.",
"providersNeedOptIn": "Schakel de AI-assistent hierboven in om aanbieders toe te voegen.",
"admin": {
"globallyEnabled": {
"label": "AI-assistent",
"description": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd."
},
"privateEndpoints": {
"label": "Toegestane privé-AI-hosts",
"description": "Hosts op uw privénetwerk waarnaar gebruikers een provider kunnen verwijzen, zoals een zelfgehoste Ollama. Plaats één host per lijn."
}
},
"host": {
"enableAiAssistant": {
"label": "AI-assistent inschakelen",
"description": "Sta de AI-assistent toe om te helpen in de terminal van deze host. Standaard uitgeschakeld, maar AI moet nog steeds voor uw account zijn ingeschakeld."
}
}
},
"ai": {
"title": "Assistent",
"send": "Versturen",
"stop": "Stop",
"save": "Redden",
"cancel": "Annuleren",
"running": "rennen",
"approve": "Goedkeuren",
"reject": "Afwijzen",
"inputPlaceholder": "Vraag naar uw servers of beschrijf een voorgestelde wijziging.",
"selectProvider": "Selecteer een aanbieder",
"providerSettings": "Aanbiederinstellingen",
"addProvider": "Aanbieder toevoegen",
"editProvider": "Bewerk provider",
"removeProvider": "Verwijder provider",
"providerType": "Aanbieder",
"providerLabel": "Naam",
"providerLabelPlaceholder": "Bijvoorbeeld: Lokale Ollama",
"baseUrl": "Serveradres",
"apiKey": "API-sleutel",
"defaultModel": "Standaardmodel",
"defaultModelPlaceholder": "Bijvoorbeeld: llama3.1 of claude-opus-5",
"labelRequired": "Geef deze aanbieder een naam",
"providerSaveFailed": "De provider kon niet worden opgeslagen.",
"providerUpdated": "Aanbieder bijgewerkt",
"providerDeleteFailed": "De provider kon niet worden verwijderd.",
"privateEndpointHint": "IP-adressen op uw privénetwerk moeten eerst door een beheerder worden toegestaan.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropisch",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI-compatibel",
"toolArguments": "Invoer",
"toolResult": "Resultaat",
"statusApplied": "Toegepast",
"statusRejected": "Afgewezen",
"proposalApplied": "Toegepaste wijziging",
"proposalApplyFailed": "De wijziging kon niet worden toegepast.",
"proposalRejectFailed": "Kon de wijziging niet afwijzen",
"enableTitle": "Schakel de AI-assistent in",
"enableDescription": "Laat de assistent je instellingen lezen en wijzigingen voorstellen die je vervolgens kunt goedkeuren.",
"readOnlyCommands": "Sta alleen-lezen diagnostische opdrachten toe.",
"readOnlyCommandsDescription": "De assistent kan een korte lijst met veilige commando's zoals df en uptime uitvoeren zonder telkens opnieuw toestemming te vragen. Alles wat een server wijzigt, wordt altijd eerst voorgesteld.",
"modelDetectFailed": "De aanbieder kon niet worden bereikt, daarom is dit een algemene lijst. U kunt elke modelnaam invoeren.",
"modelCustom": "Voer een modelnaam in",
"modelRefresh": "Vernieuw de lijst",
"modelPlaceholder": "Kies of typ een model",
"attachHint": "Typ @ om te verwijzen naar een host, codefragment of automatisering. Wijzigingen worden voorgesteld ter goedkeuring.",
"runCommandTitle": "Voer het commando uit",
"assistant": "AI-assistent",
"terminalPanelEmpty": "Vraag de assistent naar deze host. Alles wat de status wijzigt, vereist eerst uw goedkeuring.",
"terminalPanelPlaceholder": "Vraag naar deze host...",
"noProvidersConfigured": "Er is nog geen AI-provider geconfigureerd. Voeg er een toe via het tabblad AI-assistent.",
"runsInOpenTerminal": "Wordt uitgevoerd in uw geopende terminalsessie.",
"runInTerminal": "Loop",
"terminalContextPrefill": "Hier is mijn terminaluitvoer:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Laat andere functies uw API-sleutel van de AI-provider gebruiken voor hun eigen verzoeken. De sleutel wordt nooit gekopieerd en u kunt deze op elk moment intrekken."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI-assistent",
"aiIntro": "Termix kan optioneel een AI-assistent bevatten. Je kunt dit later in je instellingen wijzigen.",
"aiSkipTitle": "Laat het weg",
"aiSkipDesc": "Termix werkt zoals het nu werkt. De assistent blijft verborgen.",
"aiEnableTitle": "Schakel het in.",
"aiEnableDesc": "Voegt een assistent toe die je instellingen leest en wijzigingen voorstelt die je kunt goedkeuren. Vereist een leverancier die je zelf aanlevert, zoals een lokale Ollama.",
"aiNote": "Er wordt niets verzonden totdat je een provider toevoegt en een bericht verstuurt."
},
"hosts": {
"docsLink": "Documenten bekijken"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stopp",
"save": "Lagre",
"cancel": "Avbryt",
"running": "kjører",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Navn",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Kjør",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "Asystent AI",
"description": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich."
},
"privateEndpoints": {
"label": "Dozwolone prywatne hosty AI",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Asystent",
"send": "Wyślij",
"stop": "Zatrzymaj",
"save": "Zapisz",
"cancel": "Anuluj",
"running": "działa",
"approve": "Zatwierdź",
"reject": "Odrzuć",
"inputPlaceholder": "Zapytaj o swoje serwery lub opisz proponowaną zmianę",
"selectProvider": "Wybierz dostawcę",
"providerSettings": "Ustawienia dostawcy",
"addProvider": "Dodaj dostawcę",
"editProvider": "Edytuj dostawcę",
"removeProvider": "Usuń dostawcę",
"providerType": "Dostawca",
"providerLabel": "Nazwa",
"providerLabelPlaceholder": "Na przykład: Ollama lokalnie",
"baseUrl": "Adres serwera",
"apiKey": "Klucz API",
"defaultModel": "Model domyślny",
"defaultModelPlaceholder": "Na przykład: llama3.1 lub claude-opus-5",
"labelRequired": "Podaj nazwę tego dostawcy",
"providerSaveFailed": "Nie udało się zapisać dostawcy",
"providerUpdated": "Zaktualizowano dostawcę",
"providerDeleteFailed": "Nie można usunąć dostawcy",
"privateEndpointHint": "Adresy w sieci prywatnej wymagają, aby administrator najpierw dopuścił dany host.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "Kompatybilny z OpenAI",
"toolArguments": "Dane wejściowe",
"toolResult": "Wynik",
"statusApplied": "Zastosowano",
"statusRejected": "Odrzucono",
"proposalApplied": "Zastosowano zmianę",
"proposalApplyFailed": "Nie można zastosować zmiany",
"proposalRejectFailed": "Nie można odrzucić zmiany",
"enableTitle": "Włącz asystenta AI",
"enableDescription": "Pozwól asystentowi odczytać konfigurację i proponować zmiany do Twojego zatwierdzenia.",
"readOnlyCommands": "Zezwalaj na polecenia diagnostyczne tylko do odczytu",
"readOnlyCommandsDescription": "Asystent może uruchamiać krótką listę bezpiecznych poleceń, takich jak df czy uptime, bez pytania za każdym razem. Wszystko, co zmienia serwer, jest zawsze najpierw proponowane do zatwierdzenia.",
"modelDetectFailed": "Nie udało się skontaktować z dostawcą, więc jest to lista ogólna. Możesz wpisać dowolną nazwę modelu.",
"modelCustom": "Wprowadź nazwę modelu",
"modelRefresh": "Odśwież listę",
"modelPlaceholder": "Wybierz lub wpisz model",
"attachHint": "Wpisz @, aby odwołać się do hosta, snippetu lub automatyzacji. Zmiany są proponowane do Twojego zatwierdzenia.",
"runCommandTitle": "Uruchom polecenie",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Uruchom",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "Asystent AI",
"aiIntro": "Termix może zawierać opcjonalnego asystenta AI. Możesz to później zmienić w ustawieniach.",
"aiSkipTitle": "Zostaw wyłączone",
"aiSkipDesc": "Termix działa tak jak teraz. Asystent pozostaje ukryty.",
"aiEnableTitle": "Włącz",
"aiEnableDesc": "Dodaje asystenta, który odczytuje Twoją konfigurację i sugeruje zmiany, które zatwierdzasz. Wymaga wskazanego przez Ciebie dostawcy, np. lokalnej Ollamy.",
"aiNote": "Dopóki nie dodasz dostawcy i nie wyślesz wiadomości, nic nie zostanie wysłane."
},
"hosts": {
"docsLink": "Zobacz dokumentację"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Salvar",
"cancel": "Cancelar",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Entrada",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "Documentação"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Guardar",
"cancel": "Cancelar",
"running": "em execução",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Nome",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Entrada",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Executar",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "Ver documentação"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Folosește asistentul",
"description": "Deschide asistentul și pune-i întrebări."
},
"manage_providers": {
"title": "Gestionați furnizorii",
"description": "Adăugați, editați și eliminați furnizorii de inteligență artificială și cheile API ale acestora."
},
"apply_proposals": {
"title": "Aplicați propunerile",
"description": "Lăsați asistentul să efectueze modificările sugerate de acesta."
},
"services.use": {
"title": "Folosește inteligența artificială din alte funcții",
"description": "Alte funcții pot apela asistentul în numele tău."
},
"secrets.share": {
"title": "Partajați cheia API",
"description": "Alte funcții pot utiliza cheia API AI stocată fără a o vedea."
}
},
"settings": {
"assistantUnavailable": "Asistentul AI este dezactivat pe acest server.",
"providersNeedOptIn": "Activează asistentul AI de mai sus pentru a adăuga furnizori.",
"admin": {
"globallyEnabled": {
"label": "Asistent AI",
"description": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp această opțiune este dezactivată, asistentul este ascuns și blocat pentru toată lumea."
},
"privateEndpoints": {
"label": "Gazde private AI permise",
"description": "Gazde din rețeaua ta privată către care utilizatorii pot direcționa un furnizor, cum ar fi un Ollama auto-găzduit. Pune câte o gazdă pe fiecare linie."
}
},
"host": {
"enableAiAssistant": {
"label": "Activează Asistentul AI",
"description": "Permiteți asistentului AI să vă ajute în terminalul acestei gazde. Dezactivat în mod implicit, dar este nevoie ca AI să fie activat pentru contul dvs."
}
}
},
"ai": {
"title": "Asistent",
"send": "Trimite",
"stop": "Stop",
"save": "Salva",
"cancel": "Anula",
"running": "funcţionare",
"approve": "Aproba",
"reject": "Respinge",
"inputPlaceholder": "Întreabă despre chelnerii tăi sau descrie o schimbare pe care o propui.",
"selectProvider": "Selectați un furnizor",
"providerSettings": "Setări furnizor",
"addProvider": "Adăugați furnizor",
"editProvider": "Editați furnizorul",
"removeProvider": "Eliminați furnizorul",
"providerType": "Furnizor",
"providerLabel": "Nume",
"providerLabelPlaceholder": "De exemplu: Ollama locală",
"baseUrl": "Adresa serverului",
"apiKey": "Cheie API",
"defaultModel": "Model implicit",
"defaultModelPlaceholder": "De exemplu: llama3.1 sau claude-opus-5",
"labelRequired": "Dați un nume acestui furnizor",
"providerSaveFailed": "Nu s-a putut salva furnizorul",
"providerUpdated": "Furnizor actualizat",
"providerDeleteFailed": "Nu s-a putut elimina furnizorul",
"privateEndpointHint": "Adresele din rețeaua privată necesită mai întâi permisiunea unui administrator pentru gazdă.",
"providerOllama": "Ollama",
"providerAnthropic": "Antropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemeni",
"providerOpenaiCompatible": "Compatibil cu OpenAI",
"toolArguments": "Intrare",
"toolResult": "Rezultat",
"statusApplied": "Aplicat",
"statusRejected": "Respins",
"proposalApplied": "Modificare aplicată",
"proposalApplyFailed": "Nu s-a putut aplica modificarea",
"proposalRejectFailed": "Nu s-a putut respinge modificarea",
"enableTitle": "Activează asistentul AI",
"enableDescription": "Permiteți asistentului să citească configurația și să sugereze modificări pentru aprobare.",
"readOnlyCommands": "Permite comenzi de diagnosticare doar în citire",
"readOnlyCommandsDescription": "Asistentul poate rula o listă scurtă de comenzi sigure, cum ar fi df și uptime, fără a întreba de fiecare dată. Orice modificare a unui server este întotdeauna propusă prima.",
"modelDetectFailed": "Nu s-a putut contacta furnizorul, așadar aceasta este o listă generală. Puteți introduce orice nume de model.",
"modelCustom": "Introduceți un nume de model",
"modelRefresh": "Reîmprospătați lista",
"modelPlaceholder": "Alegeți sau introduceți un model",
"attachHint": "Tastați @ pentru a face referire la o gazdă, un fragment de cod sau o automatizare. Sunt propuse modificări pentru aprobare.",
"runCommandTitle": "Comandă de rulare",
"assistant": "Asistent AI",
"terminalPanelEmpty": "Întreabă asistentul despre această gazdă. Orice modificare a stării necesită mai întâi aprobarea ta.",
"terminalPanelPlaceholder": "Întreabă despre această gazdă...",
"noProvidersConfigured": "Încă nu este configurat niciun furnizor de inteligență artificială. Adăugați unul în fila Asistent de inteligență artificială.",
"runsInOpenTerminal": "Rulează în sesiunea de terminal deschisă.",
"runInTerminal": "Aleargă",
"terminalContextPrefill": "Iată ieșirea terminalului meu:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Permiteți altor funcții să utilizeze cheia API a furnizorului dvs. de inteligență artificială pentru propriile solicitări. Cheia nu este niciodată copiată și puteți revoca această opțiune oricând."
}
},
"nav": {
"ai": "IA"
},
"onboarding": {
"aiTitle": "Asistent AI",
"aiIntro": "Termix poate include un asistent AI opțional. Puteți modifica acest lucru ulterior în setări.",
"aiSkipTitle": "Lasă-l deoparte",
"aiSkipDesc": "Termix funcționează ca acum. Asistentul rămâne ascuns.",
"aiEnableTitle": "Pornește-l",
"aiEnableDesc": "Adaugă un asistent care citește configurația și sugerează modificări pe care le aprobi. Necesită un furnizor furnizat de tine, cum ar fi un Ollama local.",
"aiNote": "Nimic nu este trimis nicăieri până nu adăugați un furnizor și nu trimiteți un mesaj."
},
"hosts": {
"docsLink": "Vizualizați documentele"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Остановить",
"save": "Сохранить",
"cancel": "Отмена",
"running": "выполняется",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Название",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Ввод",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Запустить",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "Документация"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Durdurma",
"save": "Kaydet",
"cancel": "İptal",
"running": "çalışan",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Ad",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Giriş",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Çalıştır",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "Dokümanları görüntüle"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Stop",
"save": "Save",
"cancel": "Cancel",
"running": "running",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Name",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Input",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Run",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "View docs"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "Bit dừng",
"save": "Lưu",
"cancel": "Hủy",
"running": "đang chạy",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "Tên",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "Nhập",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "Chạy",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "Xem tài liệu"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI 助手",
"description": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。"
},
"privateEndpoints": {
"label": "允许的私有 AI 主机",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "停止",
"save": "保存",
"cancel": "取消",
"running": "运行中",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "编辑提供商",
"removeProvider": "Remove provider",
"providerType": "提供商",
"providerLabel": "名称",
"providerLabelPlaceholder": "例如:本地 Ollama",
"baseUrl": "Server address",
"apiKey": "API 密钥",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "给此提供商起个名字",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "输入",
"toolResult": "Result",
"statusApplied": "已应用",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "启用 AI 助手",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "无法联系到提供商,所以这里提供的是通用列表。您可以输入任何模型名称。",
"modelCustom": "请输入模型名称",
"modelRefresh": "Refresh list",
"modelPlaceholder": "选择或输入模型",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "运行",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI 助手",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "禁用",
"aiSkipDesc": "Termix 正常工作,AI 助手将保持隐藏状态。",
"aiEnableTitle": "启用",
"aiEnableDesc": "启用 AI 助手,它将分析您的配置并提供优化建议。需自行配置服务商,例如本地运行的 Ollama。",
"aiNote": "在您配置服务商并主动发送消息之前,绝不会向外发送任何数据。"
},
"hosts": {
"docsLink": "查看文档"
}
}
+119
View File
@@ -0,0 +1,119 @@
{
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other features",
"description": "Other features may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other features may use the stored AI API key without seeing it."
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server.",
"providersNeedOptIn": "Turn on the AI assistant above to add providers.",
"admin": {
"globallyEnabled": {
"label": "AI assistant",
"description": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone."
},
"privateEndpoints": {
"label": "Allowed private AI hosts",
"description": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Put one host on each line."
}
},
"host": {
"enableAiAssistant": {
"label": "Enable AI Assistant",
"description": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account."
}
}
},
"ai": {
"title": "Assistant",
"send": "Send",
"stop": "停止位元",
"save": "儲存",
"cancel": "取消",
"running": "執行中",
"approve": "Approve",
"reject": "Reject",
"inputPlaceholder": "Ask about your servers, or describe a change to propose",
"selectProvider": "Select a provider",
"providerSettings": "Provider settings",
"addProvider": "Add provider",
"editProvider": "Edit provider",
"removeProvider": "Remove provider",
"providerType": "Provider",
"providerLabel": "名稱",
"providerLabelPlaceholder": "For example: Local Ollama",
"baseUrl": "Server address",
"apiKey": "API key",
"defaultModel": "Default model",
"defaultModelPlaceholder": "For example: llama3.1 or claude-opus-5",
"labelRequired": "Give this provider a name",
"providerSaveFailed": "Could not save the provider",
"providerUpdated": "Provider updated",
"providerDeleteFailed": "Could not remove the provider",
"privateEndpointHint": "Addresses on your private network need an administrator to allow the host first.",
"providerOllama": "Ollama",
"providerAnthropic": "Anthropic",
"providerOpenai": "OpenAI",
"providerGemini": "Google Gemini",
"providerOpenaiCompatible": "OpenAI compatible",
"toolArguments": "輸入",
"toolResult": "Result",
"statusApplied": "Applied",
"statusRejected": "Rejected",
"proposalApplied": "Change applied",
"proposalApplyFailed": "Could not apply the change",
"proposalRejectFailed": "Could not reject the change",
"enableTitle": "Enable the AI assistant",
"enableDescription": "Let the assistant read your setup and suggest changes for you to approve.",
"readOnlyCommands": "Allow read-only diagnostic commands",
"readOnlyCommandsDescription": "The assistant may run a short list of safe commands like df and uptime without asking each time. Anything that changes a server is always proposed first.",
"modelDetectFailed": "Could not reach the provider, so this is a general list. You can type any model name.",
"modelCustom": "Enter a model name",
"modelRefresh": "Refresh list",
"modelPlaceholder": "Choose or type a model",
"attachHint": "Type @ to reference a host, snippet or automation. Changes are proposed for you to approve.",
"runCommandTitle": "Run command",
"assistant": "AI Assistant",
"terminalPanelEmpty": "Ask the assistant about this host. Anything that changes state needs your approval first.",
"terminalPanelPlaceholder": "Ask about this host...",
"noProvidersConfigured": "No AI provider is configured yet. Add one in the AI assistant tab.",
"runsInOpenTerminal": "Runs in your open terminal session.",
"runInTerminal": "執行",
"terminalContextPrefill": "Here is my terminal output:\n\n{{output}}\n\n",
"secrets": {
"shareApiKey": "Let other features use your AI provider API key for their own requests. The key is never copied, and you can revoke this at any time."
}
},
"nav": {
"ai": "AI"
},
"onboarding": {
"aiTitle": "AI assistant",
"aiIntro": "Termix can include an optional AI assistant. You can change this later in your settings.",
"aiSkipTitle": "Leave it off",
"aiSkipDesc": "Termix works as it does now. The assistant stays hidden.",
"aiEnableTitle": "Turn it on",
"aiEnableDesc": "Adds an assistant that reads your setup and suggests changes you approve. Needs a provider you supply, such as a local Ollama.",
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
},
"hosts": {
"docsLink": "檢視文件"
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"id": "ai",
"name": "AI Assistant",
"version": "1.0.0",
"description": "An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and suggest changes for you to approve.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Productivity",
"icon": "Sparkles",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"secrets:own",
"network:outbound",
"notify:send",
"network:serve",
"ui:surface",
"users:impersonate"
],
"optionalDependencies": {
"workspaces": "^1.0.0",
"network-topology": "^1.0.0",
"snippets": "^1.0.0",
"fleets": "^1.0.0",
"ssh-terminal": "^1.0.0",
"automations": "^1.0.0",
"homepage": "^1.0.0"
},
"requires": [
{
"service": "workspaces.saved",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "network-topology.graph",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "snippets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "terminal.history",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "automations.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "homepage.items",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
{
"key": "api-key",
"permission": "ai.secrets.share",
"descriptionKey": "ai.secrets.shareApiKey"
}
],
"contributes": {
"tabs": [
{
"id": "ai",
"titleKey": "nav.ai",
"icon": "Sparkles",
"openFrom": ["rail", "palette"]
}
],
"actions": [
{
"id": "ai.openWithContext",
"titleKey": "ai.assistant",
"handler": "openWithContext",
"icon": "Bot",
"permission": "ai.services.use",
"slot": "terminal.toolbar",
"kind": "button"
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["user"]
},
{
"name": "manage_providers",
"titleKey": "permissions.manage_providers.title",
"descriptionKey": "permissions.manage_providers.description",
"defaultRoles": ["user"]
},
{
"name": "apply_proposals",
"titleKey": "permissions.apply_proposals.title",
"descriptionKey": "permissions.apply_proposals.description",
"defaultRoles": ["user"]
},
{
"name": "services.use",
"titleKey": "permissions.services.use.title",
"descriptionKey": "permissions.services.use.description",
"defaultRoles": ["user"]
},
{
"name": "secrets.share",
"titleKey": "permissions.secrets.share.title",
"descriptionKey": "permissions.secrets.share.description",
"defaultRoles": ["user"]
}
],
"settings": {
"admin": [
{
"key": "globallyEnabled",
"type": "boolean",
"labelKey": "settings.admin.globallyEnabled.label",
"descriptionKey": "settings.admin.globallyEnabled.description",
"default": false
},
{
"key": "privateEndpoints",
"type": "textarea",
"labelKey": "settings.admin.privateEndpoints.label",
"descriptionKey": "settings.admin.privateEndpoints.description",
"requires": "globallyEnabled",
"default": "localhost\n127.0.0.1\n::1\nhost.docker.internal"
}
],
"user": [
{
"key": "enabled",
"type": "boolean",
"labelKey": "ai.enableTitle",
"descriptionKey": "ai.enableDescription",
"default": false
},
{
"key": "allowReadOnlyCommands",
"type": "boolean",
"labelKey": "ai.readOnlyCommands",
"descriptionKey": "ai.readOnlyCommandsDescription",
"requires": "enabled",
"default": false
},
{
"key": "providers",
"type": "custom",
"component": "providers",
"permission": "manage_providers"
}
],
"host": {
"editorGroup": "ssh",
"editorOrder": 90,
"fields": [
{
"key": "enableAiAssistant",
"type": "boolean",
"labelKey": "settings.host.enableAiAssistant.label",
"descriptionKey": "settings.host.enableAiAssistant.description",
"default": false
}
]
}
},
"uses": ["terminal.sidePanel", "terminal.toolbar"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,55 @@
-- ai 0001: adopt_ai_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign key from messages and proposals to
-- their conversation, which the SDK cannot declare.
CREATE TABLE IF NOT EXISTS `ai_providers` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`provider_type` text NOT NULL,
`label` varchar(255) NOT NULL,
`base_url` text,
`api_key_prefix` text,
`default_model` text,
`enabled` boolean NOT NULL DEFAULT true,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `ai_providers` TO `p_ai_providers`;
CREATE TABLE IF NOT EXISTS `ai_conversations` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`title` text,
`provider_id` int,
`model` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `ai_conversations` TO `p_ai_conversations`;
CREATE TABLE IF NOT EXISTS `ai_messages` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`conversation_id` int NOT NULL,
`role` text NOT NULL,
`content` text NOT NULL DEFAULT (''),
`tool_calls` text,
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `ai_messages` TO `p_ai_messages`;
CREATE TABLE IF NOT EXISTS `ai_proposals` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`conversation_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`kind` varchar(255) NOT NULL,
`summary` text,
`payload` text NOT NULL DEFAULT ('{}'),
`status` varchar(32) NOT NULL DEFAULT 'pending',
`applied_at` text,
`result_summary` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`conversation_id`) REFERENCES `p_ai_conversations` (`id`) ON DELETE CASCADE
);
RENAME TABLE `ai_proposals` TO `p_ai_proposals`;
@@ -0,0 +1 @@
ALTER TABLE `p_ai_providers` ADD COLUMN `sync_id` varchar(255) UNIQUE;
@@ -0,0 +1,60 @@
-- ai 0001: adopt_ai_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign key from messages and proposals to
-- their conversation, which the SDK cannot declare.
CREATE TABLE IF NOT EXISTS "ai_providers" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"provider_type" text NOT NULL,
"label" varchar(255) NOT NULL,
"base_url" text,
"api_key_prefix" text,
"default_model" text,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_providers_user_label" ON "ai_providers" ("user_id", "label");
ALTER TABLE "ai_providers" RENAME TO "p_ai_providers";
CREATE TABLE IF NOT EXISTS "ai_conversations" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"title" text,
"provider_id" integer,
"model" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_conversations_user" ON "ai_conversations" ("user_id", "updated_at");
ALTER TABLE "ai_conversations" RENAME TO "p_ai_conversations";
CREATE TABLE IF NOT EXISTS "ai_messages" (
"id" serial PRIMARY KEY,
"conversation_id" integer NOT NULL,
"role" text NOT NULL,
"content" text NOT NULL DEFAULT '',
"tool_calls" text,
"created_at" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_messages_conversation" ON "ai_messages" ("conversation_id", "created_at");
ALTER TABLE "ai_messages" RENAME TO "p_ai_messages";
CREATE TABLE IF NOT EXISTS "ai_proposals" (
"id" serial PRIMARY KEY,
"conversation_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"kind" varchar(255) NOT NULL,
"summary" text,
"payload" text NOT NULL DEFAULT '{}',
"status" varchar(32) NOT NULL DEFAULT 'pending',
"applied_at" text,
"result_summary" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", "status");
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
@@ -0,0 +1 @@
ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" varchar(255) UNIQUE;
+196
View File
@@ -0,0 +1,196 @@
{
"version": 1,
"tables": {
"providers": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"providerType": {
"type": "text",
"notNull": true
},
"label": {
"type": "varchar",
"length": 255,
"notNull": true
},
"baseUrl": {
"type": "text"
},
"apiKeyPrefix": {
"type": "text"
},
"defaultModel": {
"type": "text"
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_ai_providers_user_label",
"columns": ["userId", "label"],
"unique": true
}
],
"adopts": "ai_providers"
},
"conversations": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"title": {
"type": "text"
},
"providerId": {
"type": "integer"
},
"model": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_ai_conversations_user",
"columns": ["userId", "updatedAt"]
}
],
"adopts": "ai_conversations"
},
"messages": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"conversationId": {
"type": "integer",
"notNull": true
},
"role": {
"type": "text",
"notNull": true
},
"content": {
"type": "text",
"notNull": true,
"defaultValue": ""
},
"toolCalls": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_ai_messages_conversation",
"columns": ["conversationId", "createdAt"]
}
],
"adopts": "ai_messages"
},
"proposals": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"conversationId": {
"type": "integer",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"kind": {
"type": "varchar",
"length": 255,
"notNull": true
},
"summary": {
"type": "text"
},
"payload": {
"type": "text",
"notNull": true,
"defaultValue": "{}"
},
"status": {
"type": "varchar",
"length": 32,
"notNull": true,
"defaultValue": "pending"
},
"appliedAt": {
"type": "text"
},
"resultSummary": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_ai_proposals_user",
"columns": ["userId", "status"]
},
{
"name": "idx_ai_proposals_conversation",
"columns": ["conversationId"]
}
],
"adopts": "ai_proposals"
}
}
}
@@ -0,0 +1,60 @@
-- ai 0001: adopt_ai_tables
-- Generated by termix-plugin migrations. Review before committing.
-- Hand-edited: keeps the legacy foreign key from messages and proposals to
-- their conversation, which the SDK cannot declare.
CREATE TABLE IF NOT EXISTS "ai_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"provider_type" text NOT NULL,
"label" text NOT NULL,
"base_url" text,
"api_key_prefix" text,
"default_model" text,
"enabled" integer NOT NULL DEFAULT 1,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_providers_user_label" ON "ai_providers" ("user_id", "label");
ALTER TABLE "ai_providers" RENAME TO "p_ai_providers";
CREATE TABLE IF NOT EXISTS "ai_conversations" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"title" text,
"provider_id" integer,
"model" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_conversations_user" ON "ai_conversations" ("user_id", "updated_at");
ALTER TABLE "ai_conversations" RENAME TO "p_ai_conversations";
CREATE TABLE IF NOT EXISTS "ai_messages" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"conversation_id" integer NOT NULL,
"role" text NOT NULL,
"content" text NOT NULL DEFAULT '',
"tool_calls" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_messages_conversation" ON "ai_messages" ("conversation_id", "created_at");
ALTER TABLE "ai_messages" RENAME TO "p_ai_messages";
CREATE TABLE IF NOT EXISTS "ai_proposals" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"conversation_id" integer NOT NULL,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"summary" text,
"payload" text NOT NULL DEFAULT '{}',
"status" text NOT NULL DEFAULT 'pending',
"applied_at" text,
"result_summary" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("conversation_id") REFERENCES "p_ai_conversations" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", "status");
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
@@ -0,0 +1,2 @@
ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" text;
CREATE UNIQUE INDEX "p_ai_providers_sync_id_unique" ON "p_ai_providers" ("sync_id");
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@termix-plugin/ai",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"lucide-react": "^1.33.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* The system prompt.
*
* Deliberately small: the assistant starts with almost no context and must call
* a read tool to learn anything. That keeps token cost predictable, makes every
* data access visible in the transcript, and means nothing is sent to a
* third-party provider that the user did not implicitly ask for.
*/
export function buildSystemPrompt(options: {
hostCount: number;
activeTab?: string | null;
allowReadOnlyCommands: boolean;
}): string {
const lines: string[] = [
"You are the assistant built into Termix, a self-hosted server management app.",
"You help the user manage their servers, snippets, automations and fleets.",
"",
"How you work:",
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
"- You cannot change anything directly. To make a change, call a propose_* tool; the user sees a card and approves or rejects it.",
"- You have no access to passwords, SSH keys, API keys or any other credential, and you never ask the user to paste one into the chat.",
"",
"Scope:",
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
"- Do not propose follow-up work you thought of yourself: no monitoring automations, no scripts, no snippets, no cleanup, unless that is what was requested.",
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
"- One request means one proposal at most. Do not bundle extras alongside it.",
"",
"How to answer:",
"- Lead with the answer. Keep responses short and concrete.",
"- When you propose something, say in one line what it does and why.",
"- If a request is ambiguous in a way that changes what you would propose, ask before proposing.",
"- Never claim you have done something. You propose; the user applies.",
];
if (options.allowReadOnlyCommands) {
lines.push(
"- The user has allowed you to run read-only diagnostic commands directly. Anything that changes state still has to be proposed.",
);
}
lines.push(
"",
"Current context:",
`- The user has ${options.hostCount} host${options.hostCount === 1 ? "" : "s"} configured.`,
);
if (options.activeTab) {
lines.push(`- They are currently looking at: ${options.activeTab}.`);
}
return lines.join("\n");
}
+134
View File
@@ -0,0 +1,134 @@
import { BlockList, isIP } from "net";
/**
* Where the assistant is allowed to send requests.
*
* Every provider request goes through ctx.fetch, which refuses private and
* loopback addresses (after DNS, too) unless the exact host is listed. That
* guard is exactly what a self-hosted Ollama on localhost trips over, so the
* admin setting "privateEndpoints" names the hosts that may be private.
* Without that split, any logged-in user could point a "provider" at an
* internal service and use the backend as an authenticated probe of the
* server's own network.
*
* The checks here only exist to give a useful message before the request is
* made. ctx.fetch is the control.
*/
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
];
export const PRIVATE_DESTINATION_MESSAGE =
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.";
/** A bare host, not a URL: no scheme, path, port or whitespace. */
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
/**
* The admin setting is one host per line. Anything that is not a bare host
* is ignored rather than failing the whole list.
*/
export function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
const hosts: string[] = [];
for (const line of raw.split(/[\r\n,]+/)) {
const host = line.trim().toLowerCase();
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
hosts.push(host);
}
}
return hosts;
}
const PRIVATE_RANGES = (() => {
const list = new BlockList();
list.addSubnet("0.0.0.0", 8, "ipv4");
list.addSubnet("10.0.0.0", 8, "ipv4");
list.addSubnet("100.64.0.0", 10, "ipv4");
list.addSubnet("127.0.0.0", 8, "ipv4");
list.addSubnet("169.254.0.0", 16, "ipv4");
list.addSubnet("172.16.0.0", 12, "ipv4");
list.addSubnet("192.168.0.0", 16, "ipv4");
list.addAddress("::", "ipv6");
list.addAddress("::1", "ipv6");
list.addSubnet("fc00::", 7, "ipv6");
list.addSubnet("fe80::", 10, "ipv6");
return list;
})();
function normalizeHost(hostname: string): string {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
/**
* True when the URL names a destination the outbound guard would refuse. A
* bare hostname that is not an IP literal (e.g. "ollama.internal") counts as
* private only if it is "localhost"; anything else needs DNS resolution,
* which ctx.fetch does.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return false;
}
const host = normalizeHost(url.hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
const family = isIP(host);
if (family === 4) return PRIVATE_RANGES.check(host, "ipv4");
if (family === 6) return PRIVATE_RANGES.check(host, "ipv6");
return false;
}
export interface EgressDecision {
allowed: boolean;
/** True when the destination is an allowlisted private host. */
isPrivate: boolean;
reason?: string;
}
export function evaluateEgress(
rawUrl: string,
allowlist: string[],
): EgressDecision {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
}
if (!["http:", "https:"].includes(url.protocol)) {
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
}
if (url.username || url.password) {
return {
allowed: false,
isPrivate: false,
reason: "Credentials in URL are not allowed",
};
}
const host = normalizeHost(url.hostname);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An allowlisted hostname may resolve to a private address; ctx.fetch
// lets it through because the same list is passed as allowPrivateHosts.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false };
return {
allowed: false,
isPrivate: true,
reason: PRIVATE_DESTINATION_MESSAGE,
};
}
+175
View File
@@ -0,0 +1,175 @@
import { getErrorMessage } from "./errors.js";
import { describeProviderError } from "./providers/http.js";
import { getAdapter } from "./providers/registry.js";
import type {
ChatMessage,
ProviderConfig,
ToolCall,
} from "./providers/types.js";
import { redact, redactToJson } from "./redaction.js";
import { toolDefinitions } from "./tools/catalog.js";
import {
isProposalDraft,
type AiTool,
type ProposalDraft,
type ToolContext,
} from "./tools/types.js";
/**
* The agent loop: stream a turn, run any tools the model asked for, feed the
* results back, repeat. Bounded so a model that keeps calling tools cannot spin
* forever.
*/
const MAX_TURNS = 8;
export type EngineEvent =
| { type: "token"; text: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; result: unknown }
| { type: "proposal"; draft: ProposalDraft }
/** A message added to the conversation, for the caller to store. */
| { type: "message"; message: ChatMessage }
| { type: "done" }
| { type: "error"; message: string };
export interface EngineOptions {
config: ProviderConfig;
model: string;
system: string;
history: ChatMessage[];
context: ToolContext;
/** What the model may call this turn; availableTools() decides. */
tools: AiTool[];
signal?: AbortSignal;
}
export async function* runAgent(
options: EngineOptions,
): AsyncGenerator<EngineEvent> {
const adapter = getAdapter(options.config.providerType);
const tools = toolDefinitions(options.tools);
const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
const messages: ChatMessage[] = [...options.history];
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
let text = "";
const calls: ToolCall[] = [];
let failed = false;
try {
for await (const chunk of adapter.streamChat(options.config, {
model: options.model,
system: options.system,
messages,
tools,
signal: options.signal,
})) {
if (chunk.type === "text") {
text += chunk.text;
yield { type: "token", text: chunk.text };
} else if (chunk.type === "tool_call") {
calls.push(chunk.call);
} else if (chunk.type === "error") {
failed = true;
yield { type: "error", message: chunk.message };
}
}
} catch (error) {
// The user stopped it; there is no one left to show an error to.
if (options.signal?.aborted) return;
const message = describeProviderError(
error,
"The provider request failed",
);
yield { type: "error", message };
return;
}
if (failed) return;
if (!calls.length) {
yield { type: "message", message: { role: "assistant", content: text } };
yield { type: "done" };
return;
}
const assistant: ChatMessage = {
role: "assistant",
content: text,
toolCalls: calls,
};
messages.push(assistant);
yield { type: "message", message: assistant };
for (const call of calls) {
if (options.signal?.aborted) return;
yield { type: "tool_call", name: call.name, arguments: call.arguments };
const result = await runTool(byName, call, options.context);
if (isProposalDraft(result)) {
// Closes the tool call before the proposal card is emitted. Without
// this the call has no matching result and renders as permanently
// running, even though the work is done and awaiting the user.
yield {
type: "tool_result",
name: call.name,
result: { status: "awaiting_user_approval" },
};
yield { type: "proposal", draft: result };
// The model is told the proposal is awaiting the user rather than done,
// so it does not go on to describe the change as applied.
const pending: ChatMessage = {
role: "tool",
content: JSON.stringify({
status: "awaiting_user_approval",
summary: result.summary,
}),
toolCallId: call.id,
toolName: call.name,
};
messages.push(pending);
yield { type: "message", message: pending };
continue;
}
yield { type: "tool_result", name: call.name, result: redact(result) };
const answer: ChatMessage = {
role: "tool",
content: redactToJson(result),
toolCallId: call.id,
toolName: call.name,
};
messages.push(answer);
yield { type: "message", message: answer };
}
}
// Ran out of turns with the model still calling tools.
yield {
type: "error",
message: "The assistant used too many steps without finishing.",
};
}
async function runTool(
tools: Map<string, AiTool>,
call: ToolCall,
context: ToolContext,
): Promise<unknown> {
const tool = tools.get(call.name);
// A model can emit any name it likes; only what was offered runs.
if (!tool) {
return { error: `Unknown tool: ${call.name}` };
}
try {
return await tool.handler(call.arguments ?? {}, context);
} catch (error) {
return {
error: getErrorMessage(error, "The tool failed"),
};
}
}
+6
View File
@@ -0,0 +1,6 @@
/** The message of an error, or the fallback when there is none worth showing. */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
+90
View File
@@ -0,0 +1,90 @@
import type { PluginSettings } from "@termix/plugin-sdk/backend";
import { DEFAULT_PRIVATE_ALLOWLIST, parseAllowlist } from "./egress.js";
/**
* Three gates, all checked on the server.
*
* The admin switch is a hard kill switch: when it is off the feature does not
* exist for anyone, regardless of what any user has enabled. It defaults to
* false so upgrading an existing install turns nothing on by surprise. Then
* each user opts in for themselves, and RBAC decides what they may do.
*/
export type SettingsReader = Pick<PluginSettings, "get" | "getUser">;
export async function isAiGloballyEnabled(
settings: SettingsReader,
): Promise<boolean> {
return (await settings.get<boolean>("globallyEnabled")) === true;
}
/** The admin's private endpoint hosts, one per line. */
export async function readPrivateAllowlist(
settings: SettingsReader,
): Promise<string[]> {
const raw = await settings.get<string>("privateEndpoints");
if (raw === undefined || raw === null) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parseAllowlist(raw);
}
export interface AiAccess {
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function resolveAiAccess(
settings: SettingsReader,
userId: string,
): Promise<AiAccess> {
if (!(await isAiGloballyEnabled(settings))) {
return { enabled: false, allowReadOnlyCommands: false };
}
// Unset means the user was never asked, which is not consent.
const enabled = (await settings.getUser<boolean>(userId, "enabled")) === true;
return {
enabled,
allowReadOnlyCommands:
enabled &&
(await settings.getUser<boolean>(userId, "allowReadOnlyCommands")) ===
true,
};
}
interface GateRequest {
aiAccess?: AiAccess;
}
interface GateResponse {
status: (code: number) => { json: (body: unknown) => unknown };
}
/** Rejects any AI request unless both gates are open. */
export function createAiGate(
settings: SettingsReader,
currentActor: () => string | undefined,
) {
return async (
req: GateRequest,
res: GateResponse,
next: (error?: unknown) => void,
): Promise<void> => {
const userId = currentActor();
if (!userId) {
res.status(401).json({ error: "Authentication required" });
return;
}
try {
const access = await resolveAiAccess(settings, userId);
if (!access.enabled) {
res.status(403).json({ error: "The AI assistant is not enabled" });
return;
}
req.aiAccess = access;
next();
} catch (error) {
next(error);
}
};
}
+98
View File
@@ -0,0 +1,98 @@
import type { ChatMessage, ToolCall } from "./providers/types.js";
export interface StoredMessage {
role: string;
content: string;
toolCalls: string | null;
}
const INTERRUPTED_RESULT = JSON.stringify({
error: "No result: the reply was interrupted before this tool finished.",
});
function parseCalls(raw: string | null): ToolCall[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** A tool row keeps the call it answers in toolCalls, as a one-item list. */
export function toStoredMessage(message: ChatMessage): StoredMessage {
if (message.role === "tool") {
return {
role: "tool",
content: message.content,
toolCalls: JSON.stringify([
{ id: message.toolCallId, name: message.toolName },
]),
};
}
return {
role: message.role,
content: message.content,
toolCalls: message.toolCalls?.length
? JSON.stringify(message.toolCalls)
: null,
};
}
/**
* Rebuilds what the provider sees from the stored rows.
*
* Every provider rejects an assistant turn whose tool calls have no results,
* which is what a run cut off mid-way leaves behind, so any missing result is
* filled in as interrupted. Tool rows that answer nothing are dropped.
*/
export function toChatHistory(rows: StoredMessage[]): ChatMessage[] {
const history: ChatMessage[] = [];
let open: ToolCall[] = [];
const closeOpen = () => {
for (const call of open) {
history.push({
role: "tool",
content: INTERRUPTED_RESULT,
toolCallId: call.id,
toolName: call.name,
});
}
open = [];
};
for (const row of rows) {
if (row.role === "tool") {
const answered = parseCalls(row.toolCalls)[0];
const index = open.findIndex((call) => call.id === answered?.id);
if (index === -1) continue;
const [call] = open.splice(index, 1);
history.push({
role: "tool",
content: row.content,
toolCallId: call.id,
toolName: call.name,
});
continue;
}
closeOpen();
if (row.role === "assistant") {
const calls = parseCalls(row.toolCalls);
history.push({
role: "assistant",
content: row.content,
...(calls.length ? { toolCalls: calls } : {}),
});
open = [...calls];
} else if (row.role === "user") {
history.push({ role: "user", content: row.content });
}
}
closeOpen();
return history;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Registered as "ai.hostImportNormalizer" so core's host import can carry the
* assistant switch without knowing it exists. Reads a Termix export's
* pluginSettings.ai first, then the flat field hosts had before 2.9.0.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const pluginSettings = raw.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined;
const value = pluginSettings?.ai?.enableAiAssistant ?? raw.enableAiAssistant;
if (value === undefined || value === null) return null;
return {
enableAiAssistant: value === true || value === 1 || value === "true",
};
}
+40
View File
@@ -0,0 +1,40 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { hostImportNormalizer } from "./host-import.js";
import { createAiRepository } from "./repository.js";
import { registerProviderSync } from "./sync.js";
import { registerAiRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const repository = await createAiRepository(ctx);
await registerProviderSync(ctx, repository);
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
ctx.registry.provide("ai.hostImportNormalizer", hostImportNormalizer);
ctx.disposables.add(
() =>
void ctx.registry.revoke("ai.hostImportNormalizer", hostImportNormalizer),
);
// Offered by reference, not copied: this hands over a resolver, so the key
// stays ours. Rotating or deleting the provider takes effect on the next
// read by any borrower, and deactivate withdraws the offer. Keys are per
// user, so a borrowed read returns the acting user's own key.
ctx.secrets.offer("api-key", (userId: string) =>
ctx.asUser(userId, async () => {
const providers = await repository.listProviders(userId);
const preferred = providers.find((provider) => provider.apiKeyPrefix);
if (!preferred) return null;
const withSecret = await repository.findProviderWithSecret(
preferred.id,
userId,
);
return withSecret?.apiKey ?? null;
}),
);
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
}
export async function deactivate() {}
@@ -0,0 +1,161 @@
import Anthropic from "@anthropic-ai/sdk";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
/**
* Model ids offered in the picker. Users can type any other id; this is a
* convenience list, not a restriction.
*/
export const ANTHROPIC_MODELS = [
"claude-opus-5",
"claude-sonnet-5",
"claude-haiku-4-5",
];
function createClient(config: ProviderConfig): Anthropic {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
return new Anthropic({
apiKey: config.apiKey,
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
// Routes the SDK's HTTP through the shared egress guard.
fetch: config.fetch as unknown as typeof fetch,
});
}
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
const messages: Anthropic.MessageParam[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: message.toolCallId ?? "",
content: message.content,
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const content: Anthropic.ContentBlockParam[] = [];
if (message.content)
content.push({ type: "text", text: message.content });
for (const call of message.toolCalls) {
content.push({
type: "tool_use",
id: call.id,
name: call.name,
input: call.arguments,
});
}
messages.push({ role: "assistant", content });
continue;
}
if (message.role === "system") continue;
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* The SDK throws its own typed errors rather than going through assertOk, so
* they are translated here to match what every other provider reports.
*/
function translateSdkError(error: unknown): never {
const status =
typeof (error as { status?: unknown })?.status === "number"
? (error as { status: number }).status
: undefined;
const detail = error instanceof Error ? error.message : String(error);
if (status === 429) {
throw new AiProviderError(
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
429,
);
}
if (status === 401 || status === 403) {
throw new AiProviderError(
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
status,
);
}
throw new AiProviderError(
status ? `Anthropic request failed (${status}): ${detail}` : detail,
status,
);
}
export const anthropicAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const client = createClient(config);
const stream = client.messages.stream({
model: request.model,
max_tokens: 16000,
system: request.system,
messages: toAnthropicMessages(request),
thinking: { type: "adaptive" },
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
})),
}
: {}),
...(request.signal ? { signal: request.signal } : {}),
});
let final: Anthropic.Message;
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
yield { type: "text", text: event.delta.text };
}
}
final = await stream.finalMessage();
} catch (error) {
translateSdkError(error);
}
for (const block of final.content) {
if (block.type === "tool_use") {
yield {
type: "tool_call",
call: {
id: block.id,
name: block.name,
arguments: (block.input ?? {}) as Record<string, unknown>,
},
};
}
}
yield { type: "done", stopReason: final.stop_reason ?? undefined };
},
async listModels(): Promise<string[]> {
return [...ANTHROPIC_MODELS];
},
};
+191
View File
@@ -0,0 +1,191 @@
import { assertOk, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
const GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || GEMINI_DEFAULT_BASE;
}
/**
* Gemini has no tool role: a tool result is a user-side functionResponse part,
* and an assistant tool request is a model-side functionCall part.
*/
function toGeminiContents(request: ChatRequest): unknown[] {
const contents: unknown[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: message.toolName ?? "tool",
response: { result: message.content },
},
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const parts: unknown[] = [];
if (message.content) parts.push({ text: message.content });
for (const call of message.toolCalls) {
// thoughtSignature must be returned exactly as received or Gemini
// 2.5+ rejects the turn with a 400.
parts.push({
functionCall: { name: call.name, args: call.arguments },
...(call.providerSignature
? { thoughtSignature: call.providerSignature }
: {}),
});
}
contents.push({ role: "model", parts });
continue;
}
if (message.role === "system") continue;
contents.push({
role: message.role === "assistant" ? "model" : "user",
parts: [{ text: message.content }],
});
}
return contents;
}
/**
* Gemini rejects the JSON Schema keywords it does not implement, so the tool
* schemas are trimmed to the subset it accepts.
*/
function toGeminiSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
const source = schema as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key === "additionalProperties" || key === "$schema") continue;
if (key === "properties" && value && typeof value === "object") {
const properties: Record<string, unknown> = {};
for (const [name, child] of Object.entries(
value as Record<string, unknown>,
)) {
properties[name] = toGeminiSchema(child);
}
output[key] = properties;
continue;
}
output[key] = toGeminiSchema(value);
}
return output;
}
export const geminiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
const response = await config.fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": config.apiKey,
},
signal: request.signal,
body: JSON.stringify({
systemInstruction: { parts: [{ text: request.system }] },
contents: toGeminiContents(request),
...(request.tools.length
? {
tools: [
{
functionDeclarations: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toGeminiSchema(tool.parameters),
})),
},
],
}
: {}),
}),
});
await assertOk(response, "Gemini");
let index = 0;
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const candidate = frame.candidates?.[0];
if (!candidate) continue;
if (candidate.finishReason) stopReason = candidate.finishReason;
for (const part of candidate.content?.parts ?? []) {
if (typeof part.text === "string" && part.text) {
yield { type: "text", text: part.text };
}
if (part.functionCall?.name) {
yield {
type: "tool_call",
call: {
id: `call_${part.functionCall.name}_${index++}`,
name: part.functionCall.name,
arguments: (part.functionCall.args ?? {}) as Record<
string,
unknown
>,
// Carried so the next turn can echo it back; without it Gemini
// 400s as soon as a tool has been used once.
providerSignature: part.thoughtSignature,
},
};
}
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
if (!config.apiKey) return [];
const response = await config.fetch(
`${baseFor(config).replace(/\/+$/, "")}/models`,
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
);
await assertOk(response, "Gemini");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => String(entry.name ?? "").replace(/^models\//, ""))
.filter((name: string) => name.length > 0)
.sort();
},
};
+257
View File
@@ -0,0 +1,257 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
import { evaluateEgress, PRIVATE_DESTINATION_MESSAGE } from "../egress.js";
import { AiProviderError, type ProviderFetch } from "./types.js";
/** A model can take a while to load before the first token. */
const PROVIDER_TIMEOUT_MS = 10 * 60 * 1000;
function toHeaderRecord(
headers: RequestInit["headers"] | undefined,
): Record<string, string> {
const record: Record<string, string> = {};
new Headers(headers ?? {}).forEach((value, key) => {
record[key] = value;
});
return record;
}
/**
* Every outbound provider request goes through here so the egress rules cannot
* be bypassed by an adapter calling fetch directly. The actual request is
* ctx.fetch, which pins DNS and refuses private addresses unless the host is
* on the admin allowlist.
*/
export function createProviderFetch(
fetch: PluginFetch,
allowlist: string[],
): ProviderFetch {
return async (url, init) => {
const decision = evaluateEgress(url, allowlist);
if (!decision.allowed) {
throw new AiProviderError(decision.reason ?? "Destination not allowed");
}
if (
init.body !== undefined &&
init.body !== null &&
typeof init.body !== "string"
) {
throw new AiProviderError("Provider requests must send a text body");
}
try {
return await fetch(url, {
method: init.method ?? "GET",
headers: toHeaderRecord(init.headers),
body: (init.body as string | null | undefined) ?? undefined,
signal: init.signal ?? undefined,
timeoutMs: PROVIDER_TIMEOUT_MS,
allowPrivateHosts: allowlist,
});
} catch (error) {
// A hostname that resolved to a private address is refused after DNS,
// which the check above cannot see coming.
const message = error instanceof Error ? error.message : "";
if (/private destinations/i.test(message)) {
throw new AiProviderError(PRIVATE_DESTINATION_MESSAGE);
}
throw error;
}
};
}
const NETWORK_MESSAGES: Record<string, string> = {
UND_ERR_BODY_TIMEOUT:
"The provider stopped sending data for too long and the reply was cut off. A slow or overloaded model can cause this.",
UND_ERR_SOCKET:
"The provider closed the connection before the reply finished.",
ECONNRESET: "The provider closed the connection before the reply finished.",
UND_ERR_HEADERS_TIMEOUT: "The provider did not answer in time.",
UND_ERR_CONNECT_TIMEOUT:
"Timed out connecting to the provider. Check the base URL.",
ETIMEDOUT: "Timed out connecting to the provider. Check the base URL.",
ECONNREFUSED:
"The provider refused the connection. Check the base URL and that the provider is running.",
ENOTFOUND: "The provider's host name could not be found. Check the base URL.",
EAI_AGAIN: "The provider's host name could not be found. Check the base URL.",
EHOSTUNREACH: "The provider's host is unreachable from this server.",
ENETUNREACH: "The provider's host is unreachable from this server.",
};
/**
* undici reports network failures as a bare "fetch failed" or "terminated"
* with the real reason on `cause`, which is useless to show as is.
*/
export function describeProviderError(
error: unknown,
fallback: string,
): string {
if (error instanceof AiProviderError) return error.message;
if (!(error instanceof Error)) {
return typeof error === "string" && error ? error : fallback;
}
const cause = (error as { cause?: unknown }).cause as
(Error & { code?: string }) | undefined;
const code = cause?.code ?? (error as { code?: string }).code;
if (code && NETWORK_MESSAGES[code]) return NETWORK_MESSAGES[code];
const causeMessage = cause instanceof Error ? cause.message : "";
if (/private destinations/i.test(causeMessage)) {
return PRIVATE_DESTINATION_MESSAGE;
}
if (code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/i.test(code)) {
return `The provider's TLS certificate is not trusted (${code}).`;
}
if (/^(fetch failed|terminated)$/i.test(error.message)) {
if (causeMessage) return `The provider request failed: ${causeMessage}`;
return error.message === "terminated"
? NETWORK_MESSAGES.UND_ERR_SOCKET
: fallback;
}
return error.message || fallback;
}
export function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/**
* Yields the data payload of each SSE frame. Providers differ in what they put
* inside, so parsing the JSON is left to the caller.
*/
export async function* readSseLines(
response: Response,
): AsyncGenerator<string> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
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);
if (line.startsWith("data:")) {
yield line.slice(5).trim();
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/** Yields one parsed JSON object per line, for newline-delimited streams. */
export async function* readJsonLines(
response: Response,
): AsyncGenerator<unknown> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
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);
if (line) {
try {
yield JSON.parse(line);
} catch {
// A partial or malformed frame is skipped rather than failing the
// whole stream.
}
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/**
* Digs the human-readable message out of an error body.
*
* Every provider nests it differently, and dumping the raw JSON produced
* something that got cut off mid-sentence. Falls back to a trimmed snippet
* when the shape is unfamiliar.
*/
function extractProviderMessage(body: string): string {
try {
const parsed = JSON.parse(body);
const message =
parsed?.error?.message ??
parsed?.error?.["message"] ??
parsed?.message ??
(typeof parsed?.error === "string" ? parsed.error : null);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
} catch {
// Not JSON; fall through to the snippet.
}
const trimmed = body.trim();
if (!trimmed) return "";
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
}
export async function assertOk(
response: Response,
provider: string,
): Promise<void> {
if (response.ok) return;
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = extractProviderMessage(body);
// Rate limits and auth failures are the two the user can actually act on,
// so they say what to do instead of reading like an internal failure.
if (response.status === 429) {
throw new AiProviderError(
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
detail ? ` (${detail})` : ""
}`,
429,
);
}
if (response.status === 401 || response.status === 403) {
throw new AiProviderError(
`${provider} rejected the API key. Check that it is correct and still active.${
detail ? ` (${detail})` : ""
}`,
response.status,
);
}
throw new AiProviderError(
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
response.status,
);
}
+131
View File
@@ -0,0 +1,131 @@
import { assertOk, joinUrl, readJsonLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
export const OLLAMA_DEFAULT_BASE = "http://localhost:11434";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || OLLAMA_DEFAULT_BASE;
}
function toOllamaMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
content: message.content,
...(message.toolName ? { tool_name: message.toolName } : {}),
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content,
tool_calls: message.toolCalls.map((call) => ({
function: { name: call.name, arguments: call.arguments },
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
export const ollamaAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOllamaMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "Ollama");
let index = 0;
let stopReason: string | undefined;
// Ollama streams newline-delimited JSON rather than SSE.
for await (const frame of readJsonLines(response)) {
const payload = frame as any;
if (
typeof payload.message?.content === "string" &&
payload.message.content
) {
yield { type: "text", text: payload.message.content };
}
for (const call of payload.message?.tool_calls ?? []) {
const name = call.function?.name;
if (!name) continue;
const rawArgs = call.function?.arguments;
// Ollama sends an object, but some builds send a JSON string.
let args: Record<string, unknown> = {};
if (rawArgs && typeof rawArgs === "object") {
args = rawArgs as Record<string, unknown>;
} else if (typeof rawArgs === "string" && rawArgs.trim()) {
try {
args = JSON.parse(rawArgs);
} catch {
args = {};
}
}
yield {
type: "tool_call",
call: { id: `call_${name}_${index++}`, name, arguments: args },
};
}
if (payload.done) {
stopReason = payload.done_reason ?? "stop";
break;
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const response = await config.fetch(joinUrl(baseFor(config), "api/tags"), {
method: "GET",
});
await assertOk(response, "Ollama");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => entry.name)
.filter((name: unknown): name is string => typeof name === "string")
.sort();
},
};
+182
View File
@@ -0,0 +1,182 @@
import { assertOk, joinUrl, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
ToolCall,
} from "./types.js";
import { AiProviderError } from "./types.js";
const OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
function baseFor(config: ProviderConfig): string {
if (config.baseUrl?.trim()) return config.baseUrl.trim();
if (config.providerType === "openai") return OPENAI_DEFAULT_BASE;
throw new AiProviderError("This provider needs a base URL");
}
function toOpenAiMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
tool_call_id: message.toolCallId,
content: message.content,
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content || null,
tool_calls: message.toolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: JSON.stringify(call.arguments),
},
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* Tool call arguments arrive as JSON fragments spread across many deltas, so
* they are accumulated per index and only parsed once the stream ends.
*/
interface PartialCall {
id: string;
name: string;
args: string;
}
export const openAiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const url = joinUrl(baseFor(config), "chat/completions");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await config.fetch(url, {
method: "POST",
headers,
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOpenAiMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "OpenAI");
const partial = new Map<number, PartialCall>();
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
if (data === "[DONE]") break;
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const choice = frame.choices?.[0];
if (!choice) continue;
if (choice.finish_reason) stopReason = choice.finish_reason;
const delta = choice.delta;
if (!delta) continue;
if (typeof delta.content === "string" && delta.content) {
yield { type: "text", text: delta.content };
}
for (const call of delta.tool_calls ?? []) {
const index = call.index ?? 0;
const existing = partial.get(index) ?? { id: "", name: "", args: "" };
if (call.id) existing.id = call.id;
if (call.function?.name) existing.name = call.function.name;
if (call.function?.arguments) existing.args += call.function.arguments;
partial.set(index, existing);
}
}
for (const call of partial.values()) {
if (!call.name) continue;
yield { type: "tool_call", call: finalizeCall(call) };
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await config.fetch(joinUrl(baseFor(config), "models"), {
method: "GET",
headers,
});
await assertOk(response, "OpenAI");
const body: any = await response.json();
return (body.data ?? [])
.map((entry: any) => entry.id)
.filter((id: unknown): id is string => typeof id === "string")
.sort();
},
};
export function finalizeCall(call: PartialCall): ToolCall {
let args: Record<string, unknown> = {};
if (call.args.trim()) {
try {
const parsed = JSON.parse(call.args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
args = parsed as Record<string, unknown>;
}
} catch {
// A model that emitted malformed arguments gets an empty object; the
// tool's own schema validation reports the problem back to it.
args = {};
}
}
return {
id:
call.id || `call_${call.name}_${Math.random().toString(36).slice(2, 10)}`,
name: call.name,
arguments: args,
};
}
@@ -0,0 +1,64 @@
import { anthropicAdapter } from "./anthropic.js";
import { geminiAdapter } from "./gemini.js";
import { ollamaAdapter } from "./ollama.js";
import { openAiAdapter } from "./openai.js";
import {
AiProviderError,
type AiProviderType,
type ProviderAdapter,
} from "./types.js";
/**
* openai_compatible reuses the OpenAI adapter: OpenRouter, Groq, Mistral,
* DeepSeek, together.ai, LM Studio and vLLM all speak the same wire format,
* they differ only in base URL.
*/
const ADAPTERS: Record<AiProviderType, ProviderAdapter> = {
ollama: ollamaAdapter,
anthropic: anthropicAdapter,
openai: openAiAdapter,
gemini: geminiAdapter,
openai_compatible: openAiAdapter,
};
export function getAdapter(providerType: string): ProviderAdapter {
const adapter = ADAPTERS[providerType as AiProviderType];
if (!adapter) {
throw new AiProviderError(`Unknown provider type: ${providerType}`);
}
return adapter;
}
/** Provider types that cannot work without a base URL. */
export const REQUIRES_BASE_URL: AiProviderType[] = [
"ollama",
"openai_compatible",
];
/** Provider types that cannot work without an API key. */
export const REQUIRES_API_KEY: AiProviderType[] = [
"anthropic",
"openai",
"gemini",
];
/**
* Shown when a provider's model list cannot be fetched: no key entered yet,
* the endpoint is unreachable, or the vendor has no list endpoint. Users can
* always type a model id the list does not contain, so this is a starting
* point rather than a restriction.
*/
export const FALLBACK_MODELS: Record<AiProviderType, string[]> = {
ollama: [
"llama3.2",
"llama3.1",
"qwen2.5-coder",
"mistral",
"phi4",
"gemma2",
],
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
openai: ["gpt-5", "gpt-5-mini", "o4-mini", "gpt-4.1"],
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
openai_compatible: [],
};
+100
View File
@@ -0,0 +1,100 @@
/**
* The shape every provider adapter normalizes to. Adding a provider means
* translating its wire format into these events; nothing downstream (the
* engine, the tool dispatcher, the SSE route) knows which vendor is in use.
*/
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export const AI_PROVIDER_TYPES: AiProviderType[] = [
"ollama",
"anthropic",
"openai",
"gemini",
"openai_compatible",
];
export function isAiProviderType(value: unknown): value is AiProviderType {
return (
typeof value === "string" && (AI_PROVIDER_TYPES as string[]).includes(value)
);
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
/** Set on assistant turns that requested tools. */
toolCalls?: ToolCall[];
/** Set on tool turns, matching the id of the call being answered. */
toolCallId?: string;
toolName?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
/**
* Opaque provider state that has to be echoed back verbatim on the next
* turn. Gemini 2.5+ rejects a follow-up whose functionCall parts have lost
* their thoughtSignature, so this rides along rather than being dropped.
*/
providerSignature?: string;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ChatRequest {
model: string;
system: string;
messages: ChatMessage[];
tools: ToolDefinition[];
signal?: AbortSignal;
}
export type ChatChunk =
| { type: "text"; text: string }
| { type: "tool_call"; call: ToolCall }
| { type: "done"; stopReason?: string }
| { type: "error"; message: string };
/**
* How an adapter reaches its provider. Always the egress-checked fetch from
* http.ts, never the global one.
*/
export type ProviderFetch = (
url: string,
init: RequestInit,
) => Promise<Response>;
export interface ProviderConfig {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
fetch: ProviderFetch;
}
export interface ProviderAdapter {
/** Streams a single assistant turn. Tool execution happens in the engine. */
streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk>;
/** Model ids to offer in the picker, best effort. */
listModels(config: ProviderConfig): Promise<string[]>;
}
export class AiProviderError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = "AiProviderError";
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Defense in depth for anything about to leave the server.
*
* Read tools already select explicit field allowlists rather than spreading
* rows, so nothing secret should reach here. This exists because "should" is
* not a guarantee: a future tool that forgets to project its fields would
* otherwise ship credentials to a third-party model provider.
*/
const SECRET_KEY_PATTERN =
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
/** Substring markers for keys that are not exact matches but still sensitive. */
const SECRET_KEY_SUBSTRINGS = [
"password",
"secret",
"privatekey",
"private_key",
"apikey",
"api_key",
"passphrase",
];
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
{
pattern:
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
label: "[redacted private key]",
},
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
{
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
label: "[redacted token]",
},
{
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
label: "Bearer [redacted]",
},
];
export const REDACTED = "[redacted]";
function isSecretKey(key: string): boolean {
if (SECRET_KEY_PATTERN.test(key)) return true;
const lower = key.toLowerCase();
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
}
export function redactString(value: string): string {
let output = value;
for (const { pattern, label } of VALUE_PATTERNS) {
output = output.replace(pattern, label);
}
return output;
}
/**
* Recursively drops secret-named fields and masks secret-shaped values.
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
*/
export function redact(value: unknown, depth = 0): unknown {
if (depth > 12) return REDACTED;
if (typeof value === "string") return redactString(value);
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.map((entry) => redact(entry, depth + 1));
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSecretKey(key)) {
// Preserve the shape so the model can still reason about presence,
// without ever seeing the value.
output[key] = entry === null || entry === undefined ? null : REDACTED;
continue;
}
output[key] = redact(entry, depth + 1);
}
return output;
}
/** Convenience wrapper for serializing a tool result. */
export function redactToJson(value: unknown): string {
return JSON.stringify(redact(value));
}
+421
View File
@@ -0,0 +1,421 @@
import { and, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
conversations as conversationsDef,
messages as messagesDef,
proposals as proposalsDef,
providers as providersDef,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tables come from ctx.db.define, which the SDK hands back untyped.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface AiProviderRecord {
id: number;
userId: string;
providerType: string;
label: string;
baseUrl: string | null;
apiKeyPrefix: string | null;
defaultModel: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/** A provider with its key, for the one path that makes outbound calls. */
export interface AiProviderWithSecret extends AiProviderRecord {
apiKey: string | null;
}
export interface AiConversationRecord {
id: number;
userId: string;
title: string | null;
providerId: number | null;
model: string | null;
createdAt: string;
updatedAt: string;
}
export interface AiMessageRecord {
id: number;
conversationId: number;
role: string;
content: string;
toolCalls: string | null;
createdAt: string;
}
export interface AiProposalRecord {
id: number;
conversationId: number;
userId: string;
kind: string;
summary: string | null;
payload: string;
status: string;
appliedAt: string | null;
resultSummary: string | null;
createdAt: string;
}
export interface AiProviderInput {
providerType: string;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
enabled?: boolean;
}
/** Where a provider's key lives in ctx.secrets. */
export function providerSecretKey(providerId: number): string {
return `provider:${providerId}`;
}
/**
* Keeps the first few characters so the UI can tell two keys apart without
* ever receiving the key itself.
*/
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
if (!apiKey) return null;
return apiKey.slice(0, 6);
}
const now = () => new Date().toISOString();
export type AiRepository = Awaited<ReturnType<typeof createAiRepository>>;
/**
* The assistant's rows. Every method takes the owning user and scopes to
* them. Keys go through ctx.secrets, which is per acting user, so key reads
* and writes run inside the request (or ctx.asUser) of that same user.
*/
export async function createAiRepository(
ctx: Pick<PluginContext, "db" | "secrets">,
) {
const { db, secrets } = ctx;
const providers: Table = await db.define(providersDef);
const conversations: Table = await db.define(conversationsDef);
const messages: Table = await db.define(messagesDef);
const proposals: Table = await db.define(proposalsDef);
const client = () => db.client<Drizzle>();
/** Inserts one row and returns its id. MySQL has no RETURNING. */
async function insertId(
table: Table,
values: Record<string, unknown>,
): Promise<number> {
const drizzle = await client();
if (db.dialect === "mysql") {
const result = await drizzle.insert(table).values(values);
const header = Array.isArray(result) ? result[0] : result;
return Number(header?.insertId);
}
const rows = await drizzle
.insert(table)
.values(values)
.returning({ id: table.id });
return rows[0].id as number;
}
async function first<T>(table: Table, where: unknown): Promise<T | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(where)
.limit(1);
return (rows[0] as T) ?? null;
}
const repo = {
// --- providers ---
/** Never includes key material; callers get the masked prefix only. */
async listProviders(userId: string): Promise<AiProviderRecord[]> {
return (await client())
.select()
.from(providers)
.where(eq(providers.userId, userId))
.orderBy(providers.id);
},
async findProvider(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
return first<AiProviderRecord>(
providers,
and(eq(providers.id, id), eq(providers.userId, userId)),
);
},
/**
* The one path that returns usable key material, right before an
* outbound request. Must run as `userId`.
*/
async findProviderWithSecret(
id: number,
userId: string,
): Promise<AiProviderWithSecret | null> {
const row = await repo.findProvider(id, userId);
if (!row) return null;
return { ...row, apiKey: await secrets.get(providerSecretKey(id)) };
},
async createProvider(
userId: string,
input: AiProviderInput,
): Promise<AiProviderRecord> {
const stamp = now();
const id = await insertId(providers, {
userId,
providerType: input.providerType,
label: input.label,
baseUrl: input.baseUrl ?? null,
apiKeyPrefix: apiKeyPrefix(input.apiKey),
defaultModel: input.defaultModel ?? null,
enabled: input.enabled ?? true,
createdAt: stamp,
updatedAt: stamp,
});
if (input.apiKey) {
await secrets.set(providerSecretKey(id), input.apiKey);
}
await db.persist();
const created = await repo.findProvider(id, userId);
if (!created) throw new Error("Provider could not be read back");
return created;
},
async updateProvider(
id: number,
userId: string,
input: Partial<AiProviderInput>,
): Promise<AiProviderRecord | null> {
const existing = await repo.findProvider(id, userId);
if (!existing) return null;
const updates: Record<string, unknown> = { updatedAt: now() };
if (input.providerType !== undefined)
updates.providerType = input.providerType;
if (input.label !== undefined) updates.label = input.label;
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
if (input.defaultModel !== undefined)
updates.defaultModel = input.defaultModel;
if (input.enabled !== undefined) updates.enabled = input.enabled;
// An empty string clears the key; undefined leaves it untouched.
if (input.apiKey !== undefined) {
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
await secrets.set(providerSecretKey(id), input.apiKey || null);
}
await (
await client()
)
.update(providers)
.set(updates)
.where(and(eq(providers.id, id), eq(providers.userId, userId)));
await db.persist();
return repo.findProvider(id, userId);
},
async deleteProvider(id: number, userId: string): Promise<boolean> {
const existing = await repo.findProvider(id, userId);
if (!existing) return false;
await (
await client()
)
.delete(providers)
.where(and(eq(providers.id, id), eq(providers.userId, userId)));
await secrets.delete(providerSecretKey(id));
await db.persist();
return true;
},
// --- conversations ---
async listConversations(
userId: string,
limit = 50,
): Promise<AiConversationRecord[]> {
return (await client())
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.updatedAt))
.limit(limit);
},
async findConversation(
id: number,
userId: string,
): Promise<AiConversationRecord | null> {
return first<AiConversationRecord>(
conversations,
and(eq(conversations.id, id), eq(conversations.userId, userId)),
);
},
async createConversation(input: {
userId: string;
title?: string | null;
providerId?: number | null;
model?: string | null;
}): Promise<AiConversationRecord> {
const stamp = now();
const id = await insertId(conversations, {
userId: input.userId,
title: input.title ?? null,
providerId: input.providerId ?? null,
model: input.model ?? null,
createdAt: stamp,
updatedAt: stamp,
});
await db.persist();
const created = await repo.findConversation(id, input.userId);
if (!created) throw new Error("Conversation could not be read back");
return created;
},
async touchConversation(id: number): Promise<void> {
await (
await client()
)
.update(conversations)
.set({ updatedAt: now() })
.where(eq(conversations.id, id));
await db.persist();
},
async deleteConversation(id: number, userId: string): Promise<boolean> {
const existing = await repo.findConversation(id, userId);
if (!existing) return false;
// Deleted explicitly rather than trusting the cascade, which a
// connection without foreign keys on would skip.
const drizzle = await client();
await drizzle.delete(messages).where(eq(messages.conversationId, id));
await drizzle.delete(proposals).where(eq(proposals.conversationId, id));
await drizzle
.delete(conversations)
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)));
await db.persist();
return true;
},
// --- messages ---
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
return (await client())
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(messages.id);
},
async appendMessage(input: {
conversationId: number;
role: string;
content: string;
toolCalls?: string | null;
}): Promise<void> {
await insertId(messages, {
conversationId: input.conversationId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
createdAt: now(),
});
await db.persist();
},
// --- proposals ---
async listProposals(
userId: string,
conversationId: number,
): Promise<AiProposalRecord[]> {
return (await client())
.select()
.from(proposals)
.where(
and(
eq(proposals.userId, userId),
eq(proposals.conversationId, conversationId),
),
)
.orderBy(desc(proposals.id));
},
async findProposal(
id: number,
userId: string,
): Promise<AiProposalRecord | null> {
return first<AiProposalRecord>(
proposals,
and(eq(proposals.id, id), eq(proposals.userId, userId)),
);
},
async createProposal(input: {
conversationId: number;
userId: string;
kind: string;
summary?: string | null;
payload: string;
}): Promise<AiProposalRecord> {
const id = await insertId(proposals, {
conversationId: input.conversationId,
userId: input.userId,
kind: input.kind,
summary: input.summary ?? null,
payload: input.payload,
status: "pending",
createdAt: now(),
});
await db.persist();
const created = await repo.findProposal(id, input.userId);
if (!created) throw new Error("Proposal could not be read back");
return created;
},
/** Only moves a pending proposal. False when it was not pending. */
async setProposalStatus(
id: number,
userId: string,
status: "applied" | "rejected" | "expired",
resultSummary?: string | null,
): Promise<boolean> {
const existing = await repo.findProposal(id, userId);
if (!existing || existing.status !== "pending") return false;
await (
await client()
)
.update(proposals)
.set({
status,
appliedAt: status === "applied" ? now() : null,
resultSummary: resultSummary ?? null,
})
.where(
and(
eq(proposals.id, id),
eq(proposals.userId, userId),
eq(proposals.status, "pending"),
),
);
await db.persist();
return true;
},
};
return repo;
}
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
/**
* Other plugins the assistant reads and changes through, each optional. A
* service call runs as the acting user and the provider checks that user's
* permissions, so nothing here takes a user id.
*/
export const SERVICE = {
snippets: "snippets.access",
fleets: "fleets.access",
automations: "automations.access",
workspaces: "workspaces.saved",
topology: "network-topology.graph",
history: "terminal.history",
homepage: "homepage.items",
} as const;
export type ServiceName = (typeof SERVICE)[keyof typeof SERVICE];
/** Whether a compatible provider is running right now. */
export function serviceAvailable(
services: Pick<PluginServices, "providers">,
service: string,
): boolean {
try {
return services.providers(service).length > 0;
} catch {
return false;
}
}
export interface SnippetSummary {
id: number;
name: string;
content: string;
description: string | null;
isNote: boolean;
folder: string | null;
}
export interface SnippetsAccess {
list: () => Promise<SnippetSummary[]>;
get: (id: number) => Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null>;
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
}) => Promise<{ id: number; name: string }>;
update: (
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
) => Promise<void>;
remove: (id: number) => Promise<boolean>;
}
export interface FleetsAccess {
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export interface AutomationsAccess {
list: () => Promise<
Array<{
id: number;
name: string;
description: string | null;
enabled: boolean;
triggerKind: string | null;
lastRunAt: string | null;
lastRunStatus: string | null;
missingPlugins: string[];
}>
>;
get: (id: number) => Promise<{
id: number;
name: string;
enabled: boolean;
definition: unknown;
} | null>;
create: (input: {
name: string;
description?: string | null;
definition: unknown;
enabled?: boolean;
}) => Promise<{ id: number; name: string }>;
}
export interface SavedWorkspaces {
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
export interface NetworkTopologyGraph {
get: () => Promise<unknown | null>;
}
export interface TerminalHistory {
list: (
hostId: number,
limit?: number,
) => Promise<Array<{ command: string; executedAt: string }>>;
}
/** homepage.items v1, which the homepage plugin provides from B19. */
export interface HomepageItems {
list: () => Promise<
Array<{ id: number; typeId: string; title: string | null }>
>;
}
interface ServiceTypes {
"snippets.access": SnippetsAccess;
"fleets.access": FleetsAccess;
"automations.access": AutomationsAccess;
"workspaces.saved": SavedWorkspaces;
"network-topology.graph": NetworkTopologyGraph;
"terminal.history": TerminalHistory;
"homepage.items": HomepageItems;
}
/**
* The service handle. Throws when the provider is missing or the user may
* not use it; a proposal apply wants that error, a read tool catches it.
*/
export function requireService<S extends ServiceName>(
services: Pick<PluginServices, "get" | "providers">,
service: S,
): ServiceTypes[S] {
if (!serviceAvailable(services, service)) {
throw new Error(`The ${service.split(".")[0]} plugin is not available`);
}
return services.get<ServiceTypes[S]>(service);
}
/** A read through a service, or null when it is missing or refused. */
export async function readService<S extends ServiceName, T>(
services: Pick<PluginServices, "get" | "providers">,
service: S,
read: (handle: ServiceTypes[S]) => Promise<T>,
): Promise<T | null> {
try {
return await read(requireService(services, service));
} catch {
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { PluginContext, SyncRow } from "@termix/plugin-sdk/backend";
import {
apiKeyPrefix,
providerSecretKey,
type AiRepository,
} from "./repository.js";
import { providers } from "./tables.js";
function keyFromWire(row: SyncRow): string | null {
if (typeof row.apiKey === "string") return row.apiKey || null;
if (row.apiKey === null) return null;
throw new Error("Invalid synced provider key");
}
export async function registerProviderSync(
ctx: PluginContext,
repo: AiRepository,
) {
ctx.sync.registerEntity({
type: "aiProviders",
table: await ctx.db.define(providers),
permissions: {
create: "ai.manage_providers",
update: "ai.manage_providers",
delete: "ai.manage_providers",
},
serialize: async (row) => {
const apiKey = await ctx.asUser(String(row.userId), () =>
ctx.secrets.get(providerSecretKey(Number(row.id))),
);
return { ...row, apiKey, apiKeyPrefix: apiKeyPrefix(apiKey) };
},
deserialize: async (row) => {
const apiKey = keyFromWire(row);
const { apiKey: _key, ...fields } = row;
return { ...fields, apiKeyPrefix: apiKeyPrefix(apiKey) };
},
afterWrite: async ({ id, userId, wire }) => {
if (id === null) throw new Error("Synced provider has no local id");
await ctx.asUser(userId, () =>
ctx.secrets.set(providerSecretKey(id), keyFromWire(wire)),
);
},
remove: async (row, userId) => {
await ctx.asUser(userId, () =>
repo.deleteProvider(Number(row.id), userId),
);
},
});
}
+138
View File
@@ -0,0 +1,138 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/*
* Adopted from core's ai_* tables, so column and index names are the legacy
* ones and the rename carries every row across.
*
* Messages and proposals point at their conversation with a plain integer
* here, since the SDK only references users and ssh_data. The adoption
* migrations keep that foreign key by hand, so deleting a conversation still
* cascades.
*
* Provider API keys are not in these tables: they live in ctx.secrets under
* "provider:<id>". A 2.8 database still has an api_key column, which the
* boot migration empties after moving the key.
*/
export const providers = adoptLegacyTable(
"ai_providers",
defineTable(
"providers",
{
id: id(),
syncId: varchar().unique(),
userId: refUser(),
// ollama | anthropic | openai | gemini | openai_compatible
providerType: text().notNull(),
label: varchar().notNull(),
// Required for ollama and openai_compatible, optional elsewhere.
baseUrl: text(),
// First few characters, kept in the clear so the UI can tell keys apart.
apiKeyPrefix: text(),
defaultModel: text(),
enabled: boolean().notNull().default(true),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{ name: "idx_ai_providers_user_label", columns: ["userId", "label"] },
],
},
),
);
export const conversations = adoptLegacyTable(
"ai_conversations",
defineTable(
"conversations",
{
id: id(),
userId: refUser(),
title: text(),
providerId: integer(),
model: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_ai_conversations_user",
columns: ["userId", "updatedAt"],
},
],
},
),
);
export const messages = adoptLegacyTable(
"ai_messages",
defineTable(
"messages",
{
id: id(),
conversationId: integer().notNull(),
// user | assistant | tool
role: text().notNull(),
content: text().notNull().default(""),
// Serialized tool calls for this turn.
toolCalls: text(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_ai_messages_conversation",
columns: ["conversationId", "createdAt"],
},
],
},
),
);
/**
* A change the assistant wants to make. Nothing here has been applied: the
* payload is re-validated at apply time and only then dispatched.
*/
export const proposals = adoptLegacyTable(
"ai_proposals",
defineTable(
"proposals",
{
id: id(),
conversationId: integer().notNull(),
userId: refUser(),
// The propose_* tool name that produced this.
kind: varchar().notNull(),
summary: text(),
payload: text().notNull().default("{}"),
// pending | applied | rejected | expired
status: varchar(32).notNull().default("pending"),
appliedAt: text(),
resultSummary: text(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_ai_proposals_user", columns: ["userId", "status"] },
{
name: "idx_ai_proposals_conversation",
columns: ["conversationId"],
},
],
},
),
);
export const tables = [providers, conversations, messages, proposals];
+86
View File
@@ -0,0 +1,86 @@
import { proposeTools } from "./propose-tools.js";
import { readTools } from "./read-tools.js";
import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. Route-level RBAC gates the
* HTTP API, but tools run in-process with the calling user's identity and never
* pass through a router, so "the assistant cannot reach credentials or user
* administration" is a property of this list, not of the permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
*/
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
export function getTool(name: string): AiTool | undefined {
return BY_NAME.get(name);
}
export function listToolNames(): string[] {
return AI_TOOLS.map((tool) => tool.name);
}
/**
* Domains the assistant must never be able to reach, in any tool, ever.
* The catalog test asserts no tool name references these.
*/
export const FORBIDDEN_DOMAINS = [
"credential",
"vault",
"rbac",
"role",
"permission",
"user_admin",
"password",
"totp",
"webauthn",
"passkey",
"api_key",
"session",
"oidc",
"sso",
"ldap",
"termix_id",
"identity",
"certificate",
"opkssh",
"stepca",
"step_ca",
"acme",
"ssl",
"audit",
"sync",
"settings",
];
/**
* The tools offered right now. A tool whose service has no running provider
* is left out, so the model never sees something it cannot use.
*/
export function availableTools(
isAvailable: (service: string) => boolean,
options: { allowReadOnlyCommands?: boolean } = {},
): AiTool[] {
return AI_TOOLS.filter(
(tool) =>
(!tool.service || isAvailable(tool.service)) &&
(!tool.requiresReadOnlyCommands ||
options.allowReadOnlyCommands === true),
);
}
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(
tools: AiTool[] = AI_TOOLS,
): ToolDefinitionShape[] {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
}));
}
@@ -0,0 +1,136 @@
/**
* Which commands the assistant may run without a per-command approval click,
* for users who opted into read-only execution.
*
* The check parses the command into arguments and matches the resolved binary
* against the allowlist. Substring matching would be trivially defeated
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
* redirect or substitute a second command rejects the whole string outright.
*/
export const READ_ONLY_COMMANDS = new Set([
"df",
"du",
"free",
"uptime",
"uname",
"whoami",
"hostname",
"id",
"ps",
"top",
"systemctl",
"journalctl",
"docker",
"ip",
"ss",
"netstat",
"lsblk",
"cat",
"ls",
"stat",
"which",
"date",
"lscpu",
"vmstat",
"iostat",
]);
/** Characters that let one command become several. */
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
/** Subcommands that are safe for otherwise-powerful binaries. */
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
systemctl: new Set([
"status",
"list-units",
"list-unit-files",
"is-active",
"is-enabled",
"show",
]),
docker: new Set([
"ps",
"stats",
"images",
"logs",
"inspect",
"version",
"info",
]),
ip: new Set(["a", "addr", "link", "route", "neigh"]),
};
/** Paths `cat` may read. Anything else could disclose credentials. */
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
export interface CommandCheck {
allowed: boolean;
reason?: string;
}
export function isReadOnlyCommand(raw: string): CommandCheck {
const command = raw.trim();
if (!command) return { allowed: false, reason: "Empty command" };
if (SHELL_METACHARACTERS.test(command)) {
return {
allowed: false,
reason: "Command chaining, redirection and substitution are not allowed",
};
}
const parts = command.split(/\s+/).filter(Boolean);
if (!parts.length) return { allowed: false, reason: "Empty command" };
// Reject env-prefixed and privilege-escalating forms outright.
const head = parts[0];
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
return { allowed: false, reason: `${head} is not allowed` };
}
// A path like /usr/bin/df resolves to its basename.
const binary = head.includes("/")
? head.slice(head.lastIndexOf("/") + 1)
: head;
if (!READ_ONLY_COMMANDS.has(binary)) {
return {
allowed: false,
reason: `${binary} is not on the read-only allowlist`,
};
}
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
if (allowedSubcommands) {
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
if (!subcommand || !allowedSubcommands.has(subcommand)) {
return {
allowed: false,
reason:
`${binary} ${subcommand ?? ""}`.trim() +
" is not on the read-only allowlist",
};
}
}
if (binary === "cat") {
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
if (!targets.length) {
return { allowed: false, reason: "cat needs a file path" };
}
for (const target of targets) {
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
);
if (!permitted) {
return {
allowed: false,
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
};
}
}
}
return { allowed: true };
}
+280
View File
@@ -0,0 +1,280 @@
import type { Client } from "ssh2";
import type { PluginHostUpdateInput } from "@termix/plugin-sdk/backend";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { getTool } from "./catalog.js";
import type { ToolDeps } from "./types.js";
import { requireService, SERVICE } from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
/**
* Applies an approved proposal.
*
* The stored payload is treated as untrusted input even though the server wrote
* it: the proposal could have sat in the table across a release, and defending
* the apply path rather than the create path means one place to get right.
* Everything goes through the same core APIs and plugin services a human
* action uses, and runs as the approving user: the apply request's actor.
*/
export interface ApplyResult {
ok: boolean;
summary: string;
}
function requireNumber(value: unknown, field: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return parsed;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} is required`);
}
return value.trim();
}
function optionalString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
/** Tags as ssh_data stores them, comma-separated. */
function joinTags(value: unknown): string {
if (!Array.isArray(value)) return "";
return value
.filter((tag): tag is string => typeof tag === "string")
.map((tag) => tag.trim().replace(/,/g, ""))
.filter(Boolean)
.join(",");
}
async function requirePermission(
deps: ToolDeps,
permission: string,
): Promise<void> {
if (!(await deps.rbac.has(permission))) {
throw new Error("You do not have permission to do this");
}
}
export async function applyProposal(
kind: string,
payload: Record<string, unknown>,
deps: ToolDeps,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
throw new Error(`Unknown proposal kind: ${kind}`);
}
switch (kind) {
case "propose_create_host": {
await requirePermission(deps, "hosts.create");
const created = await deps.hosts.create({
name: requireString(payload.name, "name"),
ip: requireString(payload.ip, "ip"),
port: Number(payload.port) || 22,
username: optionalString(payload.username) ?? "",
// Credentials are attached by the user after approving.
authType: "password",
folder: optionalString(payload.folder),
tags: joinTags(payload.tags),
});
return { ok: true, summary: `Created host ${created.name ?? ""}`.trim() };
}
case "propose_update_host": {
await requirePermission(deps, "hosts.edit");
const hostId = requireNumber(payload.hostId, "hostId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const updates: PluginHostUpdateInput = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.ip !== undefined)
updates.ip = requireString(changes.ip, "ip");
if (changes.port !== undefined) updates.port = Number(changes.port);
if (changes.username !== undefined)
updates.username = optionalString(changes.username) ?? "";
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder);
if (changes.tags !== undefined) updates.tags = joinTags(changes.tags);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
// update() refuses a host the approving user does not own.
const updated = await deps.hosts.update(hostId, updates);
if (!updated) throw new Error("Host not found");
return { ok: true, summary: `Updated host ${hostId}` };
}
case "propose_delete_host": {
const hostId = requireNumber(payload.hostId, "hostId");
// delete() checks hosts.delete and ownership itself.
const deleted = await deps.hosts.delete(hostId);
if (!deleted) throw new Error("Host not found");
return { ok: true, summary: `Deleted host ${hostId}` };
}
case "propose_create_snippet": {
const created = await requireService(
deps.services,
SERVICE.snippets,
).create({
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
});
return {
ok: true,
summary: `Created snippet ${created.name}`.trim(),
};
}
case "propose_update_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const snippets = requireService(deps.services, SERVICE.snippets);
const existing = await snippets.get(snippetId);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.content !== undefined)
updates.content = requireString(changes.content, "content");
if (changes.description !== undefined)
updates.description = optionalString(changes.description);
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await snippets.update(snippetId, updates);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await requireService(
deps.services,
SERVICE.snippets,
).remove(snippetId);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
case "propose_create_fleet": {
const fleets = requireService(deps.services, SERVICE.fleets);
const fleet = await fleets.create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
});
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
for (const raw of hostIds) {
const hostId = Number(raw);
if (!Number.isInteger(hostId) || hostId <= 0) continue;
// Only hosts the approving user owns can join their fleet.
const access = await deps.hosts.checkAccess(hostId, "view");
if (!access.isOwner) continue;
await fleets.addMember(fleet.id, hostId);
added += 1;
}
return {
ok: true,
summary: `Created fleet ${fleet.name} with ${added} host${added === 1 ? "" : "s"}`,
};
}
case "propose_create_automation": {
// The automations plugin runs the same validator its own route does,
// so an LLM-authored definition is held to exactly the human standard.
const created = await requireService(
deps.services,
SERVICE.automations,
).create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: payload.definition,
// Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval.
enabled: false,
});
return {
ok: true,
summary: `Created automation ${created.name} (disabled until you enable it)`,
};
}
case "propose_run_command": {
const hostId = requireNumber(payload.hostId, "hostId");
const command = requireString(payload.command, "command");
// ctx.ssh resolves the host for the approving user, with the same
// connect-level access check, owner-key decryption and jump chain a
// terminal gets.
const result = await runCommandOnHost(deps, hostId, command);
if (result.error) {
throw new Error(result.error);
}
return {
ok: true,
summary: result.output?.slice(0, 2000) ?? "(no output)",
};
}
default:
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
}
}
/**
* Runs one approved command over this plugin's SSH pool, mirroring how an
* automation run_command step executes.
*/
export async function runCommandOnHost(
deps: ToolDeps,
hostId: number,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await deps.ssh.withConnection<
Awaited<ReturnType<typeof execCommand>>,
Client
>(hostId, { pool: "ai", purpose: "fleet" }, (client) =>
execCommand(client, command, COMMAND_TIMEOUT_MS),
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}: ${output}`.trim() };
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Kinds the route handles itself rather than through applyProposal.
* Empty: everything the assistant can propose can now be applied.
*/
export const ROUTE_APPLIED_KINDS = new Set<string>();
@@ -0,0 +1,251 @@
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
import { SERVICE } from "../services.js";
/**
* Propose tools never mutate anything. They return a draft that is stored as a
* pending proposal and rendered as a card; the change happens only when the
* user approves it, and the payload is re-validated at that point.
*/
export const proposeTools: AiTool[] = [
{
name: "propose_create_host",
description:
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
category: "propose",
parameters: objectSchema(
{
name: str("Display name for the host"),
ip: str("Hostname or IP address"),
port: num("SSH port (defaults to 22)"),
username: str("SSH username"),
folder: str("Folder to file the host under"),
tags: {
type: "array",
items: { type: "string" },
description: "Tags to apply",
},
},
["name", "ip"],
),
handler: async (args) =>
proposal("propose_create_host", `Add host ${String(args.name)}`, {
name: args.name,
ip: args.ip,
port: args.port ?? 22,
username: args.username ?? null,
folder: args.folder ?? null,
tags: args.tags ?? [],
}),
},
{
name: "propose_update_host",
description:
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to update"),
name: str("New display name"),
ip: str("New hostname or IP address"),
port: num("New SSH port"),
username: str("New SSH username"),
folder: str("New folder"),
tags: {
type: "array",
items: { type: "string" },
description: "Replacement tag list",
},
},
["hostId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of [
"name",
"ip",
"port",
"username",
"folder",
"tags",
]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_host",
`Update host ${String(args.hostId)}`,
{ hostId: args.hostId, changes },
);
},
},
{
name: "propose_delete_host",
description:
"Propose removing a host. Use sparingly and explain why in the reason.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to delete"),
reason: str("Why this host should be removed"),
},
["hostId", "reason"],
),
handler: async (args) =>
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
hostId: args.hostId,
reason: args.reason,
}),
},
{
name: "propose_create_snippet",
description:
"Propose saving a new command snippet the user can run against their hosts.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
name: str("Snippet name"),
content: str("The command text"),
description: str("What the snippet does"),
folder: str("Folder to file it under"),
},
["name", "content"],
),
handler: async (args) =>
proposal(
"propose_create_snippet",
`Create snippet ${String(args.name)}`,
{
name: args.name,
content: args.content,
description: args.description ?? null,
folder: args.folder ?? null,
},
),
},
{
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
name: str("New name"),
content: str("New command text"),
description: str("New description"),
folder: str("New folder"),
},
["snippetId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of ["name", "content", "description", "folder"]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_snippet",
`Update snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, changes },
);
},
},
{
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
reason: str("Why this snippet should be removed"),
},
["snippetId", "reason"],
),
handler: async (args) =>
proposal(
"propose_delete_snippet",
`Delete snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, reason: args.reason },
),
},
{
name: "propose_create_automation",
description:
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
category: "propose",
service: SERVICE.automations,
parameters: objectSchema(
{
name: str("Automation name"),
description: str("What the automation does"),
definition: {
type: "object",
description:
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
},
},
["name", "definition"],
),
handler: async (args) =>
proposal(
"propose_create_automation",
`Create automation ${String(args.name)}`,
{
name: args.name,
description: args.description ?? null,
definition: args.definition,
},
),
},
{
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
service: SERVICE.fleets,
parameters: objectSchema(
{
name: str("Fleet name"),
description: str("What this fleet is for"),
hostIds: {
type: "array",
items: { type: "number" },
description: "Host ids to add as members",
},
},
["name"],
),
handler: async (args) =>
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
name: args.name,
description: args.description ?? null,
hostIds: args.hostIds ?? [],
}),
},
{
name: "propose_run_command",
description:
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to run on"),
command: str("The exact command to run"),
explanation: str("What the command does and why it is needed"),
},
["hostId", "command", "explanation"],
),
handler: async (args) =>
proposal(
"propose_run_command",
// Deliberately short: the card renders the command in its own block,
// so repeating it here made every card twice as tall as it needed.
`Run a command on host ${String(args.hostId)}`,
{
hostId: args.hostId,
command: args.command,
explanation: args.explanation,
},
),
},
];
+313
View File
@@ -0,0 +1,313 @@
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { num, objectSchema, str, type AiTool } from "./types.js";
import { readService, SERVICE } from "../services.js";
import { isReadOnlyCommand } from "./command-allowlist.js";
import { runCommandOnHost } from "./executor.js";
/** Output handed back to the model is capped; a log can be huge. */
const MAX_COMMAND_OUTPUT = 8000;
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
* afterwards as a second line of defense, but the projection here is the
* primary control: a field that is never selected cannot leak.
*/
function splitTags(tags: string | null): string[] {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
function toHostSummary(host: PluginHostSummary) {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder ?? null,
tags: splitTags(host.tags),
authType: host.authType,
};
}
export const readTools: AiTool[] = [
{
name: "run_readonly_command",
description:
"Run a read-only diagnostic command on a host and get its output, for example df -h, uptime, free -m or systemctl status nginx. Only commands on a fixed read-only allowlist run; anything else must be proposed with propose_run_command.",
category: "read",
requiresReadOnlyCommands: true,
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
command: str("The command to run"),
},
["hostId", "command"],
),
handler: async (args, context) => {
if (!context.allowReadOnlyCommands) {
return { error: "The user has not allowed read-only commands" };
}
const hostId = Number(args.hostId);
const command = typeof args.command === "string" ? args.command : "";
const check = isReadOnlyCommand(command);
if (!check.allowed) {
return { error: check.reason ?? "Command is not read-only" };
}
const access = await context.deps.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) return { error: "Host not found" };
const result = await runCommandOnHost(context.deps, hostId, command);
await context.deps.audit
.record({
action: "ai_readonly_command",
resourceType: "host",
resourceId: String(hostId),
details: JSON.stringify({ command }),
success: !result.error,
errorMessage: result.error,
})
.catch(() => undefined);
if (result.error) return { error: result.error };
return { output: (result.output ?? "").slice(0, MAX_COMMAND_OUTPUT) };
},
},
{
name: "list_hosts",
description:
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await context.deps.hosts.list();
return { hosts: hosts.map(toHostSummary) };
},
},
{
name: "get_host",
description:
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
category: "read",
parameters: objectSchema(
{ hostId: num("The host id, as returned by list_hosts") },
["hostId"],
),
handler: async (args, context) => {
const host = await context.deps.hosts.get(Number(args.hostId));
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
category: "read",
service: SERVICE.fleets,
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await readService(
context.deps.services,
SERVICE.fleets,
(fleetsAccess) => fleetsAccess.list(),
);
if (fleets === null) return { unavailable: true, fleets: [] };
return {
fleets: fleets.map((fleet) => ({ id: fleet.id, name: fleet.name })),
};
},
},
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
category: "read",
service: SERVICE.snippets,
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await readService(
context.deps.services,
SERVICE.snippets,
(snippetsAccess) => snippetsAccess.list(),
);
if (!snippets) return { unavailable: true, snippets: [] };
return {
snippets: snippets.map((snippet) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? null,
description: snippet.description ?? null,
})),
};
},
},
{
name: "list_automations",
description:
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
category: "read",
service: SERVICE.automations,
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await readService(
context.deps.services,
SERVICE.automations,
(automationsAccess) => automationsAccess.list(),
);
if (!automations) return { automations: [], unavailable: true };
return {
automations: automations.map((automation) => ({
id: automation.id,
name: automation.name,
description: automation.description,
enabled: automation.enabled,
triggerKind: automation.triggerKind,
lastRunAt: automation.lastRunAt,
lastRunStatus: automation.lastRunStatus,
needsPlugins: automation.missingPlugins,
})),
};
},
},
{
name: "get_automation",
description:
"Get one automation's full definition (trigger and steps) by id.",
category: "read",
service: SERVICE.automations,
parameters: objectSchema(
{
automationId: num("The automation id, as returned by list_automations"),
},
["automationId"],
),
handler: async (args, context) => {
const result = await readService(
context.deps.services,
SERVICE.automations,
async (automationsAccess) => ({
automation: await automationsAccess.get(Number(args.automationId)),
}),
);
if (result === null) return { unavailable: true };
const { automation } = result;
if (!automation) return { error: "Automation not found" };
return {
automation: {
id: automation.id,
name: automation.name,
enabled: automation.enabled,
definition: automation.definition,
},
};
},
},
{
name: "list_workspaces",
description:
"List the user's saved workspace layouts (named sets of open tabs and splits).",
category: "read",
service: SERVICE.workspaces,
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await readService(
context.deps.services,
SERVICE.workspaces,
(saved) => saved.list(),
);
if (workspaces === null) return { workspaces: [], unavailable: true };
return { workspaces };
},
},
{
name: "list_notification_channels",
description:
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels = await context.deps.notify.channels();
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled,
})),
};
},
},
{
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
category: "read",
service: SERVICE.homepage,
parameters: objectSchema({}),
handler: async (_args, context) => {
const items = await readService(
context.deps.services,
SERVICE.homepage,
(homepage) => homepage.list(),
);
if (items === null) return { items: [], unavailable: true };
return {
items: items.map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
})),
};
},
},
{
name: "get_command_history",
description:
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
category: "read",
service: SERVICE.history,
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
limit: num("How many entries to return (default 25, max 100)"),
},
["hostId"],
),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const hostId = Number(args.hostId);
// Access is checked here rather than trusted from the model.
const host = await context.deps.hosts.get(hostId);
if (!host) return { error: "Host not found" };
const history = await readService(
context.deps.services,
SERVICE.history,
(terminalHistory) => terminalHistory.list(hostId, limit),
);
if (history === null) return { commands: [], unavailable: true };
return { commands: history.map((entry) => entry.command) };
},
},
{
name: "get_network_topology",
description: "The user's saved network topology graph, if they have one.",
category: "read",
service: SERVICE.topology,
parameters: objectSchema({}),
handler: async (_args, context) => {
const result = await readService(
context.deps.services,
SERVICE.topology,
async (graph) => ({ topology: await graph.get() }),
);
if (result === null) return { topology: null, unavailable: true };
return { topology: result.topology };
},
},
];
+88
View File
@@ -0,0 +1,88 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type ToolCategory = "read" | "propose";
/** What tools and the proposal executor reach core and other plugins through. */
export type ToolDeps = Pick<
PluginContext,
"hosts" | "services" | "notify" | "rbac" | "ssh" | "audit"
>;
export interface ToolContext {
/** Always the request's authenticated user, never from model input. */
userId: string;
conversationId: number;
/** Per-user opt-in for running allowlisted read-only commands. */
allowReadOnlyCommands: boolean;
deps: ToolDeps;
}
export interface AiTool {
name: string;
description: string;
category: ToolCategory;
/**
* The service this tool needs. While no plugin provides it, the tool is
* not offered to the model at all.
*/
service?: string;
/** Offered only to a user who turned on read-only commands. */
requiresReadOnlyCommands?: boolean;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
* Read tools return data to feed back to the model. Propose tools return a
* ProposalDraft and must not mutate anything.
*/
handler: (
args: Record<string, unknown>,
context: ToolContext,
) => Promise<unknown>;
}
/** What a provider adapter needs to describe a tool to its model. */
export interface ToolDefinitionShape {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ProposalDraft {
__proposal: true;
kind: string;
summary: string;
payload: Record<string, unknown>;
}
export function isProposalDraft(value: unknown): value is ProposalDraft {
return (
typeof value === "object" &&
value !== null &&
(value as ProposalDraft).__proposal === true
);
}
export function proposal(
kind: string,
summary: string,
payload: Record<string, unknown>,
): ProposalDraft {
return { __proposal: true, kind, summary, payload };
}
/** Small helper so tool schemas stay readable. */
export function objectSchema(
properties: Record<string, unknown>,
required: string[] = [],
): Record<string, unknown> {
return {
type: "object",
properties,
required,
additionalProperties: false,
};
}
export const str = (description: string) => ({ type: "string", description });
export const num = (description: string) => ({ type: "number", description });
export const bool = (description: string) => ({ type: "boolean", description });
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+442
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+208
View File
@@ -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>
);
}
+244
View File
@@ -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();
}
}
+13
View File
@@ -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;
}
+4
View File
@@ -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;
}
+194
View File
@@ -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);
});
}
+84
View File
@@ -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;
}
+82
View File
@@ -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;
}
+240
View File
@@ -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 };
}
+114
View File
@@ -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]);
}
+73
View File
@@ -0,0 +1,73 @@
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { activate } from "../../src/backend/index.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("ai activate", () => {
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
},
);
it("cannot store a provider key without secrets:own", async () => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
db: db.database,
router: () => express.Router(),
});
await activate(mock.ctx);
await expect(
mock.ctx.secrets.set("provider:1", "k"),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
it("offers the api key and the host import normalizer", async () => {
server = await startServer();
await server.enableFor("user-1");
await server.request("POST", "/providers", {
body: { providerType: "openai", label: "Work", apiKey: "sk-shared" },
});
const normalizer = server.mock.ctx.registry.consume<
(raw: Record<string, unknown>) => Record<string, unknown> | null
>("ai.hostImportNormalizer");
expect(normalizer?.({ enableAiAssistant: true })).toEqual({
enableAiAssistant: true,
});
expect(
normalizer?.({ pluginSettings: { ai: { enableAiAssistant: false } } }),
).toEqual({ enableAiAssistant: false });
expect(normalizer?.({ name: "x" })).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import {
readService,
requireService,
serviceAvailable,
SERVICE,
} from "../../src/backend/services.js";
function services(provided: Record<string, object>) {
return {
providers: (service: string) => (service in provided ? [""] : []),
get: vi.fn((service: string) => provided[service]),
} as never;
}
describe("optional services", () => {
it("reads the user's history through terminal.history", async () => {
const list = vi.fn(async () => [
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
const history = await readService(
services({ [SERVICE.history]: { list } }),
SERVICE.history,
(terminal) => terminal.list(4, 25),
);
expect(list).toHaveBeenCalledWith(4, 25);
expect(history).toEqual([
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
});
it("answers null while the provider is off or refuses the user", async () => {
expect(
await readService(services({}), SERVICE.history, (terminal) =>
terminal.list(4, 25),
),
).toBeNull();
const refusing = {
list: async () => {
throw new Error("Missing permission");
},
};
expect(
await readService(
services({ [SERVICE.history]: refusing }),
SERVICE.history,
(terminal) => terminal.list(4, 25),
),
).toBeNull();
});
it("says which plugin is missing when a write needs it", () => {
expect(serviceAvailable(services({}), SERVICE.fleets)).toBe(false);
expect(() => requireService(services({}), SERVICE.fleets)).toThrow(
"The fleets plugin is not available",
);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompt } from "../../src/backend/context.js";
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
describe("buildSystemPrompt", () => {
it("tells the assistant to stay inside what was asked", () => {
// Without these the assistant answered "what is running on this server"
// by also proposing a monitoring script and an automation nobody wanted.
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("nothing beyond it");
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
expect(prompt).toMatch(/no monitoring automations, no scripts/);
expect(prompt).toMatch(/One request means one proposal at most/);
});
it("states that it proposes rather than applies", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toContain("cannot change anything directly");
expect(prompt).toContain("Never claim you have done something");
});
it("never claims credential access", () => {
const prompt = buildSystemPrompt(BASE);
expect(prompt).toMatch(/no access to passwords, SSH keys, API keys/);
});
it("mentions read-only commands only when the user opted in", () => {
expect(buildSystemPrompt(BASE)).not.toMatch(/read-only diagnostic/);
expect(buildSystemPrompt({ ...BASE, allowReadOnlyCommands: true })).toMatch(
/read-only diagnostic/,
);
});
it("pluralises the host count", () => {
expect(buildSystemPrompt({ ...BASE, hostCount: 1 })).toContain("1 host ");
expect(buildSystemPrompt({ ...BASE, hostCount: 2 })).toContain("2 hosts");
});
it("includes the active tab only when there is one", () => {
expect(buildSystemPrompt(BASE)).not.toContain("currently looking at");
expect(buildSystemPrompt({ ...BASE, activeTab: "terminal" })).toContain(
"currently looking at: terminal",
);
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PRIVATE_ALLOWLIST,
evaluateEgress,
isPrivateDestination,
parseAllowlist,
} from "../../src/backend/egress.js";
/**
* The rule that lets a self-hosted Ollama work without turning the backend
* into an authenticated probe of its own network: private destinations are
* refused unless an admin named the host.
*/
describe("isPrivateDestination", () => {
it("recognises loopback and private ranges", () => {
for (const url of [
"http://localhost:11434",
"http://127.0.0.1:11434",
"http://10.0.0.5:11434",
"http://192.168.1.10:11434",
"http://172.16.4.4:11434",
"http://169.254.169.254/latest/meta-data",
"http://[::1]:11434",
]) {
expect(isPrivateDestination(url), url).toBe(true);
}
});
it("treats public hosts as public", () => {
for (const url of [
"https://api.openai.com/v1",
"https://api.anthropic.com",
"https://generativelanguage.googleapis.com",
"http://8.8.8.8",
]) {
expect(isPrivateDestination(url), url).toBe(false);
}
});
});
describe("evaluateEgress", () => {
it("allows public destinations without an allowlist entry", () => {
const decision = evaluateEgress("https://api.openai.com/v1", []);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(false);
});
it("refuses a private destination that is not allowlisted", () => {
const decision = evaluateEgress("http://192.168.1.50:11434", ["localhost"]);
expect(decision.allowed).toBe(false);
expect(decision.isPrivate).toBe(true);
expect(decision.reason).toContain("allowlist");
});
it("allows a private destination once its host is allowlisted", () => {
const decision = evaluateEgress("http://localhost:11434", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(true);
expect(decision.isPrivate).toBe(true);
});
it.each(["llm.internal", "host.docker.internal"])(
"routes allowlisted private DNS name %s through the private path",
(host) => {
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
allowed: true,
isPrivate: true,
});
},
);
it("matches the allowlist case-insensitively", () => {
expect(
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
).toBe(true);
});
it("does not let one allowlisted host authorise another", () => {
// A cloud metadata endpoint is the classic target, so an allowlist for
// localhost must not open 169.254.169.254.
const decision = evaluateEgress("http://169.254.169.254/latest/meta-data", [
"localhost",
"127.0.0.1",
]);
expect(decision.allowed).toBe(false);
});
it("refuses non-http protocols and embedded credentials", () => {
expect(evaluateEgress("file:///etc/passwd", []).allowed).toBe(false);
expect(evaluateEgress("ftp://example.com", []).allowed).toBe(false);
expect(evaluateEgress("https://user:pass@api.openai.com", []).allowed).toBe(
false,
);
});
it("refuses a malformed url", () => {
expect(evaluateEgress("not a url", []).allowed).toBe(false);
});
});
describe("parseAllowlist", () => {
it("falls back to the defaults when the setting is not text", () => {
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
});
it("reads one host per line, normalised, skipping anything that is not a host", () => {
expect(
parseAllowlist(
[" LocalHost ", "", "10.0.0.5", "http://x/y", "10.0.0.5"].join("\n"),
),
).toEqual(["localhost", "10.0.0.5"]);
});
it("honours a deliberately empty allowlist", () => {
// An admin clearing the list must actually block everything private,
// not silently get the defaults back.
expect(parseAllowlist("")).toEqual([]);
});
});
+278
View File
@@ -0,0 +1,278 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatChunk } from "../../src/backend/providers/types.js";
const streamChat = vi.fn();
const handler = vi.fn();
vi.mock("../../src/backend/providers/registry.js", () => ({
getAdapter: () => ({ streamChat, listModels: async () => [] }),
}));
const { runAgent } = await import("../../src/backend/engine.js");
function chunks(...values: ChatChunk[]) {
return (async function* () {
for (const value of values) yield value;
})();
}
const BASE = {
config: { providerType: "ollama" as const, fetch: vi.fn() },
model: "test",
system: "system",
context: {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: false,
deps: {} as never,
},
// Only what was offered can run, so this is the whole catalog here.
tools: [
{
name: "list_hosts",
description: "List hosts",
category: "read" as const,
parameters: { type: "object", properties: {} },
handler,
},
],
};
async function collect(history: any[] = []) {
const events: any[] = [];
for await (const event of runAgent({ ...BASE, history })) {
events.push(event);
}
return events;
}
describe("runAgent", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("streams text and finishes when no tools are called", async () => {
streamChat.mockReturnValueOnce(
chunks({ type: "text", text: "hello" }, { type: "done" }),
);
const events = await collect();
expect(events.filter((e) => e.type === "token")).toHaveLength(1);
expect(events.at(-1).type).toBe("done");
});
it("runs a known tool and feeds the result back", async () => {
handler.mockResolvedValue({ hosts: [{ id: 1, name: "web-1" }] });
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
expect(handler).toHaveBeenCalledOnce();
expect(events.some((e) => e.type === "tool_result")).toBe(true);
expect(streamChat).toHaveBeenCalledTimes(2);
});
it("refuses a tool that is not in the catalog", async () => {
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "read_credentials", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
// A model can emit any name it likes; only the catalog decides what runs.
expect(handler).not.toHaveBeenCalled();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("Unknown tool");
});
it("surfaces a handler failure without ending the run", async () => {
handler.mockRejectedValue(new Error("database is down"));
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const result = events.find((e) => e.type === "tool_result");
expect(JSON.stringify(result.result)).toContain("database is down");
expect(events.at(-1).type).toBe("done");
});
it("closes the tool call when a tool returns a proposal", async () => {
// Without a matching tool_result the call rendered as permanently
// running, even though the work was done and awaiting the user.
handler.mockResolvedValue({
__proposal: true,
kind: "propose_create_host",
summary: "Add host web-1",
payload: {},
});
streamChat
.mockReturnValueOnce(
chunks(
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "ok" }, { type: "done" }),
);
const events = await collect();
const callIndex = events.findIndex((e) => e.type === "tool_call");
const resultIndex = events.findIndex((e) => e.type === "tool_result");
const proposalIndex = events.findIndex((e) => e.type === "proposal");
expect(resultIndex).toBeGreaterThan(callIndex);
expect(proposalIndex).toBeGreaterThan(resultIndex);
expect(events[resultIndex]).toMatchObject({
name: "list_hosts",
result: { status: "awaiting_user_approval" },
});
});
it("reports a provider failure as an error and stops", async () => {
streamChat.mockImplementationOnce(() => {
throw new Error("provider unreachable");
});
const events = await collect();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: "error",
message: "provider unreachable",
});
});
it("stops after too many tool turns", async () => {
handler.mockResolvedValue({ ok: true });
streamChat.mockImplementation(() =>
chunks(
{
type: "tool_call",
call: { id: "c", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
);
const events = await collect();
// A model that never stops calling tools must not spin forever.
expect(events.at(-1)).toMatchObject({ type: "error" });
expect(streamChat.mock.calls.length).toBeLessThanOrEqual(8);
});
it("emits every message it adds so the caller can store the run", async () => {
handler.mockResolvedValue({ hosts: [] });
streamChat
.mockReturnValueOnce(
chunks(
{ type: "text", text: "Looking" },
{
type: "tool_call",
call: { id: "c1", name: "list_hosts", arguments: {} },
},
{ type: "done" },
),
)
.mockReturnValueOnce(
chunks({ type: "text", text: "none" }, { type: "done" }),
);
const stored = (await collect())
.filter((e) => e.type === "message")
.map((e) => e.message);
expect(stored).toEqual([
{
role: "assistant",
content: "Looking",
toolCalls: [{ id: "c1", name: "list_hosts", arguments: {} }],
},
{
role: "tool",
content: JSON.stringify({ hosts: [] }),
toolCallId: "c1",
toolName: "list_hosts",
},
{ role: "assistant", content: "none" },
]);
});
it("explains a stream the provider cut off", async () => {
streamChat.mockReturnValueOnce(
(async function* () {
yield { type: "text", text: "par" } as ChatChunk;
throw Object.assign(new TypeError("terminated"), {
cause: Object.assign(new Error("Body Timeout Error"), {
code: "UND_ERR_BODY_TIMEOUT",
}),
});
})(),
);
const events = await collect();
expect(events.at(-1).type).toBe("error");
expect(events.at(-1).message).toMatch(/stopped sending data/);
});
it("stays quiet when the user stopped it", async () => {
const controller = new AbortController();
streamChat.mockReturnValueOnce(
(async function* () {
yield { type: "text", text: "" } as ChatChunk;
controller.abort();
throw new Error("This operation was aborted");
})(),
);
const events: any[] = [];
for await (const event of runAgent({
...BASE,
history: [],
signal: controller.signal,
})) {
events.push(event);
}
expect(events.some((e) => e.type === "error")).toBe(false);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import {
createAiGate,
isAiGloballyEnabled,
readPrivateAllowlist,
resolveAiAccess,
} from "../../src/backend/gating.js";
import { DEFAULT_PRIVATE_ALLOWLIST } from "../../src/backend/egress.js";
/** The plugin's settings as ctx.settings reads them: admin, then per user. */
function settings(
admin: Record<string, unknown>,
user: Record<string, unknown> = {},
) {
return {
get: vi.fn(async (key: string) => admin[key]) as never,
getUser: vi.fn(async (_userId: string, key: string) => user[key]) as never,
};
}
describe("AI gating", () => {
it("is off until an admin turns it on, so an upgrade enables nothing", async () => {
expect(await isAiGloballyEnabled(settings({}))).toBe(false);
expect(await isAiGloballyEnabled(settings({ globallyEnabled: true }))).toBe(
true,
);
});
it("blocks everyone when the admin switch is off", async () => {
const reader = settings(
{ globallyEnabled: false },
{ enabled: true, allowReadOnlyCommands: true },
);
const access = await resolveAiAccess(reader, "user-1");
expect(access).toEqual({ enabled: false, allowReadOnlyCommands: false });
// The kill switch short-circuits, so the user's choice is never read.
expect(reader.getUser).not.toHaveBeenCalled();
});
it("treats a user who never chose as not enabled", async () => {
const access = await resolveAiAccess(
settings({ globallyEnabled: true }),
"user-1",
);
expect(access.enabled).toBe(false);
});
it("allows only when both gates are open", async () => {
const access = await resolveAiAccess(
settings(
{ globallyEnabled: true },
{ enabled: true, allowReadOnlyCommands: true },
),
"user-1",
);
expect(access).toEqual({ enabled: true, allowReadOnlyCommands: true });
});
it("keeps read-only commands off unless separately opted in", async () => {
const access = await resolveAiAccess(
settings({ globallyEnabled: true }, { enabled: true }),
"user-1",
);
expect(access.allowReadOnlyCommands).toBe(false);
});
it("reads the private endpoint list, with defaults when unset", async () => {
expect(await readPrivateAllowlist(settings({}))).toEqual(
DEFAULT_PRIVATE_ALLOWLIST,
);
expect(
await readPrivateAllowlist(
settings({ privateEndpoints: "ollama.lan\n10.0.0.5" }),
),
).toEqual(["ollama.lan", "10.0.0.5"]);
});
});
describe("createAiGate", () => {
function response() {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
res.statusCode = code;
return { json: (body: unknown) => (res.body = body) };
},
};
return res;
}
it("answers 401 without a user and 403 while the assistant is off", async () => {
const next = vi.fn();
const noUser = response();
await createAiGate(settings({ globallyEnabled: true }), () => undefined)(
{},
noUser,
next,
);
expect(noUser.statusCode).toBe(401);
const off = response();
await createAiGate(settings({ globallyEnabled: false }), () => "user-1")(
{},
off,
next,
);
expect(off.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it("passes an opted-in user through with their access attached", async () => {
const next = vi.fn();
const req: Parameters<ReturnType<typeof createAiGate>>[0] = {};
await createAiGate(
settings({ globallyEnabled: true }, { enabled: true }),
() => "user-1",
)(req, response(), next);
expect(next).toHaveBeenCalledWith();
expect(req.aiAccess).toEqual({
enabled: true,
allowReadOnlyCommands: false,
});
});
});
+181
View File
@@ -0,0 +1,181 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const ALL_PERMISSIONS = [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
];
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
/** Turns the assistant on for the server and opts `userId` in. */
enableFor: (userId: string) => Promise<void>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
services?: Record<string, object>;
fetch?: (url: string, init?: unknown) => Promise<Response>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ALL_PERMISSIONS,
services: options.services,
fetch: options.fetch as never,
hosts: [
{
id: 1,
userId: "user-1",
name: "web-1",
ip: "10.0.0.1",
port: 22,
username: "root",
tags: "prod",
folder: null,
authType: "password",
},
],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return { status: response.status, body: parsed };
},
async enableFor(userId) {
await mock.ctx.settings.set("globallyEnabled", true);
await mock.ctx.settings.setUser(userId, "enabled", true);
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
// The four tables as core's SQLite bootstrap created them before 2.9.0, with
// the indexes performance-indexes.ts added.
export const LEGACY_DDL = `
CREATE TABLE ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL,
label TEXT NOT NULL,
base_url TEXT,
api_key TEXT,
api_key_prefix TEXT,
default_model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, label)
);
CREATE TABLE ai_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
provider_id INTEGER,
model TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ai_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
summary TEXT,
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
applied_at TEXT,
result_summary TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_ai_providers_user_label ON ai_providers (user_id, label);
CREATE INDEX idx_ai_conversations_user ON ai_conversations (user_id, updated_at);
CREATE INDEX idx_ai_messages_conversation ON ai_messages (conversation_id, created_at);
CREATE INDEX idx_ai_proposals_user ON ai_proposals (user_id, status);
CREATE INDEX idx_ai_proposals_conversation ON ai_proposals (conversation_id);
`;
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { toChatHistory, toStoredMessage } from "../../src/backend/history.js";
const calls = [
{ id: "c1", name: "list_hosts", arguments: {} },
{ id: "c2", name: "get_host", arguments: { hostId: 1 } },
];
describe("toStoredMessage", () => {
it("keeps the answered call on a tool row", () => {
expect(
toStoredMessage({
role: "tool",
content: "{}",
toolCallId: "c1",
toolName: "list_hosts",
}),
).toEqual({
role: "tool",
content: "{}",
toolCalls: JSON.stringify([{ id: "c1", name: "list_hosts" }]),
});
});
it("stores no tool calls for a plain reply", () => {
expect(toStoredMessage({ role: "assistant", content: "hi" })).toEqual({
role: "assistant",
content: "hi",
toolCalls: null,
});
});
});
describe("toChatHistory", () => {
it("round-trips a full run", () => {
const rows = [
{ role: "user", content: "check", toolCalls: null },
toStoredMessage({ role: "assistant", content: "", toolCalls: calls }),
toStoredMessage({
role: "tool",
content: '{"hosts":[]}',
toolCallId: "c1",
toolName: "list_hosts",
}),
toStoredMessage({
role: "tool",
content: '{"host":{}}',
toolCallId: "c2",
toolName: "get_host",
}),
{ role: "assistant", content: "done", toolCalls: null },
];
expect(toChatHistory(rows)).toEqual([
{ role: "user", content: "check" },
{ role: "assistant", content: "", toolCalls: calls },
{
role: "tool",
content: '{"hosts":[]}',
toolCallId: "c1",
toolName: "list_hosts",
},
{
role: "tool",
content: '{"host":{}}',
toolCallId: "c2",
toolName: "get_host",
},
{ role: "assistant", content: "done" },
]);
});
it("fills in results for calls a cut off run never answered", () => {
const rows = [
{ role: "user", content: "check", toolCalls: null },
toStoredMessage({ role: "assistant", content: "", toolCalls: calls }),
toStoredMessage({
role: "tool",
content: "{}",
toolCallId: "c1",
toolName: "list_hosts",
}),
{ role: "user", content: "continue", toolCalls: null },
];
const history = toChatHistory(rows);
expect(history.map((m) => m.role)).toEqual([
"user",
"assistant",
"tool",
"tool",
"user",
]);
expect(history[3]).toMatchObject({
toolCallId: "c2",
toolName: "get_host",
});
expect(JSON.parse(history[3].content).error).toMatch(/interrupted/);
});
it("repairs a legacy row saved with tool calls and no results", () => {
const history = toChatHistory([
{ role: "assistant", content: "", toolCalls: JSON.stringify(calls) },
]);
expect(history.filter((m) => m.role === "tool")).toHaveLength(2);
});
it("drops tool rows that answer nothing", () => {
expect(
toChatHistory([
{ role: "tool", content: "{}", toolCalls: null },
{ role: "user", content: "hi", toolCalls: null },
]),
).toEqual([{ role: "user", content: "hi" }]);
});
});
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff. Show more