feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-4lI7pWY2I7KZ35vkFr1bKo1OZllWHDP1lggz9ajwA84='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html;
index index.html index.htm;
expires off;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-u6nDWD7Mm1U/e9z2vyubUQB8Wk00VJsojS2q1tGdZwg='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-4lI7pWY2I7KZ35vkFr1bKo1OZllWHDP1lggz9ajwA84='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+8
View File
@@ -0,0 +1,8 @@
-- The ai tables moved to the ai plugin, which renames them into its own
-- namespace at activation, and ssh_data.enable_ai_assistant and the two
-- user_preferences ai columns moved to its settings. Both run after this
-- migration, so a real DROP here would delete the data first. The columns
-- stay in place, unused, until 3.0.0. The timestamp and label type changes
-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a
-- change to the database, and are left out on purpose.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -330,6 +330,13 @@
"when": 1790272734438,
"tag": "0046_rich_logan",
"breakpoints": true
},
{
"idx": 47,
"version": "5",
"when": 1790274704817,
"tag": "0047_red_silver_sable",
"breakpoints": true
}
]
}
+8
View File
@@ -0,0 +1,8 @@
-- The ai tables moved to the ai plugin, which renames them into its own
-- namespace at activation, and ssh_data.enable_ai_assistant and the two
-- user_preferences ai columns moved to its settings. Both run after this
-- migration, so a real DROP here would delete the data first. The columns
-- stay in place, unused, until 3.0.0. The timestamp and label type changes
-- drizzle-kit also wrote are generator drift (see FINISH-LIST.md), not a
-- change to the database, and are left out on purpose.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -337,6 +337,13 @@
"when": 1790272733784,
"tag": "0047_mighty_butterfly",
"breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1790274704269,
"tag": "0048_rare_catseye",
"breakpoints": true
}
]
}
@@ -0,0 +1,6 @@
-- The ai tables moved to the ai plugin, which renames them into its own
-- namespace at activation, and ssh_data.enable_ai_assistant and the two
-- user_preferences ai columns moved to its settings. Both run after this
-- migration, so a real DROP here would delete the data first. The columns
-- stay in place, unused, until 3.0.0.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -309,6 +309,13 @@
"when": 1790272733108,
"tag": "0043_curvy_red_shift",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1790274703680,
"tag": "0044_gorgeous_yellow_claw",
"breakpoints": true
}
]
}
+53 -6
View File
@@ -457,6 +457,7 @@ boundary and Suspense.
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
@@ -767,6 +768,29 @@ live at `/plugin-api/automations/webhook/<token>`; the 2.8 path
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
because external systems hold that URL.
**AI assistant (B18)** imports nothing from core. It adopts `ai_providers`,
`ai_conversations`, `ai_messages` and `ai_proposals` (the migrations keep the
conversation foreign keys by hand), keeps provider keys in `ctx.secrets`,
reaches providers through `ctx.fetch` with the admin's private endpoint list
as `allowPrivateHosts`, and runs approved commands through
`ctx.ssh.withConnection`. Hosts come from `ctx.hosts` (proposals use
`create`, `update` and `delete`, after checking the approving user's core
`hosts.*` permission), channels from `ctx.notify.channels()`, and everything
else from optional services: `snippets.access`, `fleets.access`,
`automations.access`, `workspaces.saved`, `network-topology.graph`,
`terminal.history` and `homepage.items` v1 (`list()`, which the homepage
plugin provides from B19). A tool names the service it needs, and a tool
whose service has no running provider is not offered to the model at all. B18
made the write methods of `snippets.access` and `fleets.access` check the
same permission their routes do (`snippets.create`/`edit`/`delete`,
`fleets.manage`), since a proposal runs as the approving user. Its settings
are the admin `globallyEnabled` and `privateEndpoints`, the user `enabled`
and `allowReadOnlyCommands` plus a custom `providers` field, and the host
`enableAiAssistant`, which decides whether its `terminal.toolbar` button and
`terminal.sidePanel` appear. `ai-settings-migration.ts` moves the 2.8 values
and keys. The shell knows nothing about it: availability is the plugin being
enabled, `ai.use`, and those settings.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
@@ -1380,6 +1404,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
@@ -1389,7 +1414,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
@@ -1400,7 +1425,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1609,6 +1634,28 @@ notification)` delivers to the ones among them that are theirs and
passes the admin allowlist it reads through `ctx.settings.readCore`
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
**B18** added three members for the AI assistant:
- `ctx.secrets.get(key)` / `.set(key, value)` / `.delete(key)` store a
plugin's own secrets for the acting user, next to the `offer` side of
`ctx.secrets`. Rows live in `plugin_settings` under the `secret` scope
(`scope_id` the user id), encrypted with `encryptSystemSecret`, so they go
with the plugin on uninstall and with the user when `UserRepository.delete`
removes that user's rows. The settings service is manifest-driven and never
reads them. All three need `secrets:own` and an actor; `set` and `delete`
are audited without the value, `get` only checks the capability, like
`ctx.db.persist`, because a plugin reads its keys on every request that
uses them. The AI assistant keeps each provider's key under
`provider:<id>` instead of in its providers table.
- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body;
`timeoutMs` still only covers the wait for the headers, so a long model
stream is not cut.
- `ctx.hosts.delete(hostId)` deletes a host the actor owns through core's
own delete path (`hosts/delete-host.ts`, shared with the host editor's
route: access grants, activity, plugin settings, the tombstone and the
`host.deleted` event). Needs `hosts:write`, and the actor needs the core
`hosts.delete` permission. False for a host that is missing or not theirs.
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
@@ -2139,8 +2186,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations
(B17), which import
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17) and ai (B18), which import
nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
@@ -2168,8 +2215,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
| A plugin importing core by relative path | Warning | 12 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 55 files | D1 |
| A plugin importing core by relative path | Warning | 4 files | D1 |
| A plugin importing another plugin's source | Warning | 0 files | - |
A warning does not fail a build, so the counts are held by
+28 -7
View File
@@ -374,10 +374,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
brings `enable_docker` across, the same gap every earlier column move left
there: it writes host rows only and never plugin settings. Owner: D1, with
the generic host-settings export and import.
- **B15 (docker), for B18:** the AI assistant's `list_hosts`/`get_host`
tools still report `enableDocker` from the host row, which no longer has
the column, so it always reads null. Read `pluginSettings.docker` or ask the
`docker.containers` service instead. Owner: B18.
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
@@ -408,9 +404,34 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
ever is not, the run now fails on its first SSH step instead of being
skipped silently. Owner: none unless a background "is this user unlocked"
check is added to the SDK.
- **B17 (automations):** AI's `list_notification_channels` read tool still reads
notification channels through core's repository by relative import; it
can use `ctx.notify.channels()` now. Owner: B18.
- **B18 (ai), for B19:** the assistant's `list_homepage_items` tool calls
the optional `homepage.items` v1 service, `list(): Promise<Array<{ id,
typeId, title }>>`, as the acting user. Until B19 provides it the tool is
not offered to the model. Owner: B19.
- **B18 (ai):** the "allow read-only diagnostic commands" user setting only
adds a line to the system prompt. No tool runs a command directly;
`tools/command-allowlist.ts` (`isReadOnlyCommand`) is tested but unused, so
every command is still a proposal. Either add the direct tool or drop the
setting. Owner: D0.
- **B18 (ai):** provider requests now go through `ctx.fetch`, which has no
outbound proxy support. Before, an admin-allowlisted private provider went
through `fetchWithProxy` (public ones never did). Decide whether
`ctx.fetch` should honour the proxy settings. Owner: D0.
- **B18:** `scripts/generate-dialect-schema.cjs` decides varchar or text by
column name across every table, so removing the ai indexes on
`created_at`, `updated_at` and `label` turned those columns into `text`
everywhere in `schema.pg.ts` and `schema.mysql.ts`. drizzle-kit then wrote
about a hundred type changes, which B18 left out of
`drizzle/postgres/0048` and `drizzle/mysql/0047` (both a no-op), so real
databases keep `varchar(255)` while the snapshots say `text`. Key the
generator per table, regenerate, and check `verify:dialect`. Owner: D0.
- **B18:** saving a plugin setting from the Settings screen tells nobody. The
ai frontend re-reads its status on `termix:plugins-changed` and on window
focus (throttled to 30 seconds), so an admin turning it on shows up on the
next focus. A settings-changed hook on the app object would be cleaner.
Owner: D1.
- **B18:** tunnels builds its own SSE URL off the axios base instead of the
new `app.fetch`. Owner: D1.
## Manual checks after 2.9.0
+23
View File
@@ -219,6 +219,19 @@ export interface PluginServices {
}
export interface PluginSecrets {
/**
* The acting user's own copy of a secret this plugin stored, decrypted, or
* null when nothing is stored. Needs secrets:own and an actor. Not audited:
* a plugin reads its keys on every request that uses them.
*/
get: (key: string) => Promise<string | null>;
/**
* Stores a secret for the acting user, encrypted at rest. Null clears it.
* Needs secrets:own and an actor. Audited without the value.
*/
set: (key: string, value: string | null) => Promise<void>;
/** Removes the acting user's secret. Needs secrets:own. Audited. */
delete: (key: string) => Promise<void>;
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
@@ -580,6 +593,14 @@ export interface PluginHosts {
hostId: number,
patch: PluginHostUpdateInput,
) => Promise<PluginHostRecord | null>;
/**
* Deletes a host the acting user owns, the same way the host editor's
* delete does: its access grants, activity and plugin settings go with it
* and remote sync gets a tombstone. Needs hosts:write, and the acting user
* needs the core hosts.delete permission. False when the host does not
* exist or is not theirs.
*/
delete: (hostId: number) => Promise<boolean>;
/**
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
* not the narrow list() summary). For a plugin that scans its own hosts in
@@ -1317,6 +1338,8 @@ export interface PluginFetchInit {
* private is refused, and redirects are never followed.
*/
allowPrivateHosts?: readonly string[];
/** Aborts the request, and a streamed body, when the caller gives up. */
signal?: AbortSignal;
}
/**
+5
View File
@@ -600,6 +600,11 @@ export interface TermixApp extends TermixAppInfo {
* desktop app's connected remote server, anything else is `api`.
*/
apiFor: (origin?: unknown) => PluginApiClient;
/**
* A raw fetch on /plugin-api/<id>/<path> with core's auth, for a response
* read as a stream (server-sent events), which `api` cannot do.
*/
fetch: (path: string, init?: RequestInit) => Promise<Response>;
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
wsUrl: (
path: string,
+37
View File
@@ -185,6 +185,8 @@ export interface FakePluginContext {
channelIds: number[];
notification: PluginNotification;
}>;
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.audit.record entry, in order. */
@@ -290,6 +292,7 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
@@ -491,6 +494,14 @@ export function createFakeContext(
},
secrets: {
get: async (key) => secretStore.get(`${actor ?? ""}:${key}`) ?? null,
set: async (key, value) => {
if (value === null) secretStore.delete(`${actor ?? ""}:${key}`);
else secretStore.set(`${actor ?? ""}:${key}`, value);
},
delete: async (key) => {
secretStore.delete(`${actor ?? ""}:${key}`);
},
offer: () => {},
withdraw: () => false,
getShared: async () => null,
@@ -629,6 +640,11 @@ export function createFakeContext(
hostRecordsById.set(hostId, updated);
return updated;
},
delete: async (hostId: number): Promise<boolean> => {
const existed =
hostRecordsById.delete(hostId) || hostsById.delete(hostId);
return existed;
},
listOwned: async (): Promise<PluginHostRecord[]> => [
...hostRecordsById.values(),
],
@@ -836,6 +852,7 @@ export function createFakeContext(
credentialReads,
notifications,
fetches,
secretStore,
audits,
activities,
trackedSessions,
@@ -1076,6 +1093,10 @@ export function createMockCtx(
require("hosts:write");
return ctx.hosts.update(hostId, patch);
},
delete: async (hostId) => {
require("hosts:write");
return ctx.hosts.delete(hostId);
},
listOwned: async () => {
require("hosts:write");
return ctx.hosts.listOwned();
@@ -1220,6 +1241,22 @@ export function createMockCtx(
return ctx.fetch(url, init);
},
secrets: {
...ctx.secrets,
get: async (key) => {
require("secrets:own");
return ctx.secrets.get(key);
},
set: async (key, value) => {
require("secrets:own");
return ctx.secrets.set(key, value);
},
delete: async (key) => {
require("secrets:own");
return ctx.secrets.delete(key);
},
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {
+18 -9
View File
@@ -22,7 +22,24 @@
}
},
"settings": {
"assistantUnavailable": "The AI assistant is turned off on this server."
"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",
@@ -95,13 +112,5 @@
"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."
},
"admin": {
"aiGloballyEnabled": "AI assistant",
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
"aiPrivateEndpoints": "Allowed private AI hosts",
"aiPrivateEndpointsDesc": "Hosts on your private network that users may point a provider at, such as a self-hosted Ollama. Separate them with commas.",
"updateAiEnabledFailed": "Could not update the AI setting",
"updateAiEndpointsFailed": "Could not update the allowed hosts"
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "KI-assistent",
"aiGloballyEnabledDesc": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer.",
"aiPrivateEndpoints": "Toegelate private KI-gashere",
"aiPrivateEndpointsDesc": "Gashere op jou privaat netwerk waarna gebruikers 'n verskaffer kan verwys, soos 'n self-gehoste Ollama. Skei hulle met kommas.",
"updateAiEnabledFailed": "Kon nie die KI-instelling opdateer nie",
"updateAiEndpointsFailed": "Kon nie die toegelate gashere opdateer nie"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "يُضيف هذا البرنامج مساعدًا يقرأ إعداداتك ويقترح تغييرات توافق عليها. يتطلب ذلك مزود خدمة تُحدده بنفسك، مثل مزود خدمة Ollama محلي.",
"aiNote": "لن يتم إرسال أي شيء إلى أي مكان حتى تقوم بإضافة مزود خدمة وإرسال رسالة."
},
"admin": {
"aiGloballyEnabled": "مساعد الذكاء الاصطناعي",
"aiGloballyEnabledDesc": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع.",
"aiPrivateEndpoints": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي",
"aiPrivateEndpointsDesc": "المضيفون على شبكتك الخاصة الذين يمكن للمستخدمين توجيه مزود الخدمة إليهم، مثل Ollama المستضاف ذاتيًا. افصل بينهم بفواصل.",
"updateAiEnabledFailed": "تعذر تحديث إعدادات الذكاء الاصطناعي",
"updateAiEndpointsFailed": "تعذر تحديث المضيفين المسموح بهم"
"settings": {
"admin": {
"globallyEnabled": {
"label": "مساعد الذكاء الاصطناعي",
"description": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع."
},
"privateEndpoints": {
"label": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي"
}
},
"host": {
"enableAiAssistant": {
"label": "تفعيل مساعد الذكاء الاصطناعي",
"description": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Добавя асистент, който чете настройките ви и предлага промени, които одобрявате. Необходим е доставчик, когото вие предоставяте, например местен Ollama.",
"aiNote": "Нищо не се изпраща никъде, докато не добавите доставчик и не изпратите съобщение."
},
"admin": {
"aiGloballyEnabled": "Асистент с изкуствен интелект",
"aiGloballyEnabledDesc": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички.",
"aiPrivateEndpoints": "Разрешени частни хостове с изкуствен интелект",
"aiPrivateEndpointsDesc": "Хостове във вашата частна мрежа, към които потребителите могат да насочат доставчик, като например самостоятелно хостван Ollama. Разделете ги със запетаи.",
"updateAiEnabledFailed": "Не можа да се актуализира настройката за изкуствен интелект",
"updateAiEndpointsFailed": "Не можаха да се актуализират разрешените хостове"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Асистент с изкуствен интелект",
"description": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички."
},
"privateEndpoints": {
"label": "Разрешени частни хостове с изкуствен интелект"
}
},
"host": {
"enableAiAssistant": {
"label": "Активиране на AI асистент",
"description": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "একজন সহকারী যোগ করে যে আপনার সেটআপ পড়ে এবং আপনার অনুমোদিত পরিবর্তনের পরামর্শ দেয়। এর জন্য আপনার সরবরাহ করা একজন প্রদানকারীর প্রয়োজন, যেমন একজন স্থানীয় ওলামা।",
"aiNote": "আপনি একজন প্রোভাইডার যোগ করে মেসেজ না পাঠানো পর্যন্ত কোথাও কিছু পাঠানো হয় না।"
},
"admin": {
"aiGloballyEnabled": "এআই সহকারী",
"aiGloballyEnabledDesc": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।",
"aiPrivateEndpoints": "ব্যক্তিগত এআই হোস্ট অনুমোদিত",
"aiPrivateEndpointsDesc": "আপনার প্রাইভেট নেটওয়ার্কের হোস্টগুলো, যেগুলোকে ব্যবহারকারীরা কোনো প্রোভাইডারের দিকে নির্দেশ করতে পারে, যেমন একটি সেলফ-হোস্টেড ওলামা। এগুলোকে কমা দিয়ে আলাদা করুন।",
"updateAiEnabledFailed": "এআই সেটিং আপডেট করা যায়নি",
"updateAiEndpointsFailed": "অনুমোদিত হোস্টগুলি আপডেট করা যায়নি"
"settings": {
"admin": {
"globallyEnabled": {
"label": "এআই সহকারী",
"description": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।"
},
"privateEndpoints": {
"label": "ব্যক্তিগত এআই হোস্ট অনুমোদিত"
}
},
"host": {
"enableAiAssistant": {
"label": "এআই অ্যাসিস্ট্যান্ট সক্রিয় করুন",
"description": "এই হোস্টের টার্মিনালে সাহায্য করার জন্য এআই অ্যাসিস্ট্যান্টকে অনুমতি দিন। এটি ডিফল্টরূপে বন্ধ থাকে এবং আপনার অ্যাকাউন্টের জন্য এআই চালু করা প্রয়োজন।"
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Assistent d'IA",
"aiGloballyEnabledDesc": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom.",
"aiPrivateEndpoints": "Amfitrions d'IA privats permesos",
"aiPrivateEndpointsDesc": "Amfitrions de la vostra xarxa privada als quals els usuaris poden dirigir un proveïdor, com ara un Ollama autoallotjat. Separeu-los amb comes.",
"updateAiEnabledFailed": "No s'ha pogut actualitzar la configuració de la IA",
"updateAiEndpointsFailed": "No s'han pogut actualitzar els amfitrions permesos"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Asistent s umělou inteligencí",
"aiGloballyEnabledDesc": "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.",
"aiPrivateEndpoints": "Povolení soukromí hostitelé s umělou inteligencí",
"aiPrivateEndpointsDesc": "Hostitelé ve vaší privátní síti, na které mohou uživatelé odkazovat poskytovatele, například samostatně hostovaný Ollama. Oddělte je čárkami.",
"updateAiEnabledFailed": "Nepodařilo se aktualizovat nastavení umělé inteligence",
"updateAiEndpointsFailed": "Nepodařilo se aktualizovat povolené hostitele"
"settings": {
"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í"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "AI-assistent",
"aiGloballyEnabledDesc": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle.",
"aiPrivateEndpoints": "Tilladte private AI-værter",
"aiPrivateEndpointsDesc": "Værter på dit private netværk, som brugerne kan henvise til en udbyder, f.eks. en selvhostet Ollama. Adskil dem med kommaer.",
"updateAiEnabledFailed": "Kunne ikke opdatere AI-indstillingen",
"updateAiEndpointsFailed": "Kunne ikke opdatere de tilladte værter"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "KI-Assistent",
"aiGloballyEnabledDesc": "Ermöglichen Sie den Nutzern, den KI-Assistenten zu aktivieren. Solange diese Option deaktiviert ist, ist der Assistent für alle ausgeblendet und gesperrt.",
"aiPrivateEndpoints": "Zulässige private KI-Hosts",
"aiPrivateEndpointsDesc": "Hosts in Ihrem privaten Netzwerk, auf die Benutzer einen Provider verweisen können, wie z. B. ein selbst gehostetes Ollama. Trennen Sie diese durch Kommas.",
"updateAiEnabledFailed": "Die KI-Einstellungen konnten nicht aktualisiert werden.",
"updateAiEndpointsFailed": "Die Liste der zulässigen Hosts konnte nicht aktualisiert werden."
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Προσθέτει έναν βοηθό που διαβάζει τη ρύθμισή σας και προτείνει αλλαγές που εγκρίνετε. Χρειάζεται έναν πάροχο που παρέχετε, όπως ένα τοπικό Ollama.",
"aiNote": "Τίποτα δεν αποστέλλεται πουθενά μέχρι να προσθέσετε έναν πάροχο και να στείλετε ένα μήνυμα."
},
"admin": {
"aiGloballyEnabled": "Βοηθός Τεχνητής Νοημοσύνης",
"aiGloballyEnabledDesc": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους.",
"aiPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης",
"aiPrivateEndpointsDesc": "Κεντρικοί υπολογιστές στο ιδιωτικό σας δίκτυο στους οποίους οι χρήστες μπορούν να υποδείξουν έναν πάροχο, όπως ένα αυτοφιλοξενούμενο Ollama. Διαχωρίστε τους με κόμματα.",
"updateAiEnabledFailed": "Δεν ήταν δυνατή η ενημέρωση της ρύθμισης AI",
"updateAiEndpointsFailed": "Δεν ήταν δυνατή η ενημέρωση των επιτρεπόμενων κεντρικών υπολογιστών"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Βοηθός Τεχνητής Νοημοσύνης",
"description": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους."
},
"privateEndpoints": {
"label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης"
}
},
"host": {
"enableAiAssistant": {
"label": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης",
"description": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Asistente de IA",
"aiGloballyEnabledDesc": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos.",
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
"aiPrivateEndpointsDesc": "Hosts en tu red privada a los que los usuarios pueden dirigir un proveedor, como un servidor Ollama autogestionado. Sepáralos con comas.",
"updateAiEnabledFailed": "No se pudo actualizar la configuración de la IA.",
"updateAiEndpointsFailed": "No se pudieron actualizar los hosts permitidos."
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Tekoälyavustaja",
"aiGloballyEnabledDesc": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta.",
"aiPrivateEndpoints": "Sallitut yksityiset tekoälyisännät",
"aiPrivateEndpointsDesc": "Yksityisen verkon isännät, joihin käyttäjät voivat ohjata palveluntarjoajan, kuten itse isännöity Ollama. Erota ne pilkuilla.",
"updateAiEnabledFailed": "Tekoälyasetuksen päivittäminen epäonnistui",
"updateAiEndpointsFailed": "Sallittujen isäntien päivittäminen epäonnistui"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Assistant IA",
"aiGloballyEnabledDesc": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous.",
"aiPrivateEndpoints": "Hôtes d'IA privés autorisés",
"aiPrivateEndpointsDesc": "Les hôtes de votre réseau privé vers lesquels les utilisateurs peuvent orienter un fournisseur, comme un serveur Ollama auto-hébergé. Séparez-les par des virgules.",
"updateAiEnabledFailed": "Impossible de mettre à jour les paramètres d'IA",
"updateAiEndpointsFailed": "Impossible de mettre à jour les hôtes autorisés."
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "מוסיף עוזר שקורא את ההגדרות שלך ומציע שינויים שאתה מאשר. נדרש ספק שאתה מספק, כגון אולמה מקומי.",
"aiNote": "שום דבר לא נשלח לשום מקום עד שתוסיף ספק ותשלח הודעה."
},
"admin": {
"aiGloballyEnabled": "עוזר בינה מלאכותית",
"aiGloballyEnabledDesc": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם.",
"aiPrivateEndpoints": "מארחים פרטיים של בינה מלאכותית מורשים",
"aiPrivateEndpointsDesc": "מארחים ברשת הפרטית שלך שמשתמשים עשויים להפנות אליהם ספק, כגון Ollama המאוחסן בעצמו. הפרד ביניהם באמצעות פסיקים.",
"updateAiEnabledFailed": "לא ניתן היה לעדכן את הגדרת הבינה המלאכותית",
"updateAiEndpointsFailed": "לא ניתן היה לעדכן את המארחים המותרים"
"settings": {
"admin": {
"globallyEnabled": {
"label": "עוזר בינה מלאכותית",
"description": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם."
},
"privateEndpoints": {
"label": "מארחים פרטיים של בינה מלאכותית מורשים"
}
},
"host": {
"enableAiAssistant": {
"label": "הפעל את עוזר הבינה המלאכותית",
"description": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "यह एक सहायक सुविधा जोड़ता है जो आपके सेटअप को पढ़ता है और आपके द्वारा स्वीकृत किए जाने वाले परिवर्तनों का सुझाव देता है। इसके लिए आपको एक प्रदाता उपलब्ध कराना होगा, जैसे कि स्थानीय ओलामा।",
"aiNote": "जब तक आप कोई प्रदाता नहीं जोड़ते और संदेश नहीं भेजते, तब तक कुछ भी कहीं नहीं भेजा जाता।"
},
"admin": {
"aiGloballyEnabled": "एआई सहायक",
"aiGloballyEnabledDesc": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।",
"aiPrivateEndpoints": "निजी एआई होस्ट की अनुमति दी गई",
"aiPrivateEndpointsDesc": "आपके निजी नेटवर्क पर मौजूद होस्ट, जिन्हें उपयोगकर्ता किसी प्रदाता से जोड़ सकते हैं, जैसे कि स्व-होस्टेड ओलामा। इन्हें अल्पविराम से अलग करें।",
"updateAiEnabledFailed": "एआई सेटिंग अपडेट नहीं हो सकी",
"updateAiEndpointsFailed": "अनुमत होस्टों को अपडेट नहीं किया जा सका"
"settings": {
"admin": {
"globallyEnabled": {
"label": "एआई सहायक",
"description": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।"
},
"privateEndpoints": {
"label": "निजी एआई होस्ट की अनुमति दी गई"
}
},
"host": {
"enableAiAssistant": {
"label": "एआई असिस्टेंट को सक्षम करें",
"description": "इस होस्ट के टर्मिनल में एआई सहायक को मदद करने की अनुमति दें। यह डिफ़ॉल्ट रूप से बंद है, लेकिन आपके खाते के लिए एआई को सक्षम करना आवश्यक है।"
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "MI asszisztens",
"aiGloballyEnabledDesc": "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.",
"aiPrivateEndpoints": "Engedélyezett privát AI-gazdagépek",
"aiPrivateEndpointsDesc": "A privát hálózatodon található olyan hosztok, amelyekre a felhasználók szolgáltatót irányíthatnak, például egy saját hosztolt Ollama. Vesszővel válaszd el őket.",
"updateAiEnabledFailed": "Nem sikerült frissíteni a mesterséges intelligencia beállítását",
"updateAiEndpointsFailed": "Nem sikerült frissíteni az engedélyezett gazdagépeket"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Menambahkan asisten yang membaca pengaturan Anda dan menyarankan perubahan yang Anda setujui. Membutuhkan penyedia yang Anda berikan, seperti Ollama lokal.",
"aiNote": "Tidak ada data yang dikirim ke mana pun sampai Anda menambahkan penyedia dan mengirim pesan."
},
"admin": {
"aiGloballyEnabled": "Asisten AI",
"aiGloballyEnabledDesc": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang.",
"aiPrivateEndpoints": "Host AI pribadi yang diizinkan",
"aiPrivateEndpointsDesc": "Host di jaringan pribadi Anda yang mungkin ditunjuk pengguna untuk penyedia layanan, seperti Ollama yang dihosting sendiri. Pisahkan dengan koma.",
"updateAiEnabledFailed": "Pengaturan AI tidak dapat diperbarui.",
"updateAiEndpointsFailed": "Tidak dapat memperbarui host yang diizinkan."
"settings": {
"admin": {
"globallyEnabled": {
"label": "Asisten AI",
"description": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang."
},
"privateEndpoints": {
"label": "Host AI pribadi yang diizinkan"
}
},
"host": {
"enableAiAssistant": {
"label": "Aktifkan Asisten AI",
"description": "Izinkan asisten AI untuk membantu di terminal host ini. Secara default dinonaktifkan, dan AI tetap perlu diaktifkan untuk akun Anda."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Assistente basato sull'intelligenza artificiale",
"aiGloballyEnabledDesc": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti.",
"aiPrivateEndpoints": "Host di IA privati consentiti",
"aiPrivateEndpointsDesc": "Host sulla tua rete privata a cui gli utenti possono indirizzare un provider, come ad esempio un'istanza di Ollama autogestita. Separali con delle virgole.",
"updateAiEnabledFailed": "Impossibile aggiornare le impostazioni dell'IA",
"updateAiEndpointsFailed": "Impossibile aggiornare gli host consentiti"
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "設定内容を読み取り、承認した上で変更案を提案するアシスタント機能を追加します。ローカルのOllamaなど、お客様ご自身で手配するプロバイダーが必要です。",
"aiNote": "プロバイダーを追加してメッセージを送信するまで、データはどこにも送信されません。"
},
"admin": {
"aiGloballyEnabled": "AIアシスタント",
"aiGloballyEnabledDesc": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。",
"aiPrivateEndpoints": "プライベートAIホストを許可",
"aiPrivateEndpointsDesc": "ユーザーがプロバイダに指定できる、プライベートネットワーク上のホスト(自己ホスト型のOllamaなど)を指定します。カンマで区切ってください。",
"updateAiEnabledFailed": "AI設定を更新できませんでした",
"updateAiEndpointsFailed": "許可されたホストを更新できませんでした"
"settings": {
"admin": {
"globallyEnabled": {
"label": "AIアシスタント",
"description": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。"
},
"privateEndpoints": {
"label": "プライベートAIホストを許可"
}
},
"host": {
"enableAiAssistant": {
"label": "AIアシスタントを有効にする",
"description": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。"
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "설정을 읽고 사용자가 승인하는 변경 사항을 제안하는 도우미를 추가합니다. 사용자가 제공하는 공급업체(예: 지역 Ollama)가 필요합니다.",
"aiNote": "공급자를 추가하고 메시지를 보내기 전까지는 아무것도 전송되지 않습니다."
},
"admin": {
"aiGloballyEnabled": "AI 비서",
"aiGloballyEnabledDesc": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다.",
"aiPrivateEndpoints": "개인 AI 호스트 허용",
"aiPrivateEndpointsDesc": "사용자가 공급자에게 연결할 수 있는 개인 네트워크의 호스트(예: 자체 호스팅 Ollama)를 나열하세요. 호스트는 쉼표로 구분하세요.",
"updateAiEnabledFailed": "AI 설정을 업데이트할 수 없습니다.",
"updateAiEndpointsFailed": "허용된 호스트를 업데이트할 수 없습니다."
"settings": {
"admin": {
"globallyEnabled": {
"label": "AI 비서",
"description": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다."
},
"privateEndpoints": {
"label": "개인 AI 호스트 허용"
}
},
"host": {
"enableAiAssistant": {
"label": "AI 어시스턴트 활성화",
"description": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "AI-assistent",
"aiGloballyEnabledDesc": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd.",
"aiPrivateEndpoints": "Toegestane privé-AI-hosts",
"aiPrivateEndpointsDesc": "Hosts op uw privénetwerk waarnaar gebruikers een provider kunnen verwijzen, zoals een zelfgehoste Ollama. Scheid ze met komma's.",
"updateAiEnabledFailed": "De AI-instelling kon niet worden bijgewerkt.",
"updateAiEndpointsFailed": "Het lukte niet om de lijst met toegestane hosts bij te werken."
"settings": {
"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"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Legger til en assistent som leser oppsettet ditt og foreslår endringer du godkjenner. Krever en leverandør du oppgir, for eksempel en lokal Ollama.",
"aiNote": "Ingenting sendes noe sted før du legger til en leverandør og sender en melding."
},
"admin": {
"aiGloballyEnabled": "AI-assistent",
"aiGloballyEnabledDesc": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle.",
"aiPrivateEndpoints": "Tillatte private AI-verter",
"aiPrivateEndpointsDesc": "Verter på ditt private nettverk som brukere kan peke en leverandør til, for eksempel en selvhostet Ollama. Skill dem med komma.",
"updateAiEnabledFailed": "Kunne ikke oppdatere AI-innstillingen",
"updateAiEndpointsFailed": "Kunne ikke oppdatere de tillatte vertene"
"settings": {
"admin": {
"globallyEnabled": {
"label": "AI-assistent",
"description": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle."
},
"privateEndpoints": {
"label": "Tillatte private AI-verter"
}
},
"host": {
"enableAiAssistant": {
"label": "Aktiver AI-assistent",
"description": "Tillat at AI-assistenten hjelper til i denne vertens terminal. Av som standard, og krever fortsatt at AI er aktivert for kontoen din."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Asystent AI",
"aiGloballyEnabledDesc": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich.",
"aiPrivateEndpoints": "Dozwolone prywatne hosty AI",
"aiPrivateEndpointsDesc": "Hosty w Twojej sieci prywatnej, na które użytkownicy mogą wskazać dostawcę, na przykład własna instancja Ollamy. Rozdziel je przecinkami.",
"updateAiEnabledFailed": "Nie można zaktualizować ustawień AI",
"updateAiEndpointsFailed": "Nie można zaktualizować dozwolonych hostów"
"settings": {
"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"
}
},
"host": {
"enableAiAssistant": {
"label": "Włącz asystenta AI",
"description": "Zezwól asystentowi AI na pomoc w terminalu tego hosta. Domyślnie wyłączone, ale nadal wymaga włączenia AI na Twoim koncie."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Adiciona um assistente que lê sua configuração e sugere alterações que você aprova. Requer um provedor fornecido por você, como um Ollama local.",
"aiNote": "Nada será enviado até que você adicione um provedor e envie uma mensagem."
},
"admin": {
"aiGloballyEnabled": "assistente de IA",
"aiGloballyEnabledDesc": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.",
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
"aiPrivateEndpointsDesc": "Hosts em sua rede privada que os usuários podem usar como provedores, como um Ollama auto-hospedado. Separe-os com vírgulas.",
"updateAiEnabledFailed": "Não foi possível atualizar as configurações de IA.",
"updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos."
"settings": {
"admin": {
"globallyEnabled": {
"label": "assistente de IA",
"description": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos."
},
"privateEndpoints": {
"label": "Hosts de IA privados permitidos"
}
},
"host": {
"enableAiAssistant": {
"label": "Ativar o assistente de IA",
"description": "Permita que o assistente de IA ajude no terminal deste host. Desativado por padrão, mas ainda precisa que a IA esteja ativada para sua conta."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Adiciona um assistente que lê a sua configuração e sugere alterações que aprova. Requer um fornecedor fornecido por si, como um Ollama local.",
"aiNote": "Nada será enviado até que adicione um fornecedor e envie uma mensagem."
},
"admin": {
"aiGloballyEnabled": "assistente de IA",
"aiGloballyEnabledDesc": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.",
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
"aiPrivateEndpointsDesc": "Hosts na sua rede privada que os utilizadores podem utilizar como fornecedores, como um Ollama auto-hospedado. Separe-os com vírgulas.",
"updateAiEnabledFailed": "Não foi possível atualizar as definições de IA.",
"updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos."
"settings": {
"admin": {
"globallyEnabled": {
"label": "assistente de IA",
"description": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos."
},
"privateEndpoints": {
"label": "Hosts de IA privados permitidos"
}
},
"host": {
"enableAiAssistant": {
"label": "Ativar o assistente de IA",
"description": "Permita que o assistente de IA ajude no terminal deste host. Desativado por defeito, mas ainda precisa que a IA esteja ativada para a sua conta."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"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."
},
"admin": {
"aiGloballyEnabled": "Asistent AI",
"aiGloballyEnabledDesc": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea.",
"aiPrivateEndpoints": "Gazde private AI permise",
"aiPrivateEndpointsDesc": "Gazde din rețeaua privată către care utilizatorii pot direcționa un furnizor, cum ar fi un Ollama auto-găzduit. Separați-le prin virgule.",
"updateAiEnabledFailed": "Nu s-a putut actualiza setarea AI",
"updateAiEndpointsFailed": "Nu s-au putut actualiza gazdele permise"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Asistent AI",
"description": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea."
},
"privateEndpoints": {
"label": "Gazde private AI permise"
}
},
"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."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Добавляет помощника, который считывает ваши настройки и предлагает изменения, которые вы одобряете. Требуется поставщик, которого вы предоставляете, например, локальный Ollama.",
"aiNote": "Ничего никуда не отправляется, пока вы не добавите поставщика услуг и не отправите сообщение."
},
"admin": {
"aiGloballyEnabled": "ИИ-помощник",
"aiGloballyEnabledDesc": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех.",
"aiPrivateEndpoints": "Разрешены частные хосты ИИ",
"aiPrivateEndpointsDesc": "Укажите хосты в вашей частной сети, на которые пользователи могут направлять запросы провайдера, например, самостоятельно размещенный Ollama. Разделяйте их запятыми.",
"updateAiEnabledFailed": "Не удалось обновить настройки ИИ.",
"updateAiEndpointsFailed": "Не удалось обновить список разрешенных хостов."
"settings": {
"admin": {
"globallyEnabled": {
"label": "ИИ-помощник",
"description": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех."
},
"privateEndpoints": {
"label": "Разрешены частные хосты ИИ"
}
},
"host": {
"enableAiAssistant": {
"label": "Включить ИИ-помощника",
"description": "Разрешите помощнику с искусственным интеллектом помогать на терминале этого хоста. По умолчанию отключено, но для вашей учетной записи ИИ все равно должен быть включен."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Додаје асистента који чита ваша подешавања и предлаже измене које одобравате. Потребан је добављач кога ви обезбедите, као што је локални Олама.",
"aiNote": "Ништа се не шаље нигде док не додате провајдера и не пошаљете поруку."
},
"admin": {
"aiGloballyEnabled": "Асистент за вештачку интелигенцију",
"aiGloballyEnabledDesc": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све.",
"aiPrivateEndpoints": "Дозвољени приватни AI хостови",
"aiPrivateEndpointsDesc": "Домаћини на вашој приватној мрежи на које корисници могу усмерити провајдера, као што је самостално хостована Ollama. Раздвојите их зарезима.",
"updateAiEnabledFailed": "Није могуће ажурирати подешавање вештачке интелигенције",
"updateAiEndpointsFailed": "Није могуће ажурирати дозвољене хостове"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Асистент за вештачку интелигенцију",
"description": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све."
},
"privateEndpoints": {
"label": "Дозвољени приватни AI хостови"
}
},
"host": {
"enableAiAssistant": {
"label": "Омогући AI асистент",
"description": "Дозволите вештачкој интелигенцији да помогне у терминалу овог хоста. Искључено је подразумевано, а и даље је потребно да вештачка интелигенција буде омогућена за ваш налог."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Lägger till en assistent som läser din installation och föreslår ändringar som du godkänner. Kräver en leverantör som du tillhandahåller, till exempel en lokal Ollama.",
"aiNote": "Ingenting skickas någonstans förrän du lägger till en leverantör och skickar ett meddelande."
},
"admin": {
"aiGloballyEnabled": "AI-assistent",
"aiGloballyEnabledDesc": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla.",
"aiPrivateEndpoints": "Tillåtna privata AI-värdar",
"aiPrivateEndpointsDesc": "Värdar på ditt privata nätverk som användare kan hänvisa en leverantör till, till exempel en egenhostad Ollama. Separera dem med kommatecken.",
"updateAiEnabledFailed": "Kunde inte uppdatera AI-inställningen",
"updateAiEndpointsFailed": "Kunde inte uppdatera de tillåtna värdarna"
"settings": {
"admin": {
"globallyEnabled": {
"label": "AI-assistent",
"description": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla."
},
"privateEndpoints": {
"label": "Tillåtna privata AI-värdar"
}
},
"host": {
"enableAiAssistant": {
"label": "Aktivera AI-assistenten",
"description": "Tillåt AI-assistenten att hjälpa till i den här värdens terminal. Av som standard och kräver fortfarande att AI är aktiverat för ditt konto."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "เพิ่มผู้ช่วยที่จะอ่านการตั้งค่าของคุณและแนะนำการเปลี่ยนแปลงที่คุณต้องอนุมัติ จำเป็นต้องมีผู้ให้บริการที่คุณจัดหาให้ เช่น Ollama ในพื้นที่ของคุณ",
"aiNote": "จะไม่มีการส่งข้อมูลใดๆ จนกว่าคุณจะเพิ่มผู้ให้บริการและส่งข้อความ"
},
"admin": {
"aiGloballyEnabled": "ผู้ช่วย AI",
"aiGloballyEnabledDesc": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น",
"aiPrivateEndpoints": "อนุญาตให้โฮสต์ AI ส่วนตัว",
"aiPrivateEndpointsDesc": "โฮสต์บนเครือข่ายส่วนตัวของคุณที่ผู้ใช้สามารถชี้ไปยังผู้ให้บริการได้ เช่น Ollama ที่ติดตั้งเอง คั่นด้วยเครื่องหมายจุลภาค",
"updateAiEnabledFailed": "ไม่สามารถอัปเดตการตั้งค่า AI ได้",
"updateAiEndpointsFailed": "ไม่สามารถอัปเดตโฮสต์ที่อนุญาตได้"
"settings": {
"admin": {
"globallyEnabled": {
"label": "ผู้ช่วย AI",
"description": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น"
},
"privateEndpoints": {
"label": "อนุญาตให้โฮสต์ AI ส่วนตัว"
}
},
"host": {
"enableAiAssistant": {
"label": "เปิดใช้งานผู้ช่วย AI",
"description": "อนุญาตให้ผู้ช่วย AI ช่วยเหลือในเทอร์มินัลของโฮสต์นี้ โดยค่าเริ่มต้นจะปิดอยู่ และยังคงต้องเปิดใช้งาน AI สำหรับบัญชีของคุณก่อน"
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Ayarlarınızı okuyan ve onaylamanız halinde değişiklikler öneren bir asistan ekler. Yerel bir Ollama gibi, sizin sağladığınız bir sağlayıcıya ihtiyaç duyar.",
"aiNote": "Bir sağlayıcı ekleyip mesaj gönderene kadar hiçbir şey hiçbir yere gönderilmez."
},
"admin": {
"aiGloballyEnabled": "Yapay zeka asistanı",
"aiGloballyEnabledDesc": "Kullanıcıların yapay zeka asistanını açmasına izin verin. Bu özellik kapalıyken, asistan gizlenir ve herkes için engellenir.",
"aiPrivateEndpoints": "Özel yapay zeka sunucularına izin verildi.",
"aiPrivateEndpointsDesc": "Kullanıcıların bir sağlayıcıya yönlendirebileceği, özel ağınızdaki sunucular (örneğin, kendi sunucunuzda barındırdığınız Ollama). Bunları virgülle ayırın.",
"updateAiEnabledFailed": "Yapay zeka ayarları güncellenemedi.",
"updateAiEndpointsFailed": "İzin verilen sunucular güncellenemedi."
"settings": {
"admin": {
"globallyEnabled": {
"label": "Yapay zeka asistanı",
"description": "Kullanıcıların yapay zeka asistanını açmasına izin verin. Bu özellik kapalıyken, asistan gizlenir ve herkes için engellenir."
},
"privateEndpoints": {
"label": "Özel yapay zeka sunucularına izin verildi."
}
},
"host": {
"enableAiAssistant": {
"label": "Yapay Zeka Asistanını Etkinleştir",
"description": "Bu sunucunun terminalinde yapay zeka asistanının yardımcı olmasına izin verin. Varsayılan olarak kapalıdır ve hesabınız için yapay zekanın etkinleştirilmiş olması gerekir."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Додає помічника, який читає ваші налаштування та пропонує зміни, які ви схвалюєте. Потрібен постачальник, якого ви надаєте, наприклад, місцевий Ollama.",
"aiNote": "Нічого нікуди не надсилається, доки ви не додасте постачальника та не надішлете повідомлення."
},
"admin": {
"aiGloballyEnabled": "Асистент зі штучним інтелектом",
"aiGloballyEnabledDesc": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх.",
"aiPrivateEndpoints": "Дозволені приватні хости ШІ",
"aiPrivateEndpointsDesc": "Хости у вашій приватній мережі, на які користувачі можуть вказувати провайдера, наприклад, Ollama з власним хостингом. Розділяйте їх комами.",
"updateAiEnabledFailed": "Не вдалося оновити налаштування штучного інтелекту",
"updateAiEndpointsFailed": "Не вдалося оновити дозволені хости"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Асистент зі штучним інтелектом",
"description": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх."
},
"privateEndpoints": {
"label": "Дозволені приватні хости ШІ"
}
},
"host": {
"enableAiAssistant": {
"label": "Увімкнути помічника зі штучним інтелектом",
"description": "Дозволити помічнику зі штучним інтелектом допомагати в терміналі цього хоста. Вимкнено за замовчуванням, але все одно потребує ввімкнення штучного інтелекту для вашого облікового запису."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "Thêm một trợ lý đọc cấu hình của bạn và đề xuất các thay đổi mà bạn chấp thuận. Cần có nhà cung cấp do bạn chỉ định, chẳng hạn như Ollama địa phương.",
"aiNote": "Không có dữ liệu nào được gửi đi cho đến khi bạn thêm nhà cung cấp và gửi tin nhắn."
},
"admin": {
"aiGloballyEnabled": "Trợ lý AI",
"aiGloballyEnabledDesc": "Cho phép người dùng bật trợ lý AI. Khi trợ lý này tắt, nó sẽ bị ẩn và chặn đối với tất cả mọi người.",
"aiPrivateEndpoints": "Cho phép máy chủ AI riêng",
"aiPrivateEndpointsDesc": "Các máy chủ trên mạng riêng của bạn mà người dùng có thể trỏ đến nhà cung cấp, chẳng hạn như Ollama tự lưu trữ. Phân tách chúng bằng dấu phẩy.",
"updateAiEnabledFailed": "Không thể cập nhật cài đặt AI.",
"updateAiEndpointsFailed": "Không thể cập nhật các máy chủ được cho phép"
"settings": {
"admin": {
"globallyEnabled": {
"label": "Trợ lý AI",
"description": "Cho phép người dùng bật trợ lý AI. Khi trợ lý này tắt, nó sẽ bị ẩn và chặn đối với tất cả mọi người."
},
"privateEndpoints": {
"label": "Cho phép máy chủ AI riêng"
}
},
"host": {
"enableAiAssistant": {
"label": "Kích hoạt Trợ lý AI",
"description": "Cho phép trợ lý AI hỗ trợ trong thiết bị đầu cuối của máy chủ này. Mặc định là tắt, nhưng bạn vẫn cần bật AI cho tài khoản của mình."
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "启用 AI 助手,它将分析您的配置并提供优化建议。需自行配置服务商,例如本地运行的 Ollama。",
"aiNote": "在您配置服务商并主动发送消息之前,绝不会向外发送任何数据。"
},
"admin": {
"aiGloballyEnabled": "AI 助手",
"aiGloballyEnabledDesc": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。",
"aiPrivateEndpoints": "允许的私有 AI 主机",
"aiPrivateEndpointsDesc": "用户可能将服务提供商指向的私有网络上的主机,例如自托管的 Ollama。用逗号分隔。",
"updateAiEnabledFailed": "无法更新 AI 设置",
"updateAiEndpointsFailed": "无法更新允许的主机列表。"
"settings": {
"admin": {
"globallyEnabled": {
"label": "AI 助手",
"description": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。"
},
"privateEndpoints": {
"label": "允许的私有 AI 主机"
}
},
"host": {
"enableAiAssistant": {
"label": "启用人工智能助手",
"description": "允许 AI 助手在此主机的终端上提供帮助。默认情况下处于关闭状态,但仍需在您的帐户中启用 AI 功能。"
}
}
}
}
+16 -7
View File
@@ -67,12 +67,21 @@
"aiEnableDesc": "添加一個助手,它可以讀取您的設定並提出您批准的更改建議。需要您提供服務提供者,例如當地的 Ollama 服務提供者。",
"aiNote": "在您新增服務提供者並發送訊息之前,任何內容都不會發送到任何地方。"
},
"admin": {
"aiGloballyEnabled": "人工智慧助手",
"aiGloballyEnabledDesc": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。",
"aiPrivateEndpoints": "允許的私有人工智慧主機",
"aiPrivateEndpointsDesc": "使用者可能將服務提供者指向的私有網路上的主機,例如自架的 Ollama。用逗號分隔。",
"updateAiEnabledFailed": "無法更新AI設定",
"updateAiEndpointsFailed": "無法更新允許的主機清單。"
"settings": {
"admin": {
"globallyEnabled": {
"label": "人工智慧助手",
"description": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。"
},
"privateEndpoints": {
"label": "允許的私有人工智慧主機"
}
},
"host": {
"enableAiAssistant": {
"label": "啟用人工智慧助手",
"description": "允許 AI 助理在此主機的終端機上提供協助。預設為關閉狀態,但仍需在您的帳戶中啟用 AI 功能。"
}
}
}
}
+51 -8
View File
@@ -22,7 +22,7 @@
"db:own",
"secrets:own",
"network:outbound",
"audit:read",
"notify:send",
"network:serve",
"ui:surface"
],
@@ -32,7 +32,8 @@
"snippets": "^1.0.0",
"fleets": "^1.0.0",
"ssh-terminal": "^1.0.0",
"automations": "^1.0.0"
"automations": "^1.0.0",
"homepage": "^1.0.0"
},
"requires": [
{
@@ -64,6 +65,11 @@
"service": "automations.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "homepage.items",
"versionRange": "^1.0.0",
"optional": true
}
],
"providesSecret": [
@@ -128,18 +134,55 @@
"settings": {
"admin": [
{
"key": "access",
"type": "custom",
"component": "access"
"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": "assistant",
"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": "assistant"
"component": "providers",
"permission": "manage_providers"
}
]
],
"host": {
"fields": [
{
"key": "enableAiAssistant",
"type": "boolean",
"labelKey": "settings.host.enableAiAssistant.label",
"descriptionKey": "settings.host.enableAiAssistant.description",
"default": false
}
]
}
}
},
"locales": "locales",
@@ -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,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";
+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";
+57 -41
View File
@@ -1,20 +1,20 @@
import { isIP } from "net";
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
import { BlockList, isIP } from "net";
/**
* Where the assistant is allowed to send requests.
*
* Cloud providers are reached through the SSRF-guarded path, which refuses to
* resolve to a private address. That guard is exactly what a self-hosted Ollama
* on localhost trips over, so private destinations are permitted only when an
* admin has named the host. 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.
* 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.
*/
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
@@ -23,35 +23,53 @@ export const DEFAULT_PRIVATE_ALLOWLIST = [
"host.docker.internal",
];
export function parseAllowlist(raw: string | null): string[] {
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [...DEFAULT_PRIVATE_ALLOWLIST];
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;
}
export async function readPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
AI_PRIVATE_ALLOWLIST_KEY,
);
return parseAllowlist(raw);
}
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 SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else needs DNS resolution.
* 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;
@@ -62,13 +80,15 @@ export function isPrivateDestination(rawUrl: string): boolean {
}
const host = normalizeHost(url.hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (isIP(host)) return isBlockedAddress(host);
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 needs the allowlisted-private path. */
/** True when the destination is an allowlisted private host. */
isPrivate: boolean;
reason?: string;
}
@@ -96,23 +116,19 @@ export function evaluateEgress(
}
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
// 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 (!isPrivate) return { allowed: true, isPrivate: false };
if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false };
return {
allowed: false,
isPrivate: true,
reason:
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
reason: PRIVATE_DESTINATION_MESSAGE,
};
}
+15 -7
View File
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { getErrorMessage } from "./errors.js";
import { getAdapter } from "./providers/registry.js";
import type {
ChatMessage,
@@ -6,9 +6,10 @@ import type {
ToolCall,
} from "./providers/types.js";
import { redact, redactToJson } from "./redaction.js";
import { getTool, toolDefinitions } from "./tools/catalog.js";
import { toolDefinitions } from "./tools/catalog.js";
import {
isProposalDraft,
type AiTool,
type ProposalDraft,
type ToolContext,
} from "./tools/types.js";
@@ -36,6 +37,8 @@ export interface EngineOptions {
system: string;
history: ChatMessage[];
context: ToolContext;
/** What the model may call this turn; availableTools() decides. */
tools: AiTool[];
signal?: AbortSignal;
}
@@ -43,7 +46,8 @@ export async function* runAgent(
options: EngineOptions,
): AsyncGenerator<EngineEvent> {
const adapter = getAdapter(options.config.providerType);
const tools = toolDefinitions();
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) {
@@ -89,7 +93,7 @@ export async function* runAgent(
for (const call of calls) {
yield { type: "tool_call", name: call.name, arguments: call.arguments };
const result = await runTool(call, options.context);
const result = await runTool(byName, call, options.context);
if (isProposalDraft(result)) {
// Closes the tool call before the proposal card is emitted. Without
@@ -132,10 +136,14 @@ export async function* runAgent(
};
}
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
const tool = getTool(call.name);
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 the catalog decides what runs.
// A model can emit any name it likes; only what was offered runs.
if (!tool) {
return { error: `Unknown tool: ${call.name}` };
}
+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;
}
+58 -38
View File
@@ -1,28 +1,30 @@
import type { NextFunction, Response } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
} from "../../../../src/backend/database/repositories/factory.js";
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 global is a hard kill switch: when it is off the feature does not
* 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.
*
* Mirrors session sharing's own check, where the global also wins over the
* per-host setting.
* 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 const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
export type SettingsReader = Pick<PluginSettings, "get" | "getUser">;
export async function isAiGloballyEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean(
AI_GLOBAL_ENABLED_KEY,
false,
);
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 {
@@ -30,41 +32,59 @@ export interface AiAccess {
allowReadOnlyCommands: boolean;
}
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
const globalEnabled = await isAiGloballyEnabled();
if (!globalEnabled) {
export async function resolveAiAccess(
settings: SettingsReader,
userId: string,
): Promise<AiAccess> {
if (!(await isAiGloballyEnabled(settings))) {
return { enabled: false, allowReadOnlyCommands: false };
}
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
// Unset means the user was never asked, which is not consent.
const enabled = (await settings.getUser<boolean>(userId, "enabled")) === true;
return {
// Null means the user was never asked, which is not consent.
enabled: preferences?.aiAssistantEnabled === true,
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
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() {
export function createAiGate(
settings: SettingsReader,
currentActor: () => string | undefined,
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
req: GateRequest,
res: GateResponse,
next: (error?: unknown) => void,
): Promise<void> => {
if (!req.userId) {
const userId = currentActor();
if (!userId) {
res.status(401).json({ error: "Authentication required" });
return;
}
const access = await resolveAiAccess(req.userId);
if (!access.enabled) {
res.status(403).json({ error: "The AI assistant is not enabled" });
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);
}
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
next();
};
}
+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",
};
}
+28 -28
View File
@@ -1,38 +1,38 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./services.js";
import { startAiService, stopAiService } from "./routes.js";
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
import { hostImportNormalizer } from "./host-import.js";
import { createAiRepository } from "./repository.js";
import { registerAiRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
startAiService(ctx.http.router());
const repository = await createAiRepository(ctx);
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() below withdraws the offer entirely.
// The resolve is per-user because the keys are stored per-user and encrypted
// under that user's data key, so a borrowed read returns the acting user's
// own default key, never someone else's.
ctx.secrets.offer("api-key", async (userId: string) => {
const repository = createCurrentAiRepository();
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;
});
// 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() {
stopAiService();
}
export async function deactivate() {}
@@ -1,5 +1,4 @@
import Anthropic from "@anthropic-ai/sdk";
import { providerFetch } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -26,7 +25,7 @@ function createClient(config: ProviderConfig): Anthropic {
apiKey: config.apiKey,
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
// Routes the SDK's HTTP through the shared egress guard.
fetch: providerFetch as unknown as typeof fetch,
fetch: config.fetch as unknown as typeof fetch,
});
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, providerFetch, readSseLines } from "./http.js";
import { assertOk, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -104,7 +104,7 @@ export const geminiAdapter: ProviderAdapter = {
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
const response = await providerFetch(url, {
const response = await config.fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -176,7 +176,7 @@ export const geminiAdapter: ProviderAdapter = {
async listModels(config: ProviderConfig): Promise<string[]> {
if (!config.apiKey) return [];
const response = await providerFetch(
const response = await config.fetch(
`${baseFor(config).replace(/\/+$/, "")}/models`,
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
);
+54 -24
View File
@@ -1,33 +1,63 @@
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
import { AiProviderError } from "./types.js";
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.
*
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
* connect time. Allowlisted private hosts cannot use it (its whole job is to
* refuse them), so they use the installed Undici fetch implementation with
* its matching proxy dispatcher -- still respecting proxy configuration.
* 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 async function providerFetch(
url: string,
init: RequestInit,
): Promise<Response> {
const allowlist = await readPrivateAllowlist();
const decision = evaluateEgress(url, 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 (!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");
}
if (decision.isPrivate) {
return fetchWithProxy(url, init);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
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;
}
};
}
export function joinUrl(base: string, path: string): string {
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
import { assertOk, joinUrl, readJsonLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -47,7 +47,7 @@ export const ollamaAdapter: ProviderAdapter = {
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: request.signal,
@@ -117,7 +117,7 @@ export const ollamaAdapter: ProviderAdapter = {
},
async listModels(config: ProviderConfig): Promise<string[]> {
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
const response = await config.fetch(joinUrl(baseFor(config), "api/tags"), {
method: "GET",
});
await assertOk(response, "Ollama");
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
import { assertOk, joinUrl, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -73,7 +73,7 @@ export const openAiAdapter: ProviderAdapter = {
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(url, {
const response = await config.fetch(url, {
method: "POST",
headers,
signal: request.signal,
@@ -145,7 +145,7 @@ export const openAiAdapter: ProviderAdapter = {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
const response = await config.fetch(joinUrl(baseFor(config), "models"), {
method: "GET",
headers,
});
+10
View File
@@ -63,10 +63,20 @@ export type ChatChunk =
| { 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 {
+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
+81 -249
View File
@@ -1,111 +1,35 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginServices(services: PluginServices | null): void {
current = services;
}
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
interface SavedWorkspaces {
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
/**
* The user's saved workspaces, or null when the workspaces plugin is off or
* the user may not use it. Optional: the assistant works without it.
* 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 async function listSavedWorkspaces(
userId: string,
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
if (!current) return null;
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 await current
.get<SavedWorkspaces>("workspaces.saved", { userId })
.list();
return services.providers(service).length > 0;
} catch {
return null;
return false;
}
}
/** Another plugin's graph, as ctx.services.get("network-topology.graph") returns it. */
interface NetworkTopologyGraph {
get: () => Promise<unknown | null>;
}
/**
* The user's saved network topology, or null when the network-topology
* plugin is off or the user may not use it. Optional: the tool disappears
* without it.
*/
export async function getNetworkTopology(
userId: string,
): Promise<unknown | null> {
if (!current) return null;
try {
return await current
.get<NetworkTopologyGraph>("network-topology.graph", { userId })
.get();
} catch {
return null;
}
}
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
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>;
}
/**
* The user's fleets, or null when the fleets plugin is off or the user may
* not use it. Optional: the list_fleets tool disappears without it.
*/
export async function listFleets(
userId: string,
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
if (!current) return null;
try {
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
} catch {
return null;
}
}
/**
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
* surface a failure as an error the user sees, not silently no-op.
*/
function requireFleetsAccess(userId: string): FleetsAccess {
if (!current) {
throw new Error("The fleets plugin is disabled");
}
return current.get<FleetsAccess>("fleets.access", { userId });
}
export async function createFleet(
userId: string,
input: { name: string; description?: string | null },
): Promise<{ id: number; name: string } | null> {
if (!current) return null;
return requireFleetsAccess(userId).create(input);
}
export async function addFleetMember(
userId: string,
fleetId: number,
hostId: number,
): Promise<void> {
await requireFleetsAccess(userId).addMember(fleetId, hostId);
}
export interface SnippetSummary {
id: number;
name: string;
@@ -115,8 +39,7 @@ export interface SnippetSummary {
folder: string | null;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
export interface SnippetsAccess {
list: () => Promise<SnippetSummary[]>;
get: (id: number) => Promise<{
id: number;
@@ -142,115 +65,18 @@ interface SnippetsAccess {
remove: (id: number) => Promise<boolean>;
}
/**
* The user's own snippets, or null when the snippets plugin is off or the
* user may not use it. Optional: the list_snippets tool disappears without
* it, and the propose_*_snippet tools fail with a clear message.
*/
export async function listSnippets(
userId: string,
): Promise<SnippetSummary[] | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.list();
} catch {
return null;
}
}
export async function getSnippet(
userId: string,
id: number,
): Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.get(id);
} catch {
return null;
}
}
/**
* Requires the snippets plugin, unlike the read helpers above: a failed
* proposal apply must surface as an error the user sees, not silently no-op.
*/
function requireSnippetsAccess(userId: string): SnippetsAccess {
if (!current) {
throw new Error("The snippets plugin is disabled");
}
return current.get<SnippetsAccess>("snippets.access", { userId });
}
export async function createSnippet(
userId: string,
input: {
export interface FleetsAccess {
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
},
): Promise<{ id: number; name: string }> {
return requireSnippetsAccess(userId).create(input);
}) => Promise<{ id: number; name: string }>;
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export async function updateSnippet(
userId: string,
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
): Promise<void> {
return requireSnippetsAccess(userId).update(id, changes);
}
export async function deleteSnippet(
userId: string,
id: number,
): Promise<boolean> {
return requireSnippetsAccess(userId).remove(id);
}
/** The terminal's command history, as ctx.services.get("terminal.history") returns it. */
interface TerminalHistory {
list: (
hostId: number,
limit?: number,
) => Promise<Array<{ command: string; executedAt: string }>>;
}
/**
* The user's recent commands on one host, or null when the ssh-terminal
* plugin is off or the user may not read its history.
*/
export async function listCommandHistory(
userId: string,
hostId: number,
limit: number,
): Promise<Array<{ command: string; executedAt: string }> | null> {
if (!current) return null;
try {
return await current
.get<TerminalHistory>("terminal.history", { userId })
.list(hostId, limit);
} catch {
return null;
}
}
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
interface AutomationsAccess {
export interface AutomationsAccess {
list: () => Promise<
Array<{
id: number;
@@ -277,55 +103,61 @@ interface AutomationsAccess {
}) => Promise<{ id: number; name: string }>;
}
function automations(userId: string): Partial<AutomationsAccess> {
if (!current) return {};
try {
return current.get<AutomationsAccess>("automations.access", { userId });
} catch {
return {};
}
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 user's automations, or null when the automations plugin is off or the
* user may not use it.
* 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 async function listAutomations(
userId: string,
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
const list = automations(userId).list;
if (typeof list !== "function") return null;
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 list();
return await read(requireService(services, service));
} catch {
return null;
}
}
export async function getAutomation(
userId: string,
id: number,
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
const get = automations(userId).get;
if (typeof get !== "function") return undefined;
try {
return await get(id);
} catch {
return undefined;
}
}
/**
* Creates an automation through the plugin, which validates it exactly as
* its own route does. Throws when the plugin is off.
*/
export async function createAutomation(
userId: string,
input: Parameters<AutomationsAccess["create"]>[0],
): Promise<{ id: number; name: string }> {
const create = automations(userId).create;
if (typeof create !== "function") {
throw new Error("The automations plugin is not available");
}
return create(input);
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+137
View File
@@ -0,0 +1,137 @@
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(),
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];
+14 -2
View File
@@ -58,9 +58,21 @@ export const FORBIDDEN_DOMAINS = [
"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,
): AiTool[] {
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
}
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(): ToolDefinitionShape[] {
return AI_TOOLS.map((tool) => ({
export function toolDefinitions(
tools: AiTool[] = AI_TOOLS,
): ToolDefinitionShape[] {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
+73 -72
View File
@@ -1,17 +1,8 @@
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import type { Client } from "ssh2";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
import {
createSnippet,
updateSnippet,
deleteSnippet,
getSnippet,
createFleet,
addFleetMember,
createAutomation,
} from "../services.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;
@@ -22,8 +13,8 @@ const COMMAND_TIMEOUT_MS = 60_000;
* 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 repositories a human action uses, scoped to
* the approving user.
* 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 {
@@ -52,10 +43,29 @@ function optionalString(value: unknown): string | null {
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>,
userId: string,
deps: ToolDeps,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
@@ -64,31 +74,25 @@ export async function applyProposal(
switch (kind) {
case "propose_create_host": {
const created = await createCurrentHostRepository().create({
userId,
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) ?? "",
folder: optionalString(payload.folder) ?? "",
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
} as any);
return {
ok: true,
summary: `Created host ${(created as any).name ?? ""}`.trim(),
};
// 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 existing = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!existing) throw new Error("Host not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
@@ -98,37 +102,32 @@ export async function applyProposal(
if (changes.username !== undefined)
updates.username = optionalString(changes.username) ?? "";
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder) ?? "";
if (changes.tags !== undefined) {
updates.tags = JSON.stringify(
Array.isArray(changes.tags) ? changes.tags : [],
);
}
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" };
}
await createCurrentHostRepository().updateForUser(
userId,
hostId,
updates as any,
);
// 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");
const deleted = await createCurrentHostRepository().deleteForUser(
userId,
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 createSnippet(userId, {
const created = await requireService(
deps.services,
SERVICE.snippets,
).create({
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
@@ -144,7 +143,8 @@ export async function applyProposal(
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await getSnippet(userId, snippetId);
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> = {};
@@ -161,23 +161,26 @@ export async function applyProposal(
return { ok: false, summary: "Nothing to change" };
}
await updateSnippet(userId, snippetId, updates);
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 deleteSnippet(userId, 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 fleet = await createFleet(userId, {
const fleets = requireService(deps.services, SERVICE.fleets);
const fleet = await fleets.create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
});
if (!fleet) throw new Error("The fleets plugin is disabled");
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
@@ -185,12 +188,9 @@ export async function applyProposal(
const hostId = Number(raw);
if (!Number.isInteger(hostId) || hostId <= 0) continue;
// Only hosts the approving user owns can join their fleet.
const host = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!host) continue;
await addFleetMember(userId, fleet.id, hostId);
const access = await deps.hosts.checkAccess(hostId, "view");
if (!access.isOwner) continue;
await fleets.addMember(fleet.id, hostId);
added += 1;
}
@@ -203,7 +203,10 @@ export async function applyProposal(
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 createAutomation(userId, {
const created = await requireService(
deps.services,
SERVICE.automations,
).create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: payload.definition,
@@ -222,14 +225,10 @@ export async function applyProposal(
const hostId = requireNumber(payload.hostId, "hostId");
const command = requireString(payload.command, "command");
// resolveHostById, not the raw repository row: it runs the connect-level
// permission check, decrypts auth under the owner's key, and resolves the
// jump host chain. A plain row has none of that, so the SSH factory saw
// an unresolved jumpHosts field and failed the connection.
const host = await resolveHostById(hostId, userId);
if (!host) throw new Error("Host not found");
const result = await runCommandOnHost(host, 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);
}
@@ -245,18 +244,20 @@ export async function applyProposal(
}
/**
* Runs one approved command over the shared SSH pool, mirroring how an
* Runs one approved command over this plugin's SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
host: Record<string, any>,
deps: ToolDeps,
hostId: number,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withSshConnection(
host as any as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
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");
@@ -1,4 +1,5 @@
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
@@ -100,6 +101,7 @@ export const proposeTools: AiTool[] = [
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"),
@@ -125,6 +127,7 @@ export const proposeTools: AiTool[] = [
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
@@ -151,6 +154,7 @@ export const proposeTools: AiTool[] = [
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
@@ -170,6 +174,7 @@ export const proposeTools: AiTool[] = [
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"),
@@ -197,6 +202,7 @@ export const proposeTools: AiTool[] = [
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
service: SERVICE.fleets,
parameters: objectSchema(
{
name: str("Fleet name"),
+79 -78
View File
@@ -1,18 +1,6 @@
import {
createCurrentNotificationChannelRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { num, objectSchema, type AiTool } from "./types.js";
import {
listSavedWorkspaces,
getNetworkTopology,
listSnippets,
listFleets,
listCommandHistory,
listAutomations,
getAutomation,
} from "../services.js";
import { readService, SERVICE } from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -20,35 +8,24 @@ import {
* primary control: a field that is never selected cannot leak.
*/
interface HostSummary {
id: number;
name: string | null;
ip: string | null;
port: number | null;
username: string | null;
folder: string | null;
tags: unknown;
protocol: string | null;
enableTerminal: boolean | null;
enableFileManager: boolean | null;
enableTunnel: boolean | null;
enableDocker: boolean | null;
function splitTags(tags: string | null): string[] {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
function toHostSummary(host: Record<string, any>): HostSummary {
function toHostSummary(host: PluginHostSummary) {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip ?? null,
port: host.port ?? null,
username: host.username ?? null,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder ?? null,
tags: host.tags ?? null,
protocol: host.protocol ?? null,
enableTerminal: host.enableTerminal ?? null,
enableFileManager: host.enableFileManager ?? null,
enableTunnel: host.enableTunnel ?? null,
enableDocker: host.enableDocker ?? null,
tags: splitTags(host.tags),
authType: host.authType,
};
}
@@ -60,10 +37,8 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await createCurrentHostRepository().listByUserId(
context.userId,
);
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
const hosts = await context.deps.hosts.list();
return { hosts: hosts.map(toHostSummary) };
},
},
{
@@ -76,40 +51,43 @@ export const readTools: AiTool[] = [
["hostId"],
),
handler: async (args, context) => {
const hostId = Number(args.hostId);
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
const host = await context.deps.hosts.get(Number(args.hostId));
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host as any) };
return { host: toHostSummary(host) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
"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 listFleets(context.userId);
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,
})),
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. Unavailable if the snippets plugin is disabled.",
"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 listSnippets(context.userId);
const snippets = await readService(
context.deps.services,
SERVICE.snippets,
(snippetsAccess) => snippetsAccess.list(),
);
if (!snippets) return { unavailable: true, snippets: [] };
return {
snippets: snippets.map((snippet) => ({
@@ -127,9 +105,14 @@ export const readTools: AiTool[] = [
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 listAutomations(context.userId);
const automations = await readService(
context.deps.services,
SERVICE.automations,
(automationsAccess) => automationsAccess.list(),
);
if (!automations) return { automations: [], unavailable: true };
return {
automations: automations.map((automation) => ({
@@ -150,6 +133,7 @@ export const readTools: AiTool[] = [
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"),
@@ -157,11 +141,15 @@ export const readTools: AiTool[] = [
["automationId"],
),
handler: async (args, context) => {
const automation = await getAutomation(
context.userId,
Number(args.automationId),
const result = await readService(
context.deps.services,
SERVICE.automations,
async (automationsAccess) => ({
automation: await automationsAccess.get(Number(args.automationId)),
}),
);
if (automation === undefined) return { unavailable: true };
if (result === null) return { unavailable: true };
const { automation } = result;
if (!automation) return { error: "Automation not found" };
return {
automation: {
@@ -178,9 +166,14 @@ export const readTools: AiTool[] = [
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 listSavedWorkspaces(context.userId);
const workspaces = await readService(
context.deps.services,
SERVICE.workspaces,
(saved) => saved.list(),
);
if (workspaces === null) return { workspaces: [], unavailable: true };
return { workspaces };
},
@@ -192,16 +185,13 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentNotificationChannelRepository().listNotificationChannels(
context.userId,
);
const channels = await context.deps.notify.channels();
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled === 1,
enabled: channel.enabled,
})),
};
},
@@ -210,13 +200,17 @@ export const readTools: AiTool[] = [
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 createCurrentHomepageItemRepository().listByUserId(
context.userId,
const items = await readService(
context.deps.services,
SERVICE.homepage,
(homepage) => homepage.list(),
);
if (items === null) return { items: [], unavailable: true };
return {
items: (items as any[]).map((item) => ({
items: items.map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
@@ -229,6 +223,7 @@ export const readTools: AiTool[] = [
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"),
@@ -240,14 +235,15 @@ export const readTools: AiTool[] = [
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const hostId = Number(args.hostId);
// Ownership is enforced here rather than trusted from the model.
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
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 listCommandHistory(context.userId, hostId, limit);
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) };
},
@@ -256,11 +252,16 @@ export const readTools: AiTool[] = [
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 topology = await getNetworkTopology(context.userId);
if (topology === null) return { topology: null, unavailable: true };
return { topology };
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 };
},
},
];
+15 -1
View File
@@ -1,17 +1,31 @@
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"
>;
export interface ToolContext {
/** Always taken from the verified JWT, never from model input. */
/** 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;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
+3 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
import { setAiOptIn } from "./ai-api";
/**
* Asks once, plainly.
@@ -17,7 +17,8 @@ export function AiAssistantStep() {
function apply(next: boolean) {
setEnabled(next);
setAssistantEnabledForUser(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.
+5 -5
View File
@@ -1,20 +1,20 @@
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 } from "@/components/button";
import { Textarea } from "@/components/textarea";
import { saveUserPreferences } from "@/main-axios";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
Textarea,
} from "@termix/plugin-sdk/ui";
import {
getAiConversation,
getAiProviders,
getAiStatus,
setAiOptIn,
type AiProposal,
type AiProvider,
} from "./ai-api";
@@ -133,7 +133,7 @@ export function AiPanel({ activeTab }: { activeTab?: string | null }) {
// 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 saveUserPreferences({ aiAssistantEnabled: true });
await setAiOptIn(true);
}
if (!status.globallyEnabled) return;
@@ -1,18 +1,18 @@
import { getErrorMessage } from "@/lib/error-message";
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 } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
} from "@termix/plugin-sdk/ui";
import {
createAiProvider,
deleteAiProvider,
+2 -2
View File
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
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 "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
import { fieldLabel, toolLabel } from "./labels";
+67 -70
View File
@@ -1,4 +1,35 @@
import { authApi, handleApiError } from "@/main-axios";
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";
@@ -52,17 +83,17 @@ export interface AiStatus {
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/plugin-api/ai/status")).data;
return (await api().get("/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
throw apiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
return (await api().get("/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
throw apiError(error, "list AI providers");
}
}
@@ -74,10 +105,9 @@ export async function createAiProvider(input: {
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/plugin-api/ai/providers", input)).data
.provider;
return (await api().post("/providers", input)).data.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
throw apiError(error, "create AI provider");
}
}
@@ -92,18 +122,17 @@ export async function updateAiProvider(
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
.provider;
return (await api().patch(`/providers/${id}`, input)).data.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
throw apiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/providers/${id}`);
await api().delete(`/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
throw apiError(error, "delete AI provider");
}
}
@@ -122,27 +151,25 @@ export async function probeAiModels(input: {
warning?: string;
}> {
try {
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
return (await api().post("/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
throw apiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
.models;
return (await api().get(`/providers/${id}/models`)).data.models;
} catch (error) {
throw handleApiError(error, "list provider models");
throw apiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/plugin-api/ai/conversations")).data
.conversations;
return (await api().get("/conversations")).data.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
throw apiError(error, "list AI conversations");
}
}
@@ -152,17 +179,17 @@ export async function getAiConversation(id: number): Promise<{
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
return (await api().get(`/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
throw apiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
await api().delete(`/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
throw apiError(error, "delete AI conversation");
}
}
@@ -170,17 +197,17 @@ export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
return (await api().post(`/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
throw apiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
await api().post(`/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
throw apiError(error, "reject AI proposal");
}
}
@@ -195,53 +222,23 @@ export async function markAiProposalRunInTerminal(
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
{
hostId,
summary,
},
)
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
hostId,
summary,
})
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
throw apiError(error, "mark AI proposal run in terminal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
/** Turns the assistant on or off for the current user. */
export async function setAiOptIn(enabled: boolean): Promise<void> {
try {
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
await api().put("/opt-in", { enabled });
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
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;
}
+28 -12
View File
@@ -12,11 +12,12 @@ import type {
import { AiPanel } from "./AiPanel";
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
import { AiAssistantStep } from "./AiAssistantStep";
import { AiAccessSettings } from "./settings/AiAccessSettings";
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
import { getAiStatus } from "./ai-api";
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
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";
@@ -57,6 +58,9 @@ function TerminalSidePanel({
}
export function activate(app: TermixApp): void {
setAiApp(app);
app.onDispose(() => setAiApp(null));
let globallyEnabled = false;
let userEnabled = false;
const surface: (() => void)[] = [];
@@ -99,10 +103,14 @@ export function activate(app: TermixApp): void {
}),
);
// The per-host switch is this plugin's host setting.
const offeredOnHost = (context: Record<string, unknown>) =>
userEnabled &&
(context.host as { enableAiAssistant?: boolean } | undefined)
?.enableAiAssistant === true;
(
context.host as
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
| undefined
)?.pluginSettings?.ai?.enableAiAssistant === true;
surface.push(
app.registerSlotContribution("terminal.toolbar", {
@@ -171,15 +179,23 @@ export function activate(app: TermixApp): void {
{ permission: "services.use" },
);
app.registerSettingsComponent("access", AiAccessSettings);
app.registerSettingsComponent("assistant", AiUserSettings);
app.registerSettingsComponent("providers", ProvidersSetting);
applyStatus();
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.addEventListener("hiddenRailTabsChanged", refresh);
// The admin switch and the user's own choice are settings, saved from
// the settings screen, so re-read when plugin state or focus changes too.
let lastFocusRefresh = Date.now();
const onFocus = () => {
if (Date.now() - lastFocusRefresh < 30_000) return;
lastFocusRefresh = Date.now();
refresh();
};
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
for (const event of events) window.addEventListener(event, refresh);
window.addEventListener("focus", onFocus);
app.onDispose(() => {
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
for (const event of events) window.removeEventListener(event, refresh);
window.removeEventListener("focus", onFocus);
});
}
@@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
setAiGloballyEnabled,
setAiPrivateEndpoints,
} from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/**
* The instance-wide switch and the private address allowlist. Stored in the
* assistant's own backend rather than plugin settings, so this saves on
* change instead of through the page's Save.
*/
export function AiAccessSettings() {
const { t } = useTranslation();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [endpoints, setEndpoints] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
void Promise.allSettled([
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
]).then(([enabledResult, endpointsResult]) => {
if (cancelled) return;
if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value);
setEndpoints(
endpointsResult.status === "fulfilled" ? endpointsResult.value : [],
);
});
return () => {
cancelled = true;
};
}, []);
const toggle = async (next: boolean) => {
setEnabled(next);
try {
await setAiGloballyEnabled(next);
notifyAiStatusChanged();
} catch {
setEnabled(!next);
toast.error(t("admin.updateAiEnabledFailed"));
}
};
const saveEndpoints = async (hosts: string[]) => {
const previous = endpoints ?? [];
setEndpoints(hosts);
try {
setEndpoints(await setAiPrivateEndpoints(hosts));
} catch {
setEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
};
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("admin.aiGloballyEnabled")}
description={t("admin.aiGloballyEnabledDesc")}
>
<FakeSwitch checked={enabled} onChange={(next) => void toggle(next)} />
</SettingRow>
{enabled && endpoints !== null && (
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.aiPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.aiPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={endpoints.join(", ")}
placeholder="localhost, 127.0.0.1"
onBlur={(event) =>
void saveEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
)}
</div>
);
}
@@ -1,112 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui";
import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { getAiStatus } from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/** The rail id the assistant registers under. */
export const AI_RAIL_ID = "ai";
/**
* Turns the assistant on or off for one person. Off also hides its rail
* entry, so declining removes the feature from view rather than leaving an
* inert button behind. Stored in core user preferences, where it has always
* lived, so it roams with the rest of a user's settings.
*/
export function setAssistantEnabledForUser(enabled: boolean): void {
const hidden = readHiddenRailTabs();
if (enabled) hidden.delete(AI_RAIL_ID);
else hidden.add(AI_RAIL_ID);
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
saveUserPreferences({
aiAssistantEnabled: enabled,
hiddenRailTabs: serialized,
})
.catch(() => {
// The local choice still applies if the write fails.
})
.finally(() => notifyAiStatusChanged());
}
export function AiUserSettings() {
const { t } = useTranslation();
const [status, setStatus] = useState<{
globallyEnabled: boolean;
enabled: boolean;
readOnly: boolean;
} | null>(null);
useEffect(() => {
let cancelled = false;
getAiStatus()
.then((next) => {
if (!cancelled) {
setStatus({
globallyEnabled: next.globallyEnabled,
enabled: next.enabled,
readOnly: next.allowReadOnlyCommands,
});
}
})
.catch(() => {
if (!cancelled) {
setStatus({
globallyEnabled: false,
enabled: false,
readOnly: false,
});
}
});
return () => {
cancelled = true;
};
}, []);
// A user who cannot have the feature is not told about it.
if (!status?.globallyEnabled) {
return (
<p className="text-xs text-muted-foreground">
{t("settings.assistantUnavailable")}
</p>
);
}
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("ai.enableTitle")}
description={t("ai.enableDescription")}
>
<FakeSwitch
checked={status.enabled}
onChange={(enabled) => {
setStatus({ ...status, enabled });
setAssistantEnabledForUser(enabled);
}}
/>
</SettingRow>
{status.enabled && (
<SettingRow
label={t("ai.readOnlyCommands")}
description={t("ai.readOnlyCommandsDescription")}
>
<FakeSwitch
checked={status.readOnly}
onChange={(readOnly) => {
setStatus({ ...status, readOnly });
saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch(
() => {},
);
}}
/>
</SettingRow>
)}
</div>
);
}
@@ -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()} />
);
}
@@ -7,7 +7,7 @@ import {
} from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
import { AiMessage } from "../AiMessage";
import { AiToolCall } from "../AiToolCall";
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
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 "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import {
markAiProposalRunInTerminal,
rejectAiProposal,
@@ -7,7 +7,7 @@ interface TerminalHost {
id: number | string;
name?: string;
ip?: string;
enableAiAssistant?: boolean;
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
[key: string]: unknown;
}
@@ -1,68 +0,0 @@
import { useEffect, useState } from "react";
export interface AiAvailability {
/** Admin kill switch. When false the feature does not exist for anyone. */
globallyEnabled: boolean;
/** Global switch and the user's own opt-in, which is what the rail needs. */
userEnabled: boolean;
/** False until the status call answers, so nothing flashes in or out. */
loaded: boolean;
}
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/**
* One source of truth for whether any AI surface should render.
*
* The admin global is a hard kill switch: when it is off the assistant is
* hidden everywhere for everyone, including the tab bar, command palette,
* mobile bar, and the navigation visibility toggles, so nobody can reach or
* even see an entry the server would refuse anyway.
*/
export function useAiAvailability(): AiAvailability {
const [state, setState] = useState<AiAvailability>({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
useEffect(() => {
let cancelled = false;
const refresh = () => {
import("./ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setState({
globallyEnabled: status.globallyEnabled,
userEnabled: status.globallyEnabled && status.enabled,
loaded: true,
});
})
.catch(() => {
if (!cancelled)
setState({
globallyEnabled: false,
userEnabled: false,
loaded: true,
});
});
};
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
// The profile toggle and onboarding still fire this older event.
window.addEventListener("hiddenRailTabsChanged", refresh);
return () => {
cancelled = true;
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
};
}, []);
return state;
}
+3 -14
View File
@@ -1,18 +1,8 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useCallback, useRef, useState } from "react";
import { authApi } from "@/main-axios";
import { aiApp } from "./app-ref";
import type { AiProposal } from "./ai-api";
/**
* Reuses whatever base authApi resolved to, so the stream follows the same
* dev proxy, Electron localhost and reverse-proxy base path rules as every
* other call instead of hardcoding an origin.
*/
function streamUrl(): string {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
return `${base}/ai/chat/stream`;
}
/**
* Drives the chat stream.
*
@@ -86,10 +76,9 @@ export function useAiStream() {
let toolSequence = 0;
try {
const response = await fetch(streamUrl(), {
const response = await aiApp().fetch("chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
message: input.message,
+22 -28
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { invokeAction } from "@termix/plugin-sdk/frontend";
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
/**
* Backs the @-mention picker in the composer.
@@ -39,11 +37,14 @@ export function activeMentionQuery(
return { query, start: at };
}
/** The automations plugin's list, or nothing when it is not running. */
async function listAutomationsForMentions(): Promise<
{ id: number; name: string }[]
> {
const result = await invokeAction("automations.list");
/**
* 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 }[])
: [];
@@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise<
export function useMentions(enabled: boolean) {
const [items, setItems] = useState<MentionItem[]>([]);
const { hosts } = useHosts();
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const [hosts, snippets, automations] = await Promise.allSettled([
getSSHHosts(),
getSnippets(),
// Through the automations plugin's action, so the assistant works
// without it and never imports its code.
listAutomationsForMentions(),
const [snippets, automations] = await Promise.allSettled([
listFromAction("snippets.list"),
listFromAction("automations.list"),
]);
if (cancelled) return;
const collected: MentionItem[] = [];
const collected: MentionItem[] = hosts.map((host) => ({
kind: "host" as const,
id: Number(host.id),
label: host.name || host.ip,
detail: host.ip,
}));
if (hosts.status === "fulfilled") {
for (const host of (hosts.value ?? []) as any[]) {
collected.push({
kind: "host",
id: host.id,
label: host.name || host.ip,
detail: host.ip,
});
}
}
if (snippets.status === "fulfilled") {
for (const snippet of (snippets.value ?? []) as any[]) {
for (const snippet of snippets.value) {
collected.push({
kind: "snippet",
id: snippet.id,
@@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) {
}
}
if (automations.status === "fulfilled") {
for (const automation of (automations.value ?? []) as any[]) {
for (const automation of automations.value) {
collected.push({
kind: "automation",
id: automation.id,
@@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) {
return () => {
cancelled = true;
};
}, [enabled]);
}, [enabled, hosts]);
const search = useCallback(
(query: string): MentionItem[] => {
+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();
});
});
@@ -1,39 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
listCommandHistory,
setPluginServices,
readService,
requireService,
serviceAvailable,
SERVICE,
} from "../../src/backend/services.js";
afterEach(() => setPluginServices(null));
function services(provided: Record<string, object>) {
return {
providers: (service: string) => (service in provided ? [""] : []),
get: vi.fn((service: string) => provided[service]),
} as never;
}
describe("listCommandHistory", () => {
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 get = vi.fn(() => ({ list }));
setPluginServices({ get, provide: vi.fn() } as never);
const history = await readService(
services({ [SERVICE.history]: { list } }),
SERVICE.history,
(terminal) => terminal.list(4, 25),
);
const history = await listCommandHistory("user-1", 4, 25);
expect(get).toHaveBeenCalledWith("terminal.history", { userId: "user-1" });
expect(list).toHaveBeenCalledWith(4, 25);
expect(history).toEqual([
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
]);
});
it("answers null while the terminal is off or refuses the user", async () => {
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
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();
setPluginServices({
get: () => ({
list: async () => {
throw new Error("unavailable");
},
}),
provide: vi.fn(),
} as never);
expect(await listCommandHistory("user-1", 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",
);
});
});
+9 -9
View File
@@ -101,22 +101,22 @@ describe("evaluateEgress", () => {
});
describe("parseAllowlist", () => {
it("falls back to the defaults when unset or malformed", () => {
it("falls back to the defaults when the setting is not text", () => {
expect(parseAllowlist(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
});
it("normalises stored entries", () => {
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
"localhost",
"10.0.0.5",
]);
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([]);
expect(parseAllowlist("")).toEqual([]);
});
});
+12 -17
View File
@@ -8,22 +8,6 @@ vi.mock("../../src/backend/providers/registry.js", () => ({
getAdapter: () => ({ streamChat, listModels: async () => [] }),
}));
vi.mock("../../src/backend/tools/catalog.js", () => ({
getTool: (name: string) =>
name === "list_hosts"
? {
name: "list_hosts",
description: "List hosts",
category: "read",
parameters: { type: "object", properties: {} },
handler,
}
: undefined,
toolDefinitions: () => [
{ name: "list_hosts", description: "List hosts", parameters: {} },
],
}));
const { runAgent } = await import("../../src/backend/engine.js");
function chunks(...values: ChatChunk[]) {
@@ -33,14 +17,25 @@ function chunks(...values: ChatChunk[]) {
}
const BASE = {
config: { providerType: "ollama" as const },
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[] = []) {
+109 -76
View File
@@ -1,94 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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";
const settingsRepository = { getBoolean: vi.fn() };
const userPreferenceRepository = { findByUserId: vi.fn() };
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => settingsRepository,
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
}));
const { isAiGloballyEnabled, resolveAiAccess } =
await import("../../src/backend/gating.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", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults to off so upgrading an install enables nothing", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
await isAiGloballyEnabled();
expect(settingsRepository.getBoolean).toHaveBeenCalledWith(
"ai_globally_enabled",
false,
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 global is off", async () => {
settingsRepository.getBoolean.mockResolvedValue(false);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
it("blocks everyone when the admin switch is off", async () => {
const reader = settings(
{ globallyEnabled: false },
{ enabled: true, allowReadOnlyCommands: true },
);
const access = await resolveAiAccess("user-1");
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);
expect(access.allowReadOnlyCommands).toBe(false);
// The kill switch short-circuits, so the preference is never consulted.
expect(userPreferenceRepository.findByUserId).not.toHaveBeenCalled();
});
it("blocks a user who has not enabled it", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: false,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats never-asked as not enabled", async () => {
// Null means the user was never shown the choice, which is not consent.
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: null,
});
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("treats a missing preference row as not enabled", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue(null);
expect((await resolveAiAccess("user-1")).enabled).toBe(false);
});
it("allows only when both gates are open", async () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: true,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
expect(access.allowReadOnlyCommands).toBe(true);
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 () => {
settingsRepository.getBoolean.mockResolvedValue(true);
userPreferenceRepository.findByUserId.mockResolvedValue({
aiAssistantEnabled: true,
aiReadOnlyCommands: null,
});
const access = await resolveAiAccess("user-1");
expect(access.enabled).toBe(true);
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: { aiAccess?: unknown } = {};
await createAiGate(
settings({ globallyEnabled: true }, { enabled: true }),
() => "user-1",
)(req, response(), next);
expect(next).toHaveBeenCalledWith();
expect(req.aiAccess).toEqual({
enabled: true,
allowReadOnlyCommands: false,
});
});
});
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more