mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: finish the ai plugin (B18)
This commit is contained in:
1 parent
6b4d1432ab
commit
21937a6b02
193 files changed
+23429
-4244
No files matched your search
@@ -161,7 +161,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
@@ -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
@@ -330,6 +330,13 @@
|
|||||||
"when": 1790272734438,
|
"when": 1790272734438,
|
||||||
"tag": "0046_rich_logan",
|
"tag": "0046_rich_logan",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 47,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790274704817,
|
||||||
|
"tag": "0047_red_silver_sable",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -337,6 +337,13 @@
|
|||||||
"when": 1790272733784,
|
"when": 1790272733784,
|
||||||
"tag": "0047_mighty_butterfly",
|
"tag": "0047_mighty_butterfly",
|
||||||
"breakpoints": true
|
"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
@@ -309,6 +309,13 @@
|
|||||||
"when": 1790272733108,
|
"when": 1790272733108,
|
||||||
"tag": "0043_curvy_red_shift",
|
"tag": "0043_curvy_red_shift",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 44,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790274703680,
|
||||||
|
"tag": "0044_gorgeous_yellow_claw",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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) |
|
| `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 |
|
| `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**) |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| `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,
|
`/automations/webhook/<token>` is rewritten to it in both nginx configs,
|
||||||
because external systems hold that URL.
|
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
|
#### Host status is core
|
||||||
|
|
||||||
The status dot in the host list, the dashboard and the homepage widget is
|
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.registry.*` | none | **A1** |
|
||||||
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
|
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
|
||||||
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
|
||||||
|
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
|
||||||
| `ctx.disposables.add` | none | **A1** |
|
| `ctx.disposables.add` | none | **A1** |
|
||||||
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
|
||||||
| `ctx.currentActor()` | none | **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.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||||
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
|
||||||
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
|
| `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.hosts.status.*` | `hosts:read` | **B16** |
|
||||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
|
||||||
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
|
| `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.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
| `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
|
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
|
||||||
generic check for a capability no other ctx member wraps. Unlike every other
|
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`
|
passes the admin allowlist it reads through `ctx.settings.readCore`
|
||||||
(`notification_private_endpoint_allowlist` joined `CORE_SETTINGS_ALLOWLIST`).
|
(`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
|
`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
|
`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
|
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
|
## Legacy core imports: the debt D1 removes
|
||||||
|
|
||||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||||
(B2), remote-desktop (B14), docker (B15), host-metrics (B16) and automations
|
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
|
||||||
(B17), which import
|
(B17) and ai (B18), which import
|
||||||
nothing from core. The others still reach core by relative
|
nothing from core. The others still reach core by relative
|
||||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
`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 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin code | **Error** | 0 | - |
|
| The shell importing plugin code | **Error** | 0 | - |
|
||||||
| A plugin frontend importing core through `@/` | Warning | 64 files | D1 |
|
| A plugin frontend importing core through `@/` | Warning | 55 files | D1 |
|
||||||
| A plugin importing core by relative path | Warning | 12 files | D1 |
|
| A plugin importing core by relative path | Warning | 4 files | D1 |
|
||||||
| A plugin importing another plugin's source | Warning | 0 files | - |
|
| A plugin importing another plugin's source | Warning | 0 files | - |
|
||||||
|
|
||||||
A warning does not fail a build, so the counts are held by
|
A warning does not fail a build, so the counts are held by
|
||||||
|
|||||||
@@ -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
|
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
|
there: it writes host rows only and never plugin settings. Owner: D1, with
|
||||||
the generic host-settings export and import.
|
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
|
- **B17 (automations), for B20:** the wake-on-lan step calls the optional
|
||||||
`wake-on-lan.send` v1 service, `wake(hostId): Promise<void>`, as the
|
`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
|
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"
|
skipped silently. Owner: none unless a background "is this user unlocked"
|
||||||
check is added to the SDK.
|
check is added to the SDK.
|
||||||
- **B17 (automations):** AI's `list_notification_channels` read tool still reads
|
- **B18 (ai), for B19:** the assistant's `list_homepage_items` tool calls
|
||||||
notification channels through core's repository by relative import; it
|
the optional `homepage.items` v1 service, `list(): Promise<Array<{ id,
|
||||||
can use `ctx.notify.channels()` now. Owner: B18.
|
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
|
## Manual checks after 2.9.0
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,19 @@ export interface PluginServices {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSecrets {
|
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: (
|
offer: (
|
||||||
key: string,
|
key: string,
|
||||||
resolve: (userId: string) => Promise<string | null> | string | null,
|
resolve: (userId: string) => Promise<string | null> | string | null,
|
||||||
@@ -580,6 +593,14 @@ export interface PluginHosts {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
patch: PluginHostUpdateInput,
|
patch: PluginHostUpdateInput,
|
||||||
) => Promise<PluginHostRecord | null>;
|
) => 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,
|
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
|
||||||
* not the narrow list() summary). For a plugin that scans its own hosts in
|
* 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.
|
* private is refused, and redirects are never followed.
|
||||||
*/
|
*/
|
||||||
allowPrivateHosts?: readonly string[];
|
allowPrivateHosts?: readonly string[];
|
||||||
|
/** Aborts the request, and a streamed body, when the caller gives up. */
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -600,6 +600,11 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
* desktop app's connected remote server, anything else is `api`.
|
* desktop app's connected remote server, anything else is `api`.
|
||||||
*/
|
*/
|
||||||
apiFor: (origin?: unknown) => PluginApiClient;
|
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>. */
|
/** WebSocket URL and auth subprotocols for /plugin-ws/<id>/<path>. */
|
||||||
wsUrl: (
|
wsUrl: (
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export interface FakePluginContext {
|
|||||||
channelIds: number[];
|
channelIds: number[];
|
||||||
notification: PluginNotification;
|
notification: PluginNotification;
|
||||||
}>;
|
}>;
|
||||||
|
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
|
||||||
|
secretStore: Map<string, string>;
|
||||||
/** Every ctx.fetch call, in order. */
|
/** Every ctx.fetch call, in order. */
|
||||||
fetches: Array<{ url: string; init?: PluginFetchInit }>;
|
fetches: Array<{ url: string; init?: PluginFetchInit }>;
|
||||||
/** Every ctx.audit.record entry, in order. */
|
/** Every ctx.audit.record entry, in order. */
|
||||||
@@ -290,6 +292,7 @@ export function createFakeContext(
|
|||||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||||
const notifications: FakePluginContext["notifications"] = [];
|
const notifications: FakePluginContext["notifications"] = [];
|
||||||
const fetches: FakePluginContext["fetches"] = [];
|
const fetches: FakePluginContext["fetches"] = [];
|
||||||
|
const secretStore = new Map<string, string>();
|
||||||
const audits: FakePluginContext["audits"] = [];
|
const audits: FakePluginContext["audits"] = [];
|
||||||
const activities: FakePluginContext["activities"] = [];
|
const activities: FakePluginContext["activities"] = [];
|
||||||
const trackedSessions: number[] = [];
|
const trackedSessions: number[] = [];
|
||||||
@@ -491,6 +494,14 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
|
|
||||||
secrets: {
|
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: () => {},
|
offer: () => {},
|
||||||
withdraw: () => false,
|
withdraw: () => false,
|
||||||
getShared: async () => null,
|
getShared: async () => null,
|
||||||
@@ -629,6 +640,11 @@ export function createFakeContext(
|
|||||||
hostRecordsById.set(hostId, updated);
|
hostRecordsById.set(hostId, updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
|
delete: async (hostId: number): Promise<boolean> => {
|
||||||
|
const existed =
|
||||||
|
hostRecordsById.delete(hostId) || hostsById.delete(hostId);
|
||||||
|
return existed;
|
||||||
|
},
|
||||||
listOwned: async (): Promise<PluginHostRecord[]> => [
|
listOwned: async (): Promise<PluginHostRecord[]> => [
|
||||||
...hostRecordsById.values(),
|
...hostRecordsById.values(),
|
||||||
],
|
],
|
||||||
@@ -836,6 +852,7 @@ export function createFakeContext(
|
|||||||
credentialReads,
|
credentialReads,
|
||||||
notifications,
|
notifications,
|
||||||
fetches,
|
fetches,
|
||||||
|
secretStore,
|
||||||
audits,
|
audits,
|
||||||
activities,
|
activities,
|
||||||
trackedSessions,
|
trackedSessions,
|
||||||
@@ -1076,6 +1093,10 @@ export function createMockCtx(
|
|||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.update(hostId, patch);
|
return ctx.hosts.update(hostId, patch);
|
||||||
},
|
},
|
||||||
|
delete: async (hostId) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.delete(hostId);
|
||||||
|
},
|
||||||
listOwned: async () => {
|
listOwned: async () => {
|
||||||
require("hosts:write");
|
require("hosts:write");
|
||||||
return ctx.hosts.listOwned();
|
return ctx.hosts.listOwned();
|
||||||
@@ -1220,6 +1241,22 @@ export function createMockCtx(
|
|||||||
return ctx.fetch(url, init);
|
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: {
|
capabilities: {
|
||||||
has: async (capability) => granted.has(capability),
|
has: async (capability) => granted.has(capability),
|
||||||
require: async (capability) => {
|
require: async (capability) => {
|
||||||
|
|||||||
@@ -22,7 +22,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"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": {
|
"ai": {
|
||||||
"title": "Assistant",
|
"title": "Assistant",
|
||||||
@@ -95,13 +112,5 @@
|
|||||||
"aiEnableTitle": "Turn it on",
|
"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.",
|
"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."
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Niks word êrens heen gestuur totdat jy 'n verskaffer byvoeg en 'n boodskap stuur nie."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "KI-assistent",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Toegelate private KI-gashere",
|
"label": "KI-assistent",
|
||||||
"aiPrivateEndpointsDesc": "Gashere op jou privaat netwerk waarna gebruikers 'n verskaffer kan verwys, soos 'n self-gehoste Ollama. Skei hulle met kommas.",
|
"description": "Laat gebruikers die KI-assistent aanskakel. Terwyl dit af is, is die assistent vir almal versteek en geblokkeer."
|
||||||
"updateAiEnabledFailed": "Kon nie die KI-instelling opdateer nie",
|
},
|
||||||
"updateAiEndpointsFailed": "Kon nie die toegelate gashere opdateer nie"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "يُضيف هذا البرنامج مساعدًا يقرأ إعداداتك ويقترح تغييرات توافق عليها. يتطلب ذلك مزود خدمة تُحدده بنفسك، مثل مزود خدمة Ollama محلي.",
|
"aiEnableDesc": "يُضيف هذا البرنامج مساعدًا يقرأ إعداداتك ويقترح تغييرات توافق عليها. يتطلب ذلك مزود خدمة تُحدده بنفسك، مثل مزود خدمة Ollama محلي.",
|
||||||
"aiNote": "لن يتم إرسال أي شيء إلى أي مكان حتى تقوم بإضافة مزود خدمة وإرسال رسالة."
|
"aiNote": "لن يتم إرسال أي شيء إلى أي مكان حتى تقوم بإضافة مزود خدمة وإرسال رسالة."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "مساعد الذكاء الاصطناعي",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي",
|
"label": "مساعد الذكاء الاصطناعي",
|
||||||
"aiPrivateEndpointsDesc": "المضيفون على شبكتك الخاصة الذين يمكن للمستخدمين توجيه مزود الخدمة إليهم، مثل Ollama المستضاف ذاتيًا. افصل بينهم بفواصل.",
|
"description": "اسمح للمستخدمين بتفعيل مساعد الذكاء الاصطناعي. عند إيقاف تشغيله، يكون المساعد مخفيًا ومحظورًا عن الجميع."
|
||||||
"updateAiEnabledFailed": "تعذر تحديث إعدادات الذكاء الاصطناعي",
|
},
|
||||||
"updateAiEndpointsFailed": "تعذر تحديث المضيفين المسموح بهم"
|
"privateEndpoints": {
|
||||||
|
"label": "المضيفون الخاصون المسموح بهم للذكاء الاصطناعي"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "تفعيل مساعد الذكاء الاصطناعي",
|
||||||
|
"description": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Добавя асистент, който чете настройките ви и предлага промени, които одобрявате. Необходим е доставчик, когото вие предоставяте, например местен Ollama.",
|
"aiEnableDesc": "Добавя асистент, който чете настройките ви и предлага промени, които одобрявате. Необходим е доставчик, когото вие предоставяте, например местен Ollama.",
|
||||||
"aiNote": "Нищо не се изпраща никъде, докато не добавите доставчик и не изпратите съобщение."
|
"aiNote": "Нищо не се изпраща никъде, докато не добавите доставчик и не изпратите съобщение."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Асистент с изкуствен интелект",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Разрешени частни хостове с изкуствен интелект",
|
"label": "Асистент с изкуствен интелект",
|
||||||
"aiPrivateEndpointsDesc": "Хостове във вашата частна мрежа, към които потребителите могат да насочат доставчик, като например самостоятелно хостван Ollama. Разделете ги със запетаи.",
|
"description": "Позволете на потребителите да включат асистента с изкуствен интелект. Докато това е изключено, асистентът е скрит и блокиран за всички."
|
||||||
"updateAiEnabledFailed": "Не можа да се актуализира настройката за изкуствен интелект",
|
},
|
||||||
"updateAiEndpointsFailed": "Не можаха да се актуализират разрешените хостове"
|
"privateEndpoints": {
|
||||||
|
"label": "Разрешени частни хостове с изкуствен интелект"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "Активиране на AI асистент",
|
||||||
|
"description": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "একজন সহকারী যোগ করে যে আপনার সেটআপ পড়ে এবং আপনার অনুমোদিত পরিবর্তনের পরামর্শ দেয়। এর জন্য আপনার সরবরাহ করা একজন প্রদানকারীর প্রয়োজন, যেমন একজন স্থানীয় ওলামা।",
|
"aiEnableDesc": "একজন সহকারী যোগ করে যে আপনার সেটআপ পড়ে এবং আপনার অনুমোদিত পরিবর্তনের পরামর্শ দেয়। এর জন্য আপনার সরবরাহ করা একজন প্রদানকারীর প্রয়োজন, যেমন একজন স্থানীয় ওলামা।",
|
||||||
"aiNote": "আপনি একজন প্রোভাইডার যোগ করে মেসেজ না পাঠানো পর্যন্ত কোথাও কিছু পাঠানো হয় না।"
|
"aiNote": "আপনি একজন প্রোভাইডার যোগ করে মেসেজ না পাঠানো পর্যন্ত কোথাও কিছু পাঠানো হয় না।"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "এআই সহকারী",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "ব্যক্তিগত এআই হোস্ট অনুমোদিত",
|
"label": "এআই সহকারী",
|
||||||
"aiPrivateEndpointsDesc": "আপনার প্রাইভেট নেটওয়ার্কের হোস্টগুলো, যেগুলোকে ব্যবহারকারীরা কোনো প্রোভাইডারের দিকে নির্দেশ করতে পারে, যেমন একটি সেলফ-হোস্টেড ওলামা। এগুলোকে কমা দিয়ে আলাদা করুন।",
|
"description": "ব্যবহারকারীদের এআই অ্যাসিস্ট্যান্ট চালু করার সুযোগ দিন। এটি বন্ধ থাকা অবস্থায়, অ্যাসিস্ট্যান্টটি সকলের জন্য লুকানো এবং ব্লক করা থাকবে।"
|
||||||
"updateAiEnabledFailed": "এআই সেটিং আপডেট করা যায়নি",
|
},
|
||||||
"updateAiEndpointsFailed": "অনুমোদিত হোস্টগুলি আপডেট করা যায়নি"
|
"privateEndpoints": {
|
||||||
|
"label": "ব্যক্তিগত এআই হোস্ট অনুমোদিত"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "এআই অ্যাসিস্ট্যান্ট সক্রিয় করুন",
|
||||||
|
"description": "এই হোস্টের টার্মিনালে সাহায্য করার জন্য এআই অ্যাসিস্ট্যান্টকে অনুমতি দিন। এটি ডিফল্টরূপে বন্ধ থাকে এবং আপনার অ্যাকাউন্টের জন্য এআই চালু করা প্রয়োজন।"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "No s'envia res enlloc fins que no afegiu un proveïdor i envieu un missatge."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Assistent d'IA",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Amfitrions d'IA privats permesos",
|
"label": "Assistent d'IA",
|
||||||
"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.",
|
"description": "Permet que els usuaris activin l'assistent d'IA. Mentre estigui desactivat, l'assistent està ocult i bloquejat per a tothom."
|
||||||
"updateAiEnabledFailed": "No s'ha pogut actualitzar la configuració de la IA",
|
},
|
||||||
"updateAiEndpointsFailed": "No s'han pogut actualitzar els amfitrions permesos"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Nic se nikam neodesílá, dokud nepřidáte poskytovatele a neodešlete zprávu."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Asistent s umělou inteligencí",
|
"admin": {
|
||||||
"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.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Povolení soukromí hostitelé s umělou inteligencí",
|
"label": "Asistent 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.",
|
"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."
|
||||||
"updateAiEnabledFailed": "Nepodařilo se aktualizovat nastavení umělé inteligence",
|
},
|
||||||
"updateAiEndpointsFailed": "Nepodařilo se aktualizovat povolené hostitele"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Intet sendes nogen steder, før du tilføjer en udbyder og sender en besked."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI-assistent",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Tilladte private AI-værter",
|
"label": "AI-assistent",
|
||||||
"aiPrivateEndpointsDesc": "Værter på dit private netværk, som brugerne kan henvise til en udbyder, f.eks. en selvhostet Ollama. Adskil dem med kommaer.",
|
"description": "Lad brugerne aktivere AI-assistenten. Når dette er slået fra, er assistenten skjult og blokeret for alle."
|
||||||
"updateAiEnabledFailed": "Kunne ikke opdatere AI-indstillingen",
|
},
|
||||||
"updateAiEndpointsFailed": "Kunne ikke opdatere de tilladte værter"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Es wird nichts gesendet, bis Sie einen Anbieter hinzufügen und eine Nachricht senden."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "KI-Assistent",
|
"admin": {
|
||||||
"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.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Zulässige private KI-Hosts",
|
"label": "KI-Assistent",
|
||||||
"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.",
|
"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."
|
||||||
"updateAiEnabledFailed": "Die KI-Einstellungen konnten nicht aktualisiert werden.",
|
},
|
||||||
"updateAiEndpointsFailed": "Die Liste der zulässigen Hosts konnte nicht aktualisiert werden."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Προσθέτει έναν βοηθό που διαβάζει τη ρύθμισή σας και προτείνει αλλαγές που εγκρίνετε. Χρειάζεται έναν πάροχο που παρέχετε, όπως ένα τοπικό Ollama.",
|
"aiEnableDesc": "Προσθέτει έναν βοηθό που διαβάζει τη ρύθμισή σας και προτείνει αλλαγές που εγκρίνετε. Χρειάζεται έναν πάροχο που παρέχετε, όπως ένα τοπικό Ollama.",
|
||||||
"aiNote": "Τίποτα δεν αποστέλλεται πουθενά μέχρι να προσθέσετε έναν πάροχο και να στείλετε ένα μήνυμα."
|
"aiNote": "Τίποτα δεν αποστέλλεται πουθενά μέχρι να προσθέσετε έναν πάροχο και να στείλετε ένα μήνυμα."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Βοηθός Τεχνητής Νοημοσύνης",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης",
|
"label": "Βοηθός Τεχνητής Νοημοσύνης",
|
||||||
"aiPrivateEndpointsDesc": "Κεντρικοί υπολογιστές στο ιδιωτικό σας δίκτυο στους οποίους οι χρήστες μπορούν να υποδείξουν έναν πάροχο, όπως ένα αυτοφιλοξενούμενο Ollama. Διαχωρίστε τους με κόμματα.",
|
"description": "Επιτρέψτε στους χρήστες να ενεργοποιήσουν τον βοηθό τεχνητής νοημοσύνης. Όσο είναι απενεργοποιημένος, ο βοηθός είναι κρυφός και αποκλεισμένος για όλους."
|
||||||
"updateAiEnabledFailed": "Δεν ήταν δυνατή η ενημέρωση της ρύθμισης AI",
|
},
|
||||||
"updateAiEndpointsFailed": "Δεν ήταν δυνατή η ενημέρωση των επιτρεπόμενων κεντρικών υπολογιστών"
|
"privateEndpoints": {
|
||||||
|
"label": "Επιτρεπόμενοι ιδιωτικοί κεντρικοί υπολογιστές τεχνητής νοημοσύνης"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης",
|
||||||
|
"description": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "No se envía nada a ningún sitio hasta que añadas un proveedor y envíes un mensaje."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Asistente de IA",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
|
"label": "Asistente de IA",
|
||||||
"aiPrivateEndpointsDesc": "Hosts en tu red privada a los que los usuarios pueden dirigir un proveedor, como un servidor Ollama autogestionado. Sepáralos con comas.",
|
"description": "Permita que los usuarios activen el asistente de IA. Mientras esté desactivado, el asistente permanecerá oculto y bloqueado para todos."
|
||||||
"updateAiEnabledFailed": "No se pudo actualizar la configuración de la IA.",
|
},
|
||||||
"updateAiEndpointsFailed": "No se pudieron actualizar los hosts permitidos."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Lisää avustajan, joka lukee asetuksesi ja ehdottaa hyväksymiäsi muutoksia. Edellyttää toimittamaasi palveluntarjoajaa, kuten paikallisen Ollaman.",
|
"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."
|
"aiNote": "Mitään ei lähetetä mihinkään ennen kuin lisäät palveluntarjoajan ja lähetät viestin."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Tekoälyavustaja",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Sallitut yksityiset tekoälyisännät",
|
"label": "Tekoälyavustaja",
|
||||||
"aiPrivateEndpointsDesc": "Yksityisen verkon isännät, joihin käyttäjät voivat ohjata palveluntarjoajan, kuten itse isännöity Ollama. Erota ne pilkuilla.",
|
"description": "Salli käyttäjien ottaa tekoälyavustaja käyttöön. Kun tämä on pois päältä, avustaja on piilotettu ja estetty kaikilta."
|
||||||
"updateAiEnabledFailed": "Tekoälyasetuksen päivittäminen epäonnistui",
|
},
|
||||||
"updateAiEndpointsFailed": "Sallittujen isäntien päivittäminen epäonnistui"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Aucun message n'est envoyé tant que vous n'avez pas ajouté de fournisseur et envoyé un message."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Assistant IA",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Hôtes d'IA privés autorisés",
|
"label": "Assistant IA",
|
||||||
"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.",
|
"description": "Autoriser les utilisateurs à activer l'assistant IA. Lorsqu'il est désactivé, l'assistant est masqué et inaccessible à tous."
|
||||||
"updateAiEnabledFailed": "Impossible de mettre à jour les paramètres d'IA",
|
},
|
||||||
"updateAiEndpointsFailed": "Impossible de mettre à jour les hôtes autorisés."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "מוסיף עוזר שקורא את ההגדרות שלך ומציע שינויים שאתה מאשר. נדרש ספק שאתה מספק, כגון אולמה מקומי.",
|
"aiEnableDesc": "מוסיף עוזר שקורא את ההגדרות שלך ומציע שינויים שאתה מאשר. נדרש ספק שאתה מספק, כגון אולמה מקומי.",
|
||||||
"aiNote": "שום דבר לא נשלח לשום מקום עד שתוסיף ספק ותשלח הודעה."
|
"aiNote": "שום דבר לא נשלח לשום מקום עד שתוסיף ספק ותשלח הודעה."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "עוזר בינה מלאכותית",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "מארחים פרטיים של בינה מלאכותית מורשים",
|
"label": "עוזר בינה מלאכותית",
|
||||||
"aiPrivateEndpointsDesc": "מארחים ברשת הפרטית שלך שמשתמשים עשויים להפנות אליהם ספק, כגון Ollama המאוחסן בעצמו. הפרד ביניהם באמצעות פסיקים.",
|
"description": "אפשר למשתמשים להפעיל את עוזר הבינה המלאכותית. כאשר זה כבוי, העוזר מוסתר וחסום עבור כולם."
|
||||||
"updateAiEnabledFailed": "לא ניתן היה לעדכן את הגדרת הבינה המלאכותית",
|
},
|
||||||
"updateAiEndpointsFailed": "לא ניתן היה לעדכן את המארחים המותרים"
|
"privateEndpoints": {
|
||||||
|
"label": "מארחים פרטיים של בינה מלאכותית מורשים"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "הפעל את עוזר הבינה המלאכותית",
|
||||||
|
"description": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "यह एक सहायक सुविधा जोड़ता है जो आपके सेटअप को पढ़ता है और आपके द्वारा स्वीकृत किए जाने वाले परिवर्तनों का सुझाव देता है। इसके लिए आपको एक प्रदाता उपलब्ध कराना होगा, जैसे कि स्थानीय ओलामा।",
|
"aiEnableDesc": "यह एक सहायक सुविधा जोड़ता है जो आपके सेटअप को पढ़ता है और आपके द्वारा स्वीकृत किए जाने वाले परिवर्तनों का सुझाव देता है। इसके लिए आपको एक प्रदाता उपलब्ध कराना होगा, जैसे कि स्थानीय ओलामा।",
|
||||||
"aiNote": "जब तक आप कोई प्रदाता नहीं जोड़ते और संदेश नहीं भेजते, तब तक कुछ भी कहीं नहीं भेजा जाता।"
|
"aiNote": "जब तक आप कोई प्रदाता नहीं जोड़ते और संदेश नहीं भेजते, तब तक कुछ भी कहीं नहीं भेजा जाता।"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "एआई सहायक",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "निजी एआई होस्ट की अनुमति दी गई",
|
"label": "एआई सहायक",
|
||||||
"aiPrivateEndpointsDesc": "आपके निजी नेटवर्क पर मौजूद होस्ट, जिन्हें उपयोगकर्ता किसी प्रदाता से जोड़ सकते हैं, जैसे कि स्व-होस्टेड ओलामा। इन्हें अल्पविराम से अलग करें।",
|
"description": "उपयोगकर्ताओं को एआई सहायक चालू करने की अनुमति दें। जब यह बंद होगा, तो सहायक सभी के लिए छिपा हुआ और अवरुद्ध रहेगा।"
|
||||||
"updateAiEnabledFailed": "एआई सेटिंग अपडेट नहीं हो सकी",
|
},
|
||||||
"updateAiEndpointsFailed": "अनुमत होस्टों को अपडेट नहीं किया जा सका"
|
"privateEndpoints": {
|
||||||
|
"label": "निजी एआई होस्ट की अनुमति दी गई"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "एआई असिस्टेंट को सक्षम करें",
|
||||||
|
"description": "इस होस्ट के टर्मिनल में एआई सहायक को मदद करने की अनुमति दें। यह डिफ़ॉल्ट रूप से बंद है, लेकिन आपके खाते के लिए एआई को सक्षम करना आवश्यक है।"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Semmi sem kerül sehova, amíg nem adsz hozzá egy szolgáltatót és nem küldesz üzenetet."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "MI asszisztens",
|
"admin": {
|
||||||
"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.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Engedélyezett privát AI-gazdagépek",
|
"label": "MI asszisztens",
|
||||||
"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.",
|
"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."
|
||||||
"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"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Tidak ada data yang dikirim ke mana pun sampai Anda menambahkan penyedia dan mengirim pesan."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Asisten AI",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Host AI pribadi yang diizinkan",
|
"label": "Asisten AI",
|
||||||
"aiPrivateEndpointsDesc": "Host di jaringan pribadi Anda yang mungkin ditunjuk pengguna untuk penyedia layanan, seperti Ollama yang dihosting sendiri. Pisahkan dengan koma.",
|
"description": "Izinkan pengguna untuk mengaktifkan asisten AI. Saat dinonaktifkan, asisten akan disembunyikan dan diblokir untuk semua orang."
|
||||||
"updateAiEnabledFailed": "Pengaturan AI tidak dapat diperbarui.",
|
},
|
||||||
"updateAiEndpointsFailed": "Tidak dapat memperbarui host yang diizinkan."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Non viene inviato nulla finché non si aggiunge un provider e si invia un messaggio."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Assistente basato sull'intelligenza artificiale",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Host di IA privati consentiti",
|
"label": "Assistente basato sull'intelligenza artificiale",
|
||||||
"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.",
|
"description": "Consenti agli utenti di attivare l'assistente AI. Quando è disattivato, l'assistente è nascosto e bloccato per tutti."
|
||||||
"updateAiEnabledFailed": "Impossibile aggiornare le impostazioni dell'IA",
|
},
|
||||||
"updateAiEndpointsFailed": "Impossibile aggiornare gli host consentiti"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "設定内容を読み取り、承認した上で変更案を提案するアシスタント機能を追加します。ローカルのOllamaなど、お客様ご自身で手配するプロバイダーが必要です。",
|
"aiEnableDesc": "設定内容を読み取り、承認した上で変更案を提案するアシスタント機能を追加します。ローカルのOllamaなど、お客様ご自身で手配するプロバイダーが必要です。",
|
||||||
"aiNote": "プロバイダーを追加してメッセージを送信するまで、データはどこにも送信されません。"
|
"aiNote": "プロバイダーを追加してメッセージを送信するまで、データはどこにも送信されません。"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AIアシスタント",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "プライベートAIホストを許可",
|
"label": "AIアシスタント",
|
||||||
"aiPrivateEndpointsDesc": "ユーザーがプロバイダに指定できる、プライベートネットワーク上のホスト(自己ホスト型のOllamaなど)を指定します。カンマで区切ってください。",
|
"description": "ユーザーがAIアシスタントをオンにできるようにします。オフになっている間は、アシスタントは非表示になり、誰からも利用できなくなります。"
|
||||||
"updateAiEnabledFailed": "AI設定を更新できませんでした",
|
},
|
||||||
"updateAiEndpointsFailed": "許可されたホストを更新できませんでした"
|
"privateEndpoints": {
|
||||||
|
"label": "プライベートAIホストを許可"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "AIアシスタントを有効にする",
|
||||||
|
"description": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "설정을 읽고 사용자가 승인하는 변경 사항을 제안하는 도우미를 추가합니다. 사용자가 제공하는 공급업체(예: 지역 Ollama)가 필요합니다.",
|
"aiEnableDesc": "설정을 읽고 사용자가 승인하는 변경 사항을 제안하는 도우미를 추가합니다. 사용자가 제공하는 공급업체(예: 지역 Ollama)가 필요합니다.",
|
||||||
"aiNote": "공급자를 추가하고 메시지를 보내기 전까지는 아무것도 전송되지 않습니다."
|
"aiNote": "공급자를 추가하고 메시지를 보내기 전까지는 아무것도 전송되지 않습니다."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI 비서",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "개인 AI 호스트 허용",
|
"label": "AI 비서",
|
||||||
"aiPrivateEndpointsDesc": "사용자가 공급자에게 연결할 수 있는 개인 네트워크의 호스트(예: 자체 호스팅 Ollama)를 나열하세요. 호스트는 쉼표로 구분하세요.",
|
"description": "사용자가 AI 비서를 켜고 끌 수 있도록 하세요. AI 비서가 꺼져 있으면 모든 사용자에게 비서 기능이 숨겨지고 차단됩니다."
|
||||||
"updateAiEnabledFailed": "AI 설정을 업데이트할 수 없습니다.",
|
},
|
||||||
"updateAiEndpointsFailed": "허용된 호스트를 업데이트할 수 없습니다."
|
"privateEndpoints": {
|
||||||
|
"label": "개인 AI 호스트 허용"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "AI 어시스턴트 활성화",
|
||||||
|
"description": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Er wordt niets verzonden totdat je een provider toevoegt en een bericht verstuurt."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI-assistent",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Toegestane privé-AI-hosts",
|
"label": "AI-assistent",
|
||||||
"aiPrivateEndpointsDesc": "Hosts op uw privénetwerk waarnaar gebruikers een provider kunnen verwijzen, zoals een zelfgehoste Ollama. Scheid ze met komma's.",
|
"description": "Gebruikers kunnen de AI-assistent inschakelen. Wanneer deze is uitgeschakeld, is de assistent verborgen en voor iedereen geblokkeerd."
|
||||||
"updateAiEnabledFailed": "De AI-instelling kon niet worden bijgewerkt.",
|
},
|
||||||
"updateAiEndpointsFailed": "Het lukte niet om de lijst met toegestane hosts bij te werken."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Ingenting sendes noe sted før du legger til en leverandør og sender en melding."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI-assistent",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Tillatte private AI-verter",
|
"label": "AI-assistent",
|
||||||
"aiPrivateEndpointsDesc": "Verter på ditt private nettverk som brukere kan peke en leverandør til, for eksempel en selvhostet Ollama. Skill dem med komma.",
|
"description": "La brukere slå på AI-assistenten. Mens dette er av, er assistenten skjult og blokkert for alle."
|
||||||
"updateAiEnabledFailed": "Kunne ikke oppdatere AI-innstillingen",
|
},
|
||||||
"updateAiEndpointsFailed": "Kunne ikke oppdatere de tillatte vertene"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Dopóki nie dodasz dostawcy i nie wyślesz wiadomości, nic nie zostanie wysłane."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Asystent AI",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Dozwolone prywatne hosty AI",
|
"label": "Asystent 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.",
|
"description": "Pozwól użytkownikom włączyć asystenta AI. Gdy ta opcja jest wyłączona, asystent jest ukryty i zablokowany dla wszystkich."
|
||||||
"updateAiEnabledFailed": "Nie można zaktualizować ustawień AI",
|
},
|
||||||
"updateAiEndpointsFailed": "Nie można zaktualizować dozwolonych hostów"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Nada será enviado até que você adicione um provedor e envie uma mensagem."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "assistente de IA",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
|
"label": "assistente de IA",
|
||||||
"aiPrivateEndpointsDesc": "Hosts em sua rede privada que os usuários podem usar como provedores, como um Ollama auto-hospedado. Separe-os com vírgulas.",
|
"description": "Permita que os usuários ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos."
|
||||||
"updateAiEnabledFailed": "Não foi possível atualizar as configurações de IA.",
|
},
|
||||||
"updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Nada será enviado até que adicione um fornecedor e envie uma mensagem."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "assistente de IA",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Hosts de IA privados permitidos",
|
"label": "assistente de IA",
|
||||||
"aiPrivateEndpointsDesc": "Hosts na sua rede privada que os utilizadores podem utilizar como fornecedores, como um Ollama auto-hospedado. Separe-os com vírgulas.",
|
"description": "Permita que os utilizadores ativem o assistente de IA. Quando estiver desativado, o assistente ficará oculto e bloqueado para todos."
|
||||||
"updateAiEnabledFailed": "Não foi possível atualizar as definições de IA.",
|
},
|
||||||
"updateAiEndpointsFailed": "Não foi possível atualizar os hosts permitidos."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Nimic nu este trimis nicăieri până nu adăugați un furnizor și nu trimiteți un mesaj."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Asistent AI",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Gazde private AI permise",
|
"label": "Asistent AI",
|
||||||
"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.",
|
"description": "Permiteți utilizatorilor să activeze asistentul AI. Cât timp este dezactivat, asistentul este ascuns și blocat pentru toată lumea."
|
||||||
"updateAiEnabledFailed": "Nu s-a putut actualiza setarea AI",
|
},
|
||||||
"updateAiEndpointsFailed": "Nu s-au putut actualiza gazdele permise"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Добавляет помощника, который считывает ваши настройки и предлагает изменения, которые вы одобряете. Требуется поставщик, которого вы предоставляете, например, локальный Ollama.",
|
"aiEnableDesc": "Добавляет помощника, который считывает ваши настройки и предлагает изменения, которые вы одобряете. Требуется поставщик, которого вы предоставляете, например, локальный Ollama.",
|
||||||
"aiNote": "Ничего никуда не отправляется, пока вы не добавите поставщика услуг и не отправите сообщение."
|
"aiNote": "Ничего никуда не отправляется, пока вы не добавите поставщика услуг и не отправите сообщение."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "ИИ-помощник",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Разрешены частные хосты ИИ",
|
"label": "ИИ-помощник",
|
||||||
"aiPrivateEndpointsDesc": "Укажите хосты в вашей частной сети, на которые пользователи могут направлять запросы провайдера, например, самостоятельно размещенный Ollama. Разделяйте их запятыми.",
|
"description": "Разрешите пользователям включить голосового помощника. Если он выключен, помощник скрыт и заблокирован для всех."
|
||||||
"updateAiEnabledFailed": "Не удалось обновить настройки ИИ.",
|
},
|
||||||
"updateAiEndpointsFailed": "Не удалось обновить список разрешенных хостов."
|
"privateEndpoints": {
|
||||||
|
"label": "Разрешены частные хосты ИИ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "Включить ИИ-помощника",
|
||||||
|
"description": "Разрешите помощнику с искусственным интеллектом помогать на терминале этого хоста. По умолчанию отключено, но для вашей учетной записи ИИ все равно должен быть включен."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Додаје асистента који чита ваша подешавања и предлаже измене које одобравате. Потребан је добављач кога ви обезбедите, као што је локални Олама.",
|
"aiEnableDesc": "Додаје асистента који чита ваша подешавања и предлаже измене које одобравате. Потребан је добављач кога ви обезбедите, као што је локални Олама.",
|
||||||
"aiNote": "Ништа се не шаље нигде док не додате провајдера и не пошаљете поруку."
|
"aiNote": "Ништа се не шаље нигде док не додате провајдера и не пошаљете поруку."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Асистент за вештачку интелигенцију",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Дозвољени приватни AI хостови",
|
"label": "Асистент за вештачку интелигенцију",
|
||||||
"aiPrivateEndpointsDesc": "Домаћини на вашој приватној мрежи на које корисници могу усмерити провајдера, као што је самостално хостована Ollama. Раздвојите их зарезима.",
|
"description": "Дозволите корисницима да укључе вештачку интелигенцију асистента. Док је ово искључено, асистент је скривен и блокиран за све."
|
||||||
"updateAiEnabledFailed": "Није могуће ажурирати подешавање вештачке интелигенције",
|
},
|
||||||
"updateAiEndpointsFailed": "Није могуће ажурирати дозвољене хостове"
|
"privateEndpoints": {
|
||||||
|
"label": "Дозвољени приватни AI хостови"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "Омогући AI асистент",
|
||||||
|
"description": "Дозволите вештачкој интелигенцији да помогне у терминалу овог хоста. Искључено је подразумевано, а и даље је потребно да вештачка интелигенција буде омогућена за ваш налог."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Ingenting skickas någonstans förrän du lägger till en leverantör och skickar ett meddelande."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI-assistent",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Tillåtna privata AI-värdar",
|
"label": "AI-assistent",
|
||||||
"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.",
|
"description": "Låt användare aktivera AI-assistenten. Medan detta är avstängt är assistenten dold och blockerad för alla."
|
||||||
"updateAiEnabledFailed": "Kunde inte uppdatera AI-inställningen",
|
},
|
||||||
"updateAiEndpointsFailed": "Kunde inte uppdatera de tillåtna värdarna"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "เพิ่มผู้ช่วยที่จะอ่านการตั้งค่าของคุณและแนะนำการเปลี่ยนแปลงที่คุณต้องอนุมัติ จำเป็นต้องมีผู้ให้บริการที่คุณจัดหาให้ เช่น Ollama ในพื้นที่ของคุณ",
|
"aiEnableDesc": "เพิ่มผู้ช่วยที่จะอ่านการตั้งค่าของคุณและแนะนำการเปลี่ยนแปลงที่คุณต้องอนุมัติ จำเป็นต้องมีผู้ให้บริการที่คุณจัดหาให้ เช่น Ollama ในพื้นที่ของคุณ",
|
||||||
"aiNote": "จะไม่มีการส่งข้อมูลใดๆ จนกว่าคุณจะเพิ่มผู้ให้บริการและส่งข้อความ"
|
"aiNote": "จะไม่มีการส่งข้อมูลใดๆ จนกว่าคุณจะเพิ่มผู้ให้บริการและส่งข้อความ"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "ผู้ช่วย AI",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "อนุญาตให้โฮสต์ AI ส่วนตัว",
|
"label": "ผู้ช่วย AI",
|
||||||
"aiPrivateEndpointsDesc": "โฮสต์บนเครือข่ายส่วนตัวของคุณที่ผู้ใช้สามารถชี้ไปยังผู้ให้บริการได้ เช่น Ollama ที่ติดตั้งเอง คั่นด้วยเครื่องหมายจุลภาค",
|
"description": "อนุญาตให้ผู้ใช้เปิดใช้งานผู้ช่วย AI ได้ เมื่อปิดใช้งานอยู่ ผู้ช่วยจะถูกซ่อนและบล็อกไม่ให้ใครเห็น"
|
||||||
"updateAiEnabledFailed": "ไม่สามารถอัปเดตการตั้งค่า AI ได้",
|
},
|
||||||
"updateAiEndpointsFailed": "ไม่สามารถอัปเดตโฮสต์ที่อนุญาตได้"
|
"privateEndpoints": {
|
||||||
|
"label": "อนุญาตให้โฮสต์ AI ส่วนตัว"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "เปิดใช้งานผู้ช่วย AI",
|
||||||
|
"description": "อนุญาตให้ผู้ช่วย AI ช่วยเหลือในเทอร์มินัลของโฮสต์นี้ โดยค่าเริ่มต้นจะปิดอยู่ และยังคงต้องเปิดใช้งาน AI สำหรับบัญชีของคุณก่อน"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"aiNote": "Bir sağlayıcı ekleyip mesaj gönderene kadar hiçbir şey hiçbir yere gönderilmez."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Yapay zeka asistanı",
|
"admin": {
|
||||||
"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.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Özel yapay zeka sunucularına izin verildi.",
|
"label": "Yapay zeka asistanı",
|
||||||
"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.",
|
"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."
|
||||||
"updateAiEnabledFailed": "Yapay zeka ayarları güncellenemedi.",
|
},
|
||||||
"updateAiEndpointsFailed": "İzin verilen sunucular güncellenemedi."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "Додає помічника, який читає ваші налаштування та пропонує зміни, які ви схвалюєте. Потрібен постачальник, якого ви надаєте, наприклад, місцевий Ollama.",
|
"aiEnableDesc": "Додає помічника, який читає ваші налаштування та пропонує зміни, які ви схвалюєте. Потрібен постачальник, якого ви надаєте, наприклад, місцевий Ollama.",
|
||||||
"aiNote": "Нічого нікуди не надсилається, доки ви не додасте постачальника та не надішлете повідомлення."
|
"aiNote": "Нічого нікуди не надсилається, доки ви не додасте постачальника та не надішлете повідомлення."
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Асистент зі штучним інтелектом",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Дозволені приватні хости ШІ",
|
"label": "Асистент зі штучним інтелектом",
|
||||||
"aiPrivateEndpointsDesc": "Хости у вашій приватній мережі, на які користувачі можуть вказувати провайдера, наприклад, Ollama з власним хостингом. Розділяйте їх комами.",
|
"description": "Дозвольте користувачам увімкнути помічника зі штучним інтелектом. Коли це вимкнено, помічник прихований і заблокований для всіх."
|
||||||
"updateAiEnabledFailed": "Не вдалося оновити налаштування штучного інтелекту",
|
},
|
||||||
"updateAiEndpointsFailed": "Не вдалося оновити дозволені хости"
|
"privateEndpoints": {
|
||||||
|
"label": "Дозволені приватні хости ШІ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "Увімкнути помічника зі штучним інтелектом",
|
||||||
|
"description": "Дозволити помічнику зі штучним інтелектом допомагати в терміналі цього хоста. Вимкнено за замовчуванням, але все одно потребує ввімкнення штучного інтелекту для вашого облікового запису."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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."
|
"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": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "Trợ lý AI",
|
"admin": {
|
||||||
"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.",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "Cho phép máy chủ AI riêng",
|
"label": "Trợ lý AI",
|
||||||
"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.",
|
"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."
|
||||||
"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"
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "启用 AI 助手,它将分析您的配置并提供优化建议。需自行配置服务商,例如本地运行的 Ollama。",
|
"aiEnableDesc": "启用 AI 助手,它将分析您的配置并提供优化建议。需自行配置服务商,例如本地运行的 Ollama。",
|
||||||
"aiNote": "在您配置服务商并主动发送消息之前,绝不会向外发送任何数据。"
|
"aiNote": "在您配置服务商并主动发送消息之前,绝不会向外发送任何数据。"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "AI 助手",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "允许的私有 AI 主机",
|
"label": "AI 助手",
|
||||||
"aiPrivateEndpointsDesc": "用户可能将服务提供商指向的私有网络上的主机,例如自托管的 Ollama。用逗号分隔。",
|
"description": "允许用户开启 AI 助手。关闭此功能后,助手将被隐藏,所有用户均无法使用。"
|
||||||
"updateAiEnabledFailed": "无法更新 AI 设置",
|
},
|
||||||
"updateAiEndpointsFailed": "无法更新允许的主机列表。"
|
"privateEndpoints": {
|
||||||
|
"label": "允许的私有 AI 主机"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "启用人工智能助手",
|
||||||
|
"description": "允许 AI 助手在此主机的终端上提供帮助。默认情况下处于关闭状态,但仍需在您的帐户中启用 AI 功能。"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,12 +67,21 @@
|
|||||||
"aiEnableDesc": "添加一個助手,它可以讀取您的設定並提出您批准的更改建議。需要您提供服務提供者,例如當地的 Ollama 服務提供者。",
|
"aiEnableDesc": "添加一個助手,它可以讀取您的設定並提出您批准的更改建議。需要您提供服務提供者,例如當地的 Ollama 服務提供者。",
|
||||||
"aiNote": "在您新增服務提供者並發送訊息之前,任何內容都不會發送到任何地方。"
|
"aiNote": "在您新增服務提供者並發送訊息之前,任何內容都不會發送到任何地方。"
|
||||||
},
|
},
|
||||||
"admin": {
|
"settings": {
|
||||||
"aiGloballyEnabled": "人工智慧助手",
|
"admin": {
|
||||||
"aiGloballyEnabledDesc": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。",
|
"globallyEnabled": {
|
||||||
"aiPrivateEndpoints": "允許的私有人工智慧主機",
|
"label": "人工智慧助手",
|
||||||
"aiPrivateEndpointsDesc": "使用者可能將服務提供者指向的私有網路上的主機,例如自架的 Ollama。用逗號分隔。",
|
"description": "允許用戶開啟AI助理。關閉此功能後,助手將被隱藏,所有使用者均無法使用。"
|
||||||
"updateAiEnabledFailed": "無法更新AI設定",
|
},
|
||||||
"updateAiEndpointsFailed": "無法更新允許的主機清單。"
|
"privateEndpoints": {
|
||||||
|
"label": "允許的私有人工智慧主機"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"enableAiAssistant": {
|
||||||
|
"label": "啟用人工智慧助手",
|
||||||
|
"description": "允許 AI 助理在此主機的終端機上提供協助。預設為關閉狀態,但仍需在您的帳戶中啟用 AI 功能。"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"db:own",
|
"db:own",
|
||||||
"secrets:own",
|
"secrets:own",
|
||||||
"network:outbound",
|
"network:outbound",
|
||||||
"audit:read",
|
"notify:send",
|
||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"snippets": "^1.0.0",
|
"snippets": "^1.0.0",
|
||||||
"fleets": "^1.0.0",
|
"fleets": "^1.0.0",
|
||||||
"ssh-terminal": "^1.0.0",
|
"ssh-terminal": "^1.0.0",
|
||||||
"automations": "^1.0.0"
|
"automations": "^1.0.0",
|
||||||
|
"homepage": "^1.0.0"
|
||||||
},
|
},
|
||||||
"requires": [
|
"requires": [
|
||||||
{
|
{
|
||||||
@@ -64,6 +65,11 @@
|
|||||||
"service": "automations.access",
|
"service": "automations.access",
|
||||||
"versionRange": "^1.0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": "homepage.items",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"providesSecret": [
|
"providesSecret": [
|
||||||
@@ -128,18 +134,55 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"admin": [
|
"admin": [
|
||||||
{
|
{
|
||||||
"key": "access",
|
"key": "globallyEnabled",
|
||||||
"type": "custom",
|
"type": "boolean",
|
||||||
"component": "access"
|
"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": [
|
"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",
|
"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",
|
"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";
|
||||||
@@ -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";
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import { isIP } from "net";
|
import { BlockList, isIP } from "net";
|
||||||
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
|
||||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the assistant is allowed to send requests.
|
* Where the assistant is allowed to send requests.
|
||||||
*
|
*
|
||||||
* Cloud providers are reached through the SSRF-guarded path, which refuses to
|
* Every provider request goes through ctx.fetch, which refuses private and
|
||||||
* resolve to a private address. That guard is exactly what a self-hosted Ollama
|
* loopback addresses (after DNS, too) unless the exact host is listed. That
|
||||||
* on localhost trips over, so private destinations are permitted only when an
|
* guard is exactly what a self-hosted Ollama on localhost trips over, so the
|
||||||
* admin has named the host. Without that split, any logged-in user could point
|
* admin setting "privateEndpoints" names the hosts that may be private.
|
||||||
* a "provider" at an internal service and use the backend as an authenticated
|
* Without that split, any logged-in user could point a "provider" at an
|
||||||
* probe of the server's own network.
|
* 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. */
|
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
|
||||||
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||||
"localhost",
|
"localhost",
|
||||||
@@ -23,35 +23,53 @@ export const DEFAULT_PRIVATE_ALLOWLIST = [
|
|||||||
"host.docker.internal",
|
"host.docker.internal",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function parseAllowlist(raw: string | null): string[] {
|
export const PRIVATE_DESTINATION_MESSAGE =
|
||||||
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.";
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
/** A bare host, not a URL: no scheme, path, port or whitespace. */
|
||||||
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
|
||||||
return parsed
|
|
||||||
.filter((entry): entry is string => typeof entry === "string")
|
/**
|
||||||
.map((entry) => entry.trim().toLowerCase())
|
* The admin setting is one host per line. Anything that is not a bare host
|
||||||
.filter(Boolean);
|
* is ignored rather than failing the whole list.
|
||||||
} catch {
|
*/
|
||||||
return [...DEFAULT_PRIVATE_ALLOWLIST];
|
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 PRIVATE_RANGES = (() => {
|
||||||
const raw = await createCurrentSettingsRepository().get(
|
const list = new BlockList();
|
||||||
AI_PRIVATE_ALLOWLIST_KEY,
|
list.addSubnet("0.0.0.0", 8, "ipv4");
|
||||||
);
|
list.addSubnet("10.0.0.0", 8, "ipv4");
|
||||||
return parseAllowlist(raw);
|
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 {
|
function normalizeHost(hostname: string): string {
|
||||||
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
* True when the URL names a destination the outbound guard would refuse. A
|
||||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
* 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.
|
* private only if it is "localhost"; anything else needs DNS resolution,
|
||||||
|
* which ctx.fetch does.
|
||||||
*/
|
*/
|
||||||
export function isPrivateDestination(rawUrl: string): boolean {
|
export function isPrivateDestination(rawUrl: string): boolean {
|
||||||
let url: URL;
|
let url: URL;
|
||||||
@@ -62,13 +80,15 @@ export function isPrivateDestination(rawUrl: string): boolean {
|
|||||||
}
|
}
|
||||||
const host = normalizeHost(url.hostname);
|
const host = normalizeHost(url.hostname);
|
||||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EgressDecision {
|
export interface EgressDecision {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
/** True when the destination needs the allowlisted-private path. */
|
/** True when the destination is an allowlisted private host. */
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
@@ -96,23 +116,19 @@ export function evaluateEgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const host = normalizeHost(url.hostname);
|
const host = normalizeHost(url.hostname);
|
||||||
const isPrivate = isPrivateDestination(rawUrl);
|
|
||||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||||
|
|
||||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
// An allowlisted hostname may resolve to a private address; ctx.fetch
|
||||||
// must use the private fetch path; sending it through safeOutboundFetch
|
// lets it through because the same list is passed as allowPrivateHosts.
|
||||||
// would reject it after DNS resolution and make hostname allowlist entries
|
|
||||||
// ineffective. Only administrators can write this list.
|
|
||||||
if (normalized.includes(host)) {
|
if (normalized.includes(host)) {
|
||||||
return { allowed: true, isPrivate: true };
|
return { allowed: true, isPrivate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
reason:
|
reason: PRIVATE_DESTINATION_MESSAGE,
|
||||||
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 { getAdapter } from "./providers/registry.js";
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -6,9 +6,10 @@ import type {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
} from "./providers/types.js";
|
} from "./providers/types.js";
|
||||||
import { redact, redactToJson } from "./redaction.js";
|
import { redact, redactToJson } from "./redaction.js";
|
||||||
import { getTool, toolDefinitions } from "./tools/catalog.js";
|
import { toolDefinitions } from "./tools/catalog.js";
|
||||||
import {
|
import {
|
||||||
isProposalDraft,
|
isProposalDraft,
|
||||||
|
type AiTool,
|
||||||
type ProposalDraft,
|
type ProposalDraft,
|
||||||
type ToolContext,
|
type ToolContext,
|
||||||
} from "./tools/types.js";
|
} from "./tools/types.js";
|
||||||
@@ -36,6 +37,8 @@ export interface EngineOptions {
|
|||||||
system: string;
|
system: string;
|
||||||
history: ChatMessage[];
|
history: ChatMessage[];
|
||||||
context: ToolContext;
|
context: ToolContext;
|
||||||
|
/** What the model may call this turn; availableTools() decides. */
|
||||||
|
tools: AiTool[];
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,8 @@ export async function* runAgent(
|
|||||||
options: EngineOptions,
|
options: EngineOptions,
|
||||||
): AsyncGenerator<EngineEvent> {
|
): AsyncGenerator<EngineEvent> {
|
||||||
const adapter = getAdapter(options.config.providerType);
|
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];
|
const messages: ChatMessage[] = [...options.history];
|
||||||
|
|
||||||
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
||||||
@@ -89,7 +93,7 @@ export async function* runAgent(
|
|||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
yield { type: "tool_call", name: call.name, arguments: call.arguments };
|
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)) {
|
if (isProposalDraft(result)) {
|
||||||
// Closes the tool call before the proposal card is emitted. Without
|
// 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> {
|
async function runTool(
|
||||||
const tool = getTool(call.name);
|
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) {
|
if (!tool) {
|
||||||
return { error: `Unknown tool: ${call.name}` };
|
return { error: `Unknown tool: ${call.name}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
import type { NextFunction, Response } from "express";
|
import type { PluginSettings } from "@termix/plugin-sdk/backend";
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import { DEFAULT_PRIVATE_ALLOWLIST, parseAllowlist } from "./egress.js";
|
||||||
import {
|
|
||||||
createCurrentSettingsRepository,
|
|
||||||
createCurrentUserPreferenceRepository,
|
|
||||||
} from "../../../../src/backend/database/repositories/factory.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Three gates, all checked on the server.
|
* 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
|
* exist for anyone, regardless of what any user has enabled. It defaults to
|
||||||
* false so upgrading an existing install turns nothing on by surprise.
|
* 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.
|
||||||
* Mirrors session sharing's own check, where the global also wins over the
|
|
||||||
* per-host setting.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
|
export type SettingsReader = Pick<PluginSettings, "get" | "getUser">;
|
||||||
|
|
||||||
export async function isAiGloballyEnabled(): Promise<boolean> {
|
export async function isAiGloballyEnabled(
|
||||||
return createCurrentSettingsRepository().getBoolean(
|
settings: SettingsReader,
|
||||||
AI_GLOBAL_ENABLED_KEY,
|
): Promise<boolean> {
|
||||||
false,
|
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 {
|
export interface AiAccess {
|
||||||
@@ -30,41 +32,59 @@ export interface AiAccess {
|
|||||||
allowReadOnlyCommands: boolean;
|
allowReadOnlyCommands: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
|
export async function resolveAiAccess(
|
||||||
const globalEnabled = await isAiGloballyEnabled();
|
settings: SettingsReader,
|
||||||
if (!globalEnabled) {
|
userId: string,
|
||||||
|
): Promise<AiAccess> {
|
||||||
|
if (!(await isAiGloballyEnabled(settings))) {
|
||||||
return { enabled: false, allowReadOnlyCommands: false };
|
return { enabled: false, allowReadOnlyCommands: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferences =
|
// Unset means the user was never asked, which is not consent.
|
||||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
const enabled = (await settings.getUser<boolean>(userId, "enabled")) === true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Null means the user was never asked, which is not consent.
|
enabled,
|
||||||
enabled: preferences?.aiAssistantEnabled === true,
|
allowReadOnlyCommands:
|
||||||
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
|
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. */
|
/** Rejects any AI request unless both gates are open. */
|
||||||
export function createAiGate() {
|
export function createAiGate(
|
||||||
|
settings: SettingsReader,
|
||||||
|
currentActor: () => string | undefined,
|
||||||
|
) {
|
||||||
return async (
|
return async (
|
||||||
req: AuthenticatedRequest,
|
req: GateRequest,
|
||||||
res: Response,
|
res: GateResponse,
|
||||||
next: NextFunction,
|
next: (error?: unknown) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!req.userId) {
|
const userId = currentActor();
|
||||||
|
if (!userId) {
|
||||||
res.status(401).json({ error: "Authentication required" });
|
res.status(401).json({ error: "Authentication required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const access = await resolveAiAccess(req.userId);
|
try {
|
||||||
if (!access.enabled) {
|
const access = await resolveAiAccess(settings, userId);
|
||||||
res.status(403).json({ error: "The AI assistant is not enabled" });
|
if (!access.enabled) {
|
||||||
return;
|
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();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { setPluginSsh } from "./ssh.js";
|
import { hostImportNormalizer } from "./host-import.js";
|
||||||
import { setPluginServices } from "./services.js";
|
import { createAiRepository } from "./repository.js";
|
||||||
import { startAiService, stopAiService } from "./routes.js";
|
import { registerAiRoutes } from "./routes.js";
|
||||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
setPluginSsh(ctx.ssh);
|
const repository = await createAiRepository(ctx);
|
||||||
ctx.disposables.add(() => setPluginSsh(null));
|
|
||||||
setPluginServices(ctx.services);
|
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
|
||||||
ctx.disposables.add(() => setPluginServices(null));
|
|
||||||
startAiService(ctx.http.router());
|
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
|
// 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
|
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
// read by any borrower, and deactivate withdraws the offer. Keys are per
|
||||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
// user, so a borrowed read returns the acting user's own key.
|
||||||
// under that user's data key, so a borrowed read returns the acting user's
|
ctx.secrets.offer("api-key", (userId: string) =>
|
||||||
// own default key, never someone else's.
|
ctx.asUser(userId, async () => {
|
||||||
ctx.secrets.offer("api-key", async (userId: string) => {
|
const providers = await repository.listProviders(userId);
|
||||||
const repository = createCurrentAiRepository();
|
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||||
const providers = await repository.listProviders(userId);
|
if (!preferred) return null;
|
||||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
const withSecret = await repository.findProviderWithSecret(
|
||||||
if (!preferred) return null;
|
preferred.id,
|
||||||
|
userId,
|
||||||
const withSecret = await repository.findProviderWithSecret(
|
);
|
||||||
preferred.id,
|
return withSecret?.apiKey ?? null;
|
||||||
userId,
|
}),
|
||||||
);
|
);
|
||||||
return withSecret?.apiKey ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
|
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {}
|
||||||
stopAiService();
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import Anthropic from "@anthropic-ai/sdk";
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
import { providerFetch } from "./http.js";
|
|
||||||
import type {
|
import type {
|
||||||
ChatChunk,
|
ChatChunk,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -26,7 +25,7 @@ function createClient(config: ProviderConfig): Anthropic {
|
|||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
||||||
// Routes the SDK's HTTP through the shared egress guard.
|
// 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assertOk, providerFetch, readSseLines } from "./http.js";
|
import { assertOk, readSseLines } from "./http.js";
|
||||||
import type {
|
import type {
|
||||||
ChatChunk,
|
ChatChunk,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -104,7 +104,7 @@ export const geminiAdapter: ProviderAdapter = {
|
|||||||
|
|
||||||
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -176,7 +176,7 @@ export const geminiAdapter: ProviderAdapter = {
|
|||||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||||
if (!config.apiKey) return [];
|
if (!config.apiKey) return [];
|
||||||
|
|
||||||
const response = await providerFetch(
|
const response = await config.fetch(
|
||||||
`${baseFor(config).replace(/\/+$/, "")}/models`,
|
`${baseFor(config).replace(/\/+$/, "")}/models`,
|
||||||
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
|
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,63 @@
|
|||||||
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
|
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||||
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
|
import { evaluateEgress, PRIVATE_DESTINATION_MESSAGE } from "../egress.js";
|
||||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
import { AiProviderError, type ProviderFetch } from "./types.js";
|
||||||
import { AiProviderError } 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
|
* Every outbound provider request goes through here so the egress rules cannot
|
||||||
* be bypassed by an adapter calling fetch directly.
|
* 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
|
||||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
* on the admin allowlist.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function providerFetch(
|
export function createProviderFetch(
|
||||||
url: string,
|
fetch: PluginFetch,
|
||||||
init: RequestInit,
|
allowlist: string[],
|
||||||
): Promise<Response> {
|
): ProviderFetch {
|
||||||
const allowlist = await readPrivateAllowlist();
|
return async (url, init) => {
|
||||||
const decision = evaluateEgress(url, allowlist);
|
const decision = evaluateEgress(url, allowlist);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
if (!decision.allowed) {
|
if (
|
||||||
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
init.body !== undefined &&
|
||||||
}
|
init.body !== null &&
|
||||||
|
typeof init.body !== "string"
|
||||||
|
) {
|
||||||
|
throw new AiProviderError("Provider requests must send a text body");
|
||||||
|
}
|
||||||
|
|
||||||
if (decision.isPrivate) {
|
try {
|
||||||
return fetchWithProxy(url, init);
|
return await fetch(url, {
|
||||||
}
|
method: init.method ?? "GET",
|
||||||
|
headers: toHeaderRecord(init.headers),
|
||||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
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 {
|
export function joinUrl(base: string, path: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
|
import { assertOk, joinUrl, readJsonLines } from "./http.js";
|
||||||
import type {
|
import type {
|
||||||
ChatChunk,
|
ChatChunk,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -47,7 +47,7 @@ export const ollamaAdapter: ProviderAdapter = {
|
|||||||
config: ProviderConfig,
|
config: ProviderConfig,
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
): AsyncIterable<ChatChunk> {
|
): AsyncIterable<ChatChunk> {
|
||||||
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
|
const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
@@ -117,7 +117,7 @@ export const ollamaAdapter: ProviderAdapter = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
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",
|
method: "GET",
|
||||||
});
|
});
|
||||||
await assertOk(response, "Ollama");
|
await assertOk(response, "Ollama");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
|
import { assertOk, joinUrl, readSseLines } from "./http.js";
|
||||||
import type {
|
import type {
|
||||||
ChatChunk,
|
ChatChunk,
|
||||||
ChatRequest,
|
ChatRequest,
|
||||||
@@ -73,7 +73,7 @@ export const openAiAdapter: ProviderAdapter = {
|
|||||||
};
|
};
|
||||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
|
||||||
const response = await providerFetch(url, {
|
const response = await config.fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
@@ -145,7 +145,7 @@ export const openAiAdapter: ProviderAdapter = {
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
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",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,10 +63,20 @@ export type ChatChunk =
|
|||||||
| { type: "done"; stopReason?: string }
|
| { type: "done"; stopReason?: string }
|
||||||
| { type: "error"; message: 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 {
|
export interface ProviderConfig {
|
||||||
providerType: AiProviderType;
|
providerType: AiProviderType;
|
||||||
baseUrl?: string | null;
|
baseUrl?: string | null;
|
||||||
apiKey?: string | null;
|
apiKey?: string | null;
|
||||||
|
fetch: ProviderFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderAdapter {
|
export interface ProviderAdapter {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+1050
-1287
File diff suppressed because it is too large.
Load diff
@@ -1,111 +1,35 @@
|
|||||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
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
|
* Other plugins the assistant reads and changes through, each optional. A
|
||||||
* the user may not use it. Optional: the assistant works without it.
|
* 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,
|
export const SERVICE = {
|
||||||
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
|
snippets: "snippets.access",
|
||||||
if (!current) return null;
|
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 {
|
try {
|
||||||
return await current
|
return services.providers(service).length > 0;
|
||||||
.get<SavedWorkspaces>("workspaces.saved", { userId })
|
|
||||||
.list();
|
|
||||||
} catch {
|
} 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 {
|
export interface SnippetSummary {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -115,8 +39,7 @@ export interface SnippetSummary {
|
|||||||
folder: string | null;
|
folder: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
|
export interface SnippetsAccess {
|
||||||
interface SnippetsAccess {
|
|
||||||
list: () => Promise<SnippetSummary[]>;
|
list: () => Promise<SnippetSummary[]>;
|
||||||
get: (id: number) => Promise<{
|
get: (id: number) => Promise<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -142,115 +65,18 @@ interface SnippetsAccess {
|
|||||||
remove: (id: number) => Promise<boolean>;
|
remove: (id: number) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface FleetsAccess {
|
||||||
* The user's own snippets, or null when the snippets plugin is off or the
|
list: () => Promise<
|
||||||
* user may not use it. Optional: the list_snippets tool disappears without
|
Array<{ id: number; name: string; color: string | null }>
|
||||||
* it, and the propose_*_snippet tools fail with a clear message.
|
>;
|
||||||
*/
|
create: (input: {
|
||||||
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: {
|
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
folder?: string | null;
|
}) => Promise<{ id: number; name: string }>;
|
||||||
},
|
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||||
): Promise<{ id: number; name: string }> {
|
|
||||||
return requireSnippetsAccess(userId).create(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSnippet(
|
export interface AutomationsAccess {
|
||||||
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 {
|
|
||||||
list: () => Promise<
|
list: () => Promise<
|
||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -277,55 +103,61 @@ interface AutomationsAccess {
|
|||||||
}) => Promise<{ id: number; name: string }>;
|
}) => Promise<{ id: number; name: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function automations(userId: string): Partial<AutomationsAccess> {
|
export interface SavedWorkspaces {
|
||||||
if (!current) return {};
|
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||||
try {
|
}
|
||||||
return current.get<AutomationsAccess>("automations.access", { userId });
|
|
||||||
} catch {
|
export interface NetworkTopologyGraph {
|
||||||
return {};
|
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
|
* The service handle. Throws when the provider is missing or the user may
|
||||||
* user may not use it.
|
* not use it; a proposal apply wants that error, a read tool catches it.
|
||||||
*/
|
*/
|
||||||
export async function listAutomations(
|
export function requireService<S extends ServiceName>(
|
||||||
userId: string,
|
services: Pick<PluginServices, "get" | "providers">,
|
||||||
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
|
service: S,
|
||||||
const list = automations(userId).list;
|
): ServiceTypes[S] {
|
||||||
if (typeof list !== "function") return null;
|
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 {
|
try {
|
||||||
return await list();
|
return await read(requireService(services, service));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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];
|
||||||
@@ -58,9 +58,21 @@ export const FORBIDDEN_DOMAINS = [
|
|||||||
"settings",
|
"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. */
|
/** Tool definitions in the shape the provider adapters expect. */
|
||||||
export function toolDefinitions(): ToolDefinitionShape[] {
|
export function toolDefinitions(
|
||||||
return AI_TOOLS.map((tool) => ({
|
tools: AiTool[] = AI_TOOLS,
|
||||||
|
): ToolDefinitionShape[] {
|
||||||
|
return tools.map((tool) => ({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
parameters: tool.parameters,
|
parameters: tool.parameters,
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
import type { Client } from "ssh2";
|
||||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
|
||||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||||
import { withSshConnection } from "../ssh.js";
|
|
||||||
import { getTool } from "./catalog.js";
|
import { getTool } from "./catalog.js";
|
||||||
import {
|
import type { ToolDeps } from "./types.js";
|
||||||
createSnippet,
|
import { requireService, SERVICE } from "../services.js";
|
||||||
updateSnippet,
|
|
||||||
deleteSnippet,
|
|
||||||
getSnippet,
|
|
||||||
createFleet,
|
|
||||||
addFleetMember,
|
|
||||||
createAutomation,
|
|
||||||
} from "../services.js";
|
|
||||||
|
|
||||||
/** Approved commands get a bounded window rather than hanging the request. */
|
/** Approved commands get a bounded window rather than hanging the request. */
|
||||||
const COMMAND_TIMEOUT_MS = 60_000;
|
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
|
* 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
|
* 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.
|
* 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
|
* Everything goes through the same core APIs and plugin services a human
|
||||||
* the approving user.
|
* action uses, and runs as the approving user: the apply request's actor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ApplyResult {
|
export interface ApplyResult {
|
||||||
@@ -52,10 +43,29 @@ function optionalString(value: unknown): string | null {
|
|||||||
return trimmed ? trimmed : 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(
|
export async function applyProposal(
|
||||||
kind: string,
|
kind: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
userId: string,
|
deps: ToolDeps,
|
||||||
): Promise<ApplyResult> {
|
): Promise<ApplyResult> {
|
||||||
// A payload whose tool no longer exists is refused rather than guessed at.
|
// A payload whose tool no longer exists is refused rather than guessed at.
|
||||||
if (!getTool(kind)) {
|
if (!getTool(kind)) {
|
||||||
@@ -64,31 +74,25 @@ export async function applyProposal(
|
|||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "propose_create_host": {
|
case "propose_create_host": {
|
||||||
const created = await createCurrentHostRepository().create({
|
await requirePermission(deps, "hosts.create");
|
||||||
userId,
|
const created = await deps.hosts.create({
|
||||||
name: requireString(payload.name, "name"),
|
name: requireString(payload.name, "name"),
|
||||||
ip: requireString(payload.ip, "ip"),
|
ip: requireString(payload.ip, "ip"),
|
||||||
port: Number(payload.port) || 22,
|
port: Number(payload.port) || 22,
|
||||||
username: optionalString(payload.username) ?? "",
|
username: optionalString(payload.username) ?? "",
|
||||||
folder: optionalString(payload.folder) ?? "",
|
// Credentials are attached by the user after approving.
|
||||||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
|
authType: "password",
|
||||||
} as any);
|
folder: optionalString(payload.folder),
|
||||||
return {
|
tags: joinTags(payload.tags),
|
||||||
ok: true,
|
});
|
||||||
summary: `Created host ${(created as any).name ?? ""}`.trim(),
|
return { ok: true, summary: `Created host ${created.name ?? ""}`.trim() };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_update_host": {
|
case "propose_update_host": {
|
||||||
|
await requirePermission(deps, "hosts.edit");
|
||||||
const hostId = requireNumber(payload.hostId, "hostId");
|
const hostId = requireNumber(payload.hostId, "hostId");
|
||||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
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> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (changes.name !== undefined)
|
if (changes.name !== undefined)
|
||||||
updates.name = requireString(changes.name, "name");
|
updates.name = requireString(changes.name, "name");
|
||||||
@@ -98,37 +102,32 @@ export async function applyProposal(
|
|||||||
if (changes.username !== undefined)
|
if (changes.username !== undefined)
|
||||||
updates.username = optionalString(changes.username) ?? "";
|
updates.username = optionalString(changes.username) ?? "";
|
||||||
if (changes.folder !== undefined)
|
if (changes.folder !== undefined)
|
||||||
updates.folder = optionalString(changes.folder) ?? "";
|
updates.folder = optionalString(changes.folder);
|
||||||
if (changes.tags !== undefined) {
|
if (changes.tags !== undefined) updates.tags = joinTags(changes.tags);
|
||||||
updates.tags = JSON.stringify(
|
|
||||||
Array.isArray(changes.tags) ? changes.tags : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.keys(updates).length) {
|
if (!Object.keys(updates).length) {
|
||||||
return { ok: false, summary: "Nothing to change" };
|
return { ok: false, summary: "Nothing to change" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentHostRepository().updateForUser(
|
// update() refuses a host the approving user does not own.
|
||||||
userId,
|
const updated = await deps.hosts.update(hostId, updates);
|
||||||
hostId,
|
if (!updated) throw new Error("Host not found");
|
||||||
updates as any,
|
|
||||||
);
|
|
||||||
return { ok: true, summary: `Updated host ${hostId}` };
|
return { ok: true, summary: `Updated host ${hostId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_delete_host": {
|
case "propose_delete_host": {
|
||||||
const hostId = requireNumber(payload.hostId, "hostId");
|
const hostId = requireNumber(payload.hostId, "hostId");
|
||||||
const deleted = await createCurrentHostRepository().deleteForUser(
|
// delete() checks hosts.delete and ownership itself.
|
||||||
userId,
|
const deleted = await deps.hosts.delete(hostId);
|
||||||
hostId,
|
|
||||||
);
|
|
||||||
if (!deleted) throw new Error("Host not found");
|
if (!deleted) throw new Error("Host not found");
|
||||||
return { ok: true, summary: `Deleted host ${hostId}` };
|
return { ok: true, summary: `Deleted host ${hostId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_create_snippet": {
|
case "propose_create_snippet": {
|
||||||
const created = await createSnippet(userId, {
|
const created = await requireService(
|
||||||
|
deps.services,
|
||||||
|
SERVICE.snippets,
|
||||||
|
).create({
|
||||||
name: requireString(payload.name, "name"),
|
name: requireString(payload.name, "name"),
|
||||||
content: requireString(payload.content, "content"),
|
content: requireString(payload.content, "content"),
|
||||||
description: optionalString(payload.description),
|
description: optionalString(payload.description),
|
||||||
@@ -144,7 +143,8 @@ export async function applyProposal(
|
|||||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
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");
|
if (!existing) throw new Error("Snippet not found");
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
@@ -161,23 +161,26 @@ export async function applyProposal(
|
|||||||
return { ok: false, summary: "Nothing to change" };
|
return { ok: false, summary: "Nothing to change" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSnippet(userId, snippetId, updates);
|
await snippets.update(snippetId, updates);
|
||||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_delete_snippet": {
|
case "propose_delete_snippet": {
|
||||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
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");
|
if (!deleted) throw new Error("Snippet not found");
|
||||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "propose_create_fleet": {
|
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"),
|
name: requireString(payload.name, "name"),
|
||||||
description: optionalString(payload.description),
|
description: optionalString(payload.description),
|
||||||
});
|
});
|
||||||
if (!fleet) throw new Error("The fleets plugin is disabled");
|
|
||||||
|
|
||||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||||
let added = 0;
|
let added = 0;
|
||||||
@@ -185,12 +188,9 @@ export async function applyProposal(
|
|||||||
const hostId = Number(raw);
|
const hostId = Number(raw);
|
||||||
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
||||||
// Only hosts the approving user owns can join their fleet.
|
// Only hosts the approving user owns can join their fleet.
|
||||||
const host = await createCurrentHostRepository().findByIdForUser(
|
const access = await deps.hosts.checkAccess(hostId, "view");
|
||||||
userId,
|
if (!access.isOwner) continue;
|
||||||
hostId,
|
await fleets.addMember(fleet.id, hostId);
|
||||||
);
|
|
||||||
if (!host) continue;
|
|
||||||
await addFleetMember(userId, fleet.id, hostId);
|
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +203,10 @@ export async function applyProposal(
|
|||||||
case "propose_create_automation": {
|
case "propose_create_automation": {
|
||||||
// The automations plugin runs the same validator its own route does,
|
// The automations plugin runs the same validator its own route does,
|
||||||
// so an LLM-authored definition is held to exactly the human standard.
|
// 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"),
|
name: requireString(payload.name, "name"),
|
||||||
description: optionalString(payload.description),
|
description: optionalString(payload.description),
|
||||||
definition: payload.definition,
|
definition: payload.definition,
|
||||||
@@ -222,14 +225,10 @@ export async function applyProposal(
|
|||||||
const hostId = requireNumber(payload.hostId, "hostId");
|
const hostId = requireNumber(payload.hostId, "hostId");
|
||||||
const command = requireString(payload.command, "command");
|
const command = requireString(payload.command, "command");
|
||||||
|
|
||||||
// resolveHostById, not the raw repository row: it runs the connect-level
|
// ctx.ssh resolves the host for the approving user, with the same
|
||||||
// permission check, decrypts auth under the owner's key, and resolves the
|
// connect-level access check, owner-key decryption and jump chain a
|
||||||
// jump host chain. A plain row has none of that, so the SSH factory saw
|
// terminal gets.
|
||||||
// an unresolved jumpHosts field and failed the connection.
|
const result = await runCommandOnHost(deps, hostId, command);
|
||||||
const host = await resolveHostById(hostId, userId);
|
|
||||||
if (!host) throw new Error("Host not found");
|
|
||||||
|
|
||||||
const result = await runCommandOnHost(host, command);
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw new Error(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.
|
* automation run_command step executes.
|
||||||
*/
|
*/
|
||||||
async function runCommandOnHost(
|
async function runCommandOnHost(
|
||||||
host: Record<string, any>,
|
deps: ToolDeps,
|
||||||
|
hostId: number,
|
||||||
command: string,
|
command: string,
|
||||||
): Promise<{ output?: string; error?: string }> {
|
): Promise<{ output?: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await withSshConnection(
|
const result = await deps.ssh.withConnection<
|
||||||
host as any as never,
|
Awaited<ReturnType<typeof execCommand>>,
|
||||||
{ pool: "fleet", purpose: "fleet" },
|
Client
|
||||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
>(hostId, { pool: "ai", purpose: "fleet" }, (client) =>
|
||||||
|
execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||||
);
|
);
|
||||||
|
|
||||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
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 { 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
|
* Propose tools never mutate anything. They return a draft that is stored as a
|
||||||
@@ -100,6 +101,7 @@ export const proposeTools: AiTool[] = [
|
|||||||
description:
|
description:
|
||||||
"Propose saving a new command snippet the user can run against their hosts.",
|
"Propose saving a new command snippet the user can run against their hosts.",
|
||||||
category: "propose",
|
category: "propose",
|
||||||
|
service: SERVICE.snippets,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
name: str("Snippet name"),
|
name: str("Snippet name"),
|
||||||
@@ -125,6 +127,7 @@ export const proposeTools: AiTool[] = [
|
|||||||
name: "propose_update_snippet",
|
name: "propose_update_snippet",
|
||||||
description: "Propose editing an existing snippet.",
|
description: "Propose editing an existing snippet.",
|
||||||
category: "propose",
|
category: "propose",
|
||||||
|
service: SERVICE.snippets,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
snippetId: num("The snippet id to update"),
|
snippetId: num("The snippet id to update"),
|
||||||
@@ -151,6 +154,7 @@ export const proposeTools: AiTool[] = [
|
|||||||
name: "propose_delete_snippet",
|
name: "propose_delete_snippet",
|
||||||
description: "Propose deleting a snippet.",
|
description: "Propose deleting a snippet.",
|
||||||
category: "propose",
|
category: "propose",
|
||||||
|
service: SERVICE.snippets,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
snippetId: num("The snippet id to delete"),
|
snippetId: num("The snippet id to delete"),
|
||||||
@@ -170,6 +174,7 @@ export const proposeTools: AiTool[] = [
|
|||||||
description:
|
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.",
|
"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",
|
category: "propose",
|
||||||
|
service: SERVICE.automations,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
name: str("Automation name"),
|
name: str("Automation name"),
|
||||||
@@ -197,6 +202,7 @@ export const proposeTools: AiTool[] = [
|
|||||||
name: "propose_create_fleet",
|
name: "propose_create_fleet",
|
||||||
description: "Propose grouping hosts into a new fleet.",
|
description: "Propose grouping hosts into a new fleet.",
|
||||||
category: "propose",
|
category: "propose",
|
||||||
|
service: SERVICE.fleets,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
name: str("Fleet name"),
|
name: str("Fleet name"),
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import {
|
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||||
createCurrentNotificationChannelRepository,
|
|
||||||
createCurrentHomepageItemRepository,
|
|
||||||
createCurrentHostRepository,
|
|
||||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
|
||||||
import { num, objectSchema, type AiTool } from "./types.js";
|
import { num, objectSchema, type AiTool } from "./types.js";
|
||||||
import {
|
import { readService, SERVICE } from "../services.js";
|
||||||
listSavedWorkspaces,
|
|
||||||
getNetworkTopology,
|
|
||||||
listSnippets,
|
|
||||||
listFleets,
|
|
||||||
listCommandHistory,
|
|
||||||
listAutomations,
|
|
||||||
getAutomation,
|
|
||||||
} from "../services.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
* 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.
|
* primary control: a field that is never selected cannot leak.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface HostSummary {
|
function splitTags(tags: string | null): string[] {
|
||||||
id: number;
|
if (!tags) return [];
|
||||||
name: string | null;
|
return tags
|
||||||
ip: string | null;
|
.split(",")
|
||||||
port: number | null;
|
.map((tag) => tag.trim())
|
||||||
username: string | null;
|
.filter(Boolean);
|
||||||
folder: string | null;
|
|
||||||
tags: unknown;
|
|
||||||
protocol: string | null;
|
|
||||||
enableTerminal: boolean | null;
|
|
||||||
enableFileManager: boolean | null;
|
|
||||||
enableTunnel: boolean | null;
|
|
||||||
enableDocker: boolean | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toHostSummary(host: Record<string, any>): HostSummary {
|
function toHostSummary(host: PluginHostSummary) {
|
||||||
return {
|
return {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
name: host.name ?? null,
|
name: host.name ?? null,
|
||||||
ip: host.ip ?? null,
|
ip: host.ip,
|
||||||
port: host.port ?? null,
|
port: host.port,
|
||||||
username: host.username ?? null,
|
username: host.username,
|
||||||
folder: host.folder ?? null,
|
folder: host.folder ?? null,
|
||||||
tags: host.tags ?? null,
|
tags: splitTags(host.tags),
|
||||||
protocol: host.protocol ?? null,
|
authType: host.authType,
|
||||||
enableTerminal: host.enableTerminal ?? null,
|
|
||||||
enableFileManager: host.enableFileManager ?? null,
|
|
||||||
enableTunnel: host.enableTunnel ?? null,
|
|
||||||
enableDocker: host.enableDocker ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +37,8 @@ export const readTools: AiTool[] = [
|
|||||||
category: "read",
|
category: "read",
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const hosts = await createCurrentHostRepository().listByUserId(
|
const hosts = await context.deps.hosts.list();
|
||||||
context.userId,
|
return { hosts: hosts.map(toHostSummary) };
|
||||||
);
|
|
||||||
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -76,40 +51,43 @@ export const readTools: AiTool[] = [
|
|||||||
["hostId"],
|
["hostId"],
|
||||||
),
|
),
|
||||||
handler: async (args, context) => {
|
handler: async (args, context) => {
|
||||||
const hostId = Number(args.hostId);
|
const host = await context.deps.hosts.get(Number(args.hostId));
|
||||||
const host = await createCurrentHostRepository().findByIdForUser(
|
|
||||||
context.userId,
|
|
||||||
hostId,
|
|
||||||
);
|
|
||||||
if (!host) return { error: "Host not found" };
|
if (!host) return { error: "Host not found" };
|
||||||
return { host: toHostSummary(host as any) };
|
return { host: toHostSummary(host) };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_fleets",
|
name: "list_fleets",
|
||||||
description:
|
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",
|
category: "read",
|
||||||
|
service: SERVICE.fleets,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
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: [] };
|
if (fleets === null) return { unavailable: true, fleets: [] };
|
||||||
return {
|
return {
|
||||||
fleets: fleets.map((fleet) => ({
|
fleets: fleets.map((fleet) => ({ id: fleet.id, name: fleet.name })),
|
||||||
id: fleet.id,
|
|
||||||
name: fleet.name,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "list_snippets",
|
name: "list_snippets",
|
||||||
description:
|
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",
|
category: "read",
|
||||||
|
service: SERVICE.snippets,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
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: [] };
|
if (!snippets) return { unavailable: true, snippets: [] };
|
||||||
return {
|
return {
|
||||||
snippets: snippets.map((snippet) => ({
|
snippets: snippets.map((snippet) => ({
|
||||||
@@ -127,9 +105,14 @@ export const readTools: AiTool[] = [
|
|||||||
description:
|
description:
|
||||||
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.automations,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
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 };
|
if (!automations) return { automations: [], unavailable: true };
|
||||||
return {
|
return {
|
||||||
automations: automations.map((automation) => ({
|
automations: automations.map((automation) => ({
|
||||||
@@ -150,6 +133,7 @@ export const readTools: AiTool[] = [
|
|||||||
description:
|
description:
|
||||||
"Get one automation's full definition (trigger and steps) by id.",
|
"Get one automation's full definition (trigger and steps) by id.",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.automations,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
automationId: num("The automation id, as returned by list_automations"),
|
automationId: num("The automation id, as returned by list_automations"),
|
||||||
@@ -157,11 +141,15 @@ export const readTools: AiTool[] = [
|
|||||||
["automationId"],
|
["automationId"],
|
||||||
),
|
),
|
||||||
handler: async (args, context) => {
|
handler: async (args, context) => {
|
||||||
const automation = await getAutomation(
|
const result = await readService(
|
||||||
context.userId,
|
context.deps.services,
|
||||||
Number(args.automationId),
|
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" };
|
if (!automation) return { error: "Automation not found" };
|
||||||
return {
|
return {
|
||||||
automation: {
|
automation: {
|
||||||
@@ -178,9 +166,14 @@ export const readTools: AiTool[] = [
|
|||||||
description:
|
description:
|
||||||
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.workspaces,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
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 };
|
if (workspaces === null) return { workspaces: [], unavailable: true };
|
||||||
return { workspaces };
|
return { workspaces };
|
||||||
},
|
},
|
||||||
@@ -192,16 +185,13 @@ export const readTools: AiTool[] = [
|
|||||||
category: "read",
|
category: "read",
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const channels =
|
const channels = await context.deps.notify.channels();
|
||||||
await createCurrentNotificationChannelRepository().listNotificationChannels(
|
|
||||||
context.userId,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
channels: channels.map((channel) => ({
|
channels: channels.map((channel) => ({
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
name: channel.name,
|
name: channel.name,
|
||||||
type: channel.type,
|
type: channel.type,
|
||||||
enabled: channel.enabled === 1,
|
enabled: channel.enabled,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -210,13 +200,17 @@ export const readTools: AiTool[] = [
|
|||||||
name: "list_homepage_items",
|
name: "list_homepage_items",
|
||||||
description: "List the user's homepage service-link tiles.",
|
description: "List the user's homepage service-link tiles.",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.homepage,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const items = await createCurrentHomepageItemRepository().listByUserId(
|
const items = await readService(
|
||||||
context.userId,
|
context.deps.services,
|
||||||
|
SERVICE.homepage,
|
||||||
|
(homepage) => homepage.list(),
|
||||||
);
|
);
|
||||||
|
if (items === null) return { items: [], unavailable: true };
|
||||||
return {
|
return {
|
||||||
items: (items as any[]).map((item) => ({
|
items: items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
typeId: item.typeId,
|
typeId: item.typeId,
|
||||||
title: item.title ?? null,
|
title: item.title ?? null,
|
||||||
@@ -229,6 +223,7 @@ export const readTools: AiTool[] = [
|
|||||||
description:
|
description:
|
||||||
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.history,
|
||||||
parameters: objectSchema(
|
parameters: objectSchema(
|
||||||
{
|
{
|
||||||
hostId: num("The host id, as returned by list_hosts"),
|
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 limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||||
const hostId = Number(args.hostId);
|
const hostId = Number(args.hostId);
|
||||||
|
|
||||||
// Ownership is enforced here rather than trusted from the model.
|
// Access is checked here rather than trusted from the model.
|
||||||
const host = await createCurrentHostRepository().findByIdForUser(
|
const host = await context.deps.hosts.get(hostId);
|
||||||
context.userId,
|
|
||||||
hostId,
|
|
||||||
);
|
|
||||||
if (!host) return { error: "Host not found" };
|
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 };
|
if (history === null) return { commands: [], unavailable: true };
|
||||||
return { commands: history.map((entry) => entry.command) };
|
return { commands: history.map((entry) => entry.command) };
|
||||||
},
|
},
|
||||||
@@ -256,11 +252,16 @@ export const readTools: AiTool[] = [
|
|||||||
name: "get_network_topology",
|
name: "get_network_topology",
|
||||||
description: "The user's saved network topology graph, if they have one.",
|
description: "The user's saved network topology graph, if they have one.",
|
||||||
category: "read",
|
category: "read",
|
||||||
|
service: SERVICE.topology,
|
||||||
parameters: objectSchema({}),
|
parameters: objectSchema({}),
|
||||||
handler: async (_args, context) => {
|
handler: async (_args, context) => {
|
||||||
const topology = await getNetworkTopology(context.userId);
|
const result = await readService(
|
||||||
if (topology === null) return { topology: null, unavailable: true };
|
context.deps.services,
|
||||||
return { topology };
|
SERVICE.topology,
|
||||||
|
async (graph) => ({ topology: await graph.get() }),
|
||||||
|
);
|
||||||
|
if (result === null) return { topology: null, unavailable: true };
|
||||||
|
return { topology: result.topology };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
|
|
||||||
export type ToolCategory = "read" | "propose";
|
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 {
|
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;
|
userId: string;
|
||||||
conversationId: number;
|
conversationId: number;
|
||||||
/** Per-user opt-in for running allowlisted read-only commands. */
|
/** Per-user opt-in for running allowlisted read-only commands. */
|
||||||
allowReadOnlyCommands: boolean;
|
allowReadOnlyCommands: boolean;
|
||||||
|
deps: ToolDeps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AiTool {
|
export interface AiTool {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: ToolCategory;
|
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. */
|
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
|
import { setAiOptIn } from "./ai-api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks once, plainly.
|
* Asks once, plainly.
|
||||||
@@ -17,7 +17,8 @@ export function AiAssistantStep() {
|
|||||||
|
|
||||||
function apply(next: boolean) {
|
function apply(next: boolean) {
|
||||||
setEnabled(next);
|
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.
|
// Skipping the step entirely still has to mean off, not unset.
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
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 {
|
import {
|
||||||
|
Button,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/select";
|
Textarea,
|
||||||
|
} from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import {
|
||||||
getAiConversation,
|
getAiConversation,
|
||||||
getAiProviders,
|
getAiProviders,
|
||||||
getAiStatus,
|
getAiStatus,
|
||||||
|
setAiOptIn,
|
||||||
type AiProposal,
|
type AiProposal,
|
||||||
type AiProvider,
|
type AiProvider,
|
||||||
} from "./ai-api";
|
} 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
|
// 403s through the gate, which surfaces as a bare permission error
|
||||||
// rather than anything the user can act on.
|
// rather than anything the user can act on.
|
||||||
if (status.globallyEnabled && !status.enabled) {
|
if (status.globallyEnabled && !status.enabled) {
|
||||||
await saveUserPreferences({ aiAssistantEnabled: true });
|
await setAiOptIn(true);
|
||||||
}
|
}
|
||||||
if (!status.globallyEnabled) return;
|
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 { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
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 {
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/select";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import {
|
||||||
createAiProvider,
|
createAiProvider,
|
||||||
deleteAiProvider,
|
deleteAiProvider,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "./errors";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
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 { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
|
||||||
import { fieldLabel, toolLabel } from "./labels";
|
import { fieldLabel, toolLabel } from "./labels";
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
export type AiProviderType =
|
||||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||||
@@ -52,17 +83,17 @@ export interface AiStatus {
|
|||||||
|
|
||||||
export async function getAiStatus(): Promise<AiStatus> {
|
export async function getAiStatus(): Promise<AiStatus> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/plugin-api/ai/status")).data;
|
return (await api().get("/status")).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get AI status");
|
throw apiError(error, "get AI status");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
|
return (await api().get("/providers")).data.providers;
|
||||||
} catch (error) {
|
} 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;
|
defaultModel?: string | null;
|
||||||
}): Promise<AiProvider> {
|
}): Promise<AiProvider> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/plugin-api/ai/providers", input)).data
|
return (await api().post("/providers", input)).data.provider;
|
||||||
.provider;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create AI provider");
|
throw apiError(error, "create AI provider");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +122,17 @@ export async function updateAiProvider(
|
|||||||
}>,
|
}>,
|
||||||
): Promise<AiProvider> {
|
): Promise<AiProvider> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
|
return (await api().patch(`/providers/${id}`, input)).data.provider;
|
||||||
.provider;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update AI provider");
|
throw apiError(error, "update AI provider");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAiProvider(id: number): Promise<void> {
|
export async function deleteAiProvider(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.delete(`/plugin-api/ai/providers/${id}`);
|
await api().delete(`/providers/${id}`);
|
||||||
} catch (error) {
|
} 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;
|
warning?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
|
return (await api().post("/probe-models", input)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "detect models");
|
throw apiError(error, "detect models");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
|
return (await api().get(`/providers/${id}/models`)).data.models;
|
||||||
.models;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "list provider models");
|
throw apiError(error, "list provider models");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/plugin-api/ai/conversations")).data
|
return (await api().get("/conversations")).data.conversations;
|
||||||
.conversations;
|
|
||||||
} catch (error) {
|
} 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[];
|
proposals: AiProposal[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
|
return (await api().get(`/conversations/${id}`)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "load AI conversation");
|
throw apiError(error, "load AI conversation");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAiConversation(id: number): Promise<void> {
|
export async function deleteAiConversation(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
|
await api().delete(`/conversations/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete AI conversation");
|
throw apiError(error, "delete AI conversation");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +197,17 @@ export async function applyAiProposal(
|
|||||||
id: number,
|
id: number,
|
||||||
): Promise<{ success: boolean; summary: string }> {
|
): Promise<{ success: boolean; summary: string }> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
|
return (await api().post(`/proposals/${id}/apply`)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "apply AI proposal");
|
throw apiError(error, "apply AI proposal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectAiProposal(id: number): Promise<void> {
|
export async function rejectAiProposal(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
|
await api().post(`/proposals/${id}/reject`);
|
||||||
} catch (error) {
|
} 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 }> {
|
): Promise<{ success: boolean; summary: string }> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await authApi.post(
|
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
|
||||||
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
|
hostId,
|
||||||
{
|
summary,
|
||||||
hostId,
|
})
|
||||||
summary,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).data;
|
).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "mark AI proposal run in terminal");
|
throw apiError(error, "mark AI proposal run in terminal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- admin ---
|
/** Turns the assistant on or off for the current user. */
|
||||||
|
export async function setAiOptIn(enabled: boolean): Promise<void> {
|
||||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
|
await api().put("/opt-in", { enabled });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get AI enabled setting");
|
throw apiError(error, "update the AI setting");
|
||||||
}
|
} finally {
|
||||||
}
|
notifyAiStatusChanged();
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||||
|
|
||||||
|
let current: TermixApp | null = null;
|
||||||
|
|
||||||
|
/** Set in activate, cleared on deactivate. */
|
||||||
|
export function setAiApp(app: TermixApp | null): void {
|
||||||
|
current = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiApp(): TermixApp {
|
||||||
|
if (!current) throw new Error("The AI plugin is not active");
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** A readable message from anything thrown, or the fallback. */
|
||||||
|
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
@@ -12,11 +12,12 @@ import type {
|
|||||||
import { AiPanel } from "./AiPanel";
|
import { AiPanel } from "./AiPanel";
|
||||||
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
||||||
import { AiAssistantStep } from "./AiAssistantStep";
|
import { AiAssistantStep } from "./AiAssistantStep";
|
||||||
import { AiAccessSettings } from "./settings/AiAccessSettings";
|
import { ProvidersSetting } from "./settings/ProvidersSetting";
|
||||||
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
|
import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api";
|
||||||
import { getAiStatus } from "./ai-api";
|
import { setAiApp } from "./app-ref";
|
||||||
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
|
|
||||||
|
|
||||||
|
/** The rail id the assistant registers under. */
|
||||||
|
const AI_RAIL_ID = "ai";
|
||||||
const ASSISTANT_ACTION = "ai.openWithContext";
|
const ASSISTANT_ACTION = "ai.openWithContext";
|
||||||
const SIDE_PANEL_ID = "ai.assistant";
|
const SIDE_PANEL_ID = "ai.assistant";
|
||||||
|
|
||||||
@@ -57,6 +58,9 @@ function TerminalSidePanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function activate(app: TermixApp): void {
|
export function activate(app: TermixApp): void {
|
||||||
|
setAiApp(app);
|
||||||
|
app.onDispose(() => setAiApp(null));
|
||||||
|
|
||||||
let globallyEnabled = false;
|
let globallyEnabled = false;
|
||||||
let userEnabled = false;
|
let userEnabled = false;
|
||||||
const surface: (() => void)[] = [];
|
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>) =>
|
const offeredOnHost = (context: Record<string, unknown>) =>
|
||||||
userEnabled &&
|
userEnabled &&
|
||||||
(context.host as { enableAiAssistant?: boolean } | undefined)
|
(
|
||||||
?.enableAiAssistant === true;
|
context.host as
|
||||||
|
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
|
||||||
|
| undefined
|
||||||
|
)?.pluginSettings?.ai?.enableAiAssistant === true;
|
||||||
|
|
||||||
surface.push(
|
surface.push(
|
||||||
app.registerSlotContribution("terminal.toolbar", {
|
app.registerSlotContribution("terminal.toolbar", {
|
||||||
@@ -171,15 +179,23 @@ export function activate(app: TermixApp): void {
|
|||||||
{ permission: "services.use" },
|
{ permission: "services.use" },
|
||||||
);
|
);
|
||||||
|
|
||||||
app.registerSettingsComponent("access", AiAccessSettings);
|
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||||
app.registerSettingsComponent("assistant", AiUserSettings);
|
|
||||||
|
|
||||||
applyStatus();
|
applyStatus();
|
||||||
refresh();
|
refresh();
|
||||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
// The admin switch and the user's own choice are settings, saved from
|
||||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
// 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(() => {
|
app.onDispose(() => {
|
||||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
for (const event of events) window.removeEventListener(event, refresh);
|
||||||
window.removeEventListener("hiddenRailTabsChanged", 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";
|
} from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
|
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 { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
|
||||||
import { AiMessage } from "../AiMessage";
|
import { AiMessage } from "../AiMessage";
|
||||||
import { AiToolCall } from "../AiToolCall";
|
import { AiToolCall } from "../AiToolCall";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "../errors";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@termix/plugin-sdk/ui";
|
||||||
import {
|
import {
|
||||||
markAiProposalRunInTerminal,
|
markAiProposalRunInTerminal,
|
||||||
rejectAiProposal,
|
rejectAiProposal,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface TerminalHost {
|
|||||||
id: number | string;
|
id: number | string;
|
||||||
name?: string;
|
name?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
enableAiAssistant?: boolean;
|
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||||
[key: string]: unknown;
|
[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;
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "./errors";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { authApi } from "@/main-axios";
|
import { aiApp } from "./app-ref";
|
||||||
import type { AiProposal } from "./ai-api";
|
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.
|
* Drives the chat stream.
|
||||||
*
|
*
|
||||||
@@ -86,10 +76,9 @@ export function useAiStream() {
|
|||||||
let toolSequence = 0;
|
let toolSequence = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(streamUrl(), {
|
const response = await aiApp().fetch("chat/stream", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
|
||||||
import { getSnippets } from "@/api/snippets-api";
|
|
||||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs the @-mention picker in the composer.
|
* Backs the @-mention picker in the composer.
|
||||||
@@ -39,11 +37,14 @@ export function activeMentionQuery(
|
|||||||
return { query, start: at };
|
return { query, start: at };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The automations plugin's list, or nothing when it is not running. */
|
/**
|
||||||
async function listAutomationsForMentions(): Promise<
|
* Another plugin's list through its action, or nothing when it is not
|
||||||
{ id: number; name: string }[]
|
* running, so the assistant works without it and never imports its code.
|
||||||
> {
|
*/
|
||||||
const result = await invokeAction("automations.list");
|
async function listFromAction(
|
||||||
|
actionId: string,
|
||||||
|
): Promise<{ id: number; name: string }[]> {
|
||||||
|
const result = await invokeAction(actionId);
|
||||||
return Array.isArray(result)
|
return Array.isArray(result)
|
||||||
? (result as { id: number; name: string }[])
|
? (result as { id: number; name: string }[])
|
||||||
: [];
|
: [];
|
||||||
@@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise<
|
|||||||
|
|
||||||
export function useMentions(enabled: boolean) {
|
export function useMentions(enabled: boolean) {
|
||||||
const [items, setItems] = useState<MentionItem[]>([]);
|
const [items, setItems] = useState<MentionItem[]>([]);
|
||||||
|
const { hosts } = useHosts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const [hosts, snippets, automations] = await Promise.allSettled([
|
const [snippets, automations] = await Promise.allSettled([
|
||||||
getSSHHosts(),
|
listFromAction("snippets.list"),
|
||||||
getSnippets(),
|
listFromAction("automations.list"),
|
||||||
// Through the automations plugin's action, so the assistant works
|
|
||||||
// without it and never imports its code.
|
|
||||||
listAutomationsForMentions(),
|
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
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") {
|
if (snippets.status === "fulfilled") {
|
||||||
for (const snippet of (snippets.value ?? []) as any[]) {
|
for (const snippet of snippets.value) {
|
||||||
collected.push({
|
collected.push({
|
||||||
kind: "snippet",
|
kind: "snippet",
|
||||||
id: snippet.id,
|
id: snippet.id,
|
||||||
@@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (automations.status === "fulfilled") {
|
if (automations.status === "fulfilled") {
|
||||||
for (const automation of (automations.value ?? []) as any[]) {
|
for (const automation of automations.value) {
|
||||||
collected.push({
|
collected.push({
|
||||||
kind: "automation",
|
kind: "automation",
|
||||||
id: automation.id,
|
id: automation.id,
|
||||||
@@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled, hosts]);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(query: string): MentionItem[] => {
|
(query: string): MentionItem[] => {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
listCommandHistory,
|
readService,
|
||||||
setPluginServices,
|
requireService,
|
||||||
|
serviceAvailable,
|
||||||
|
SERVICE,
|
||||||
} from "../../src/backend/services.js";
|
} 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 () => {
|
it("reads the user's history through terminal.history", async () => {
|
||||||
const list = vi.fn(async () => [
|
const list = vi.fn(async () => [
|
||||||
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
||||||
]);
|
]);
|
||||||
const get = vi.fn(() => ({ list }));
|
const history = await readService(
|
||||||
setPluginServices({ get, provide: vi.fn() } as never);
|
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(list).toHaveBeenCalledWith(4, 25);
|
||||||
expect(history).toEqual([
|
expect(history).toEqual([
|
||||||
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
{ command: "ls", executedAt: "2026-01-01T00:00:00.000Z" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("answers null while the terminal is off or refuses the user", async () => {
|
it("answers null while the provider is off or refuses the user", async () => {
|
||||||
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
|
expect(
|
||||||
|
await readService(services({}), SERVICE.history, (terminal) =>
|
||||||
|
terminal.list(4, 25),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
setPluginServices({
|
const refusing = {
|
||||||
get: () => ({
|
list: async () => {
|
||||||
list: async () => {
|
throw new Error("Missing permission");
|
||||||
throw new Error("unavailable");
|
},
|
||||||
},
|
};
|
||||||
}),
|
expect(
|
||||||
provide: vi.fn(),
|
await readService(
|
||||||
} as never);
|
services({ [SERVICE.history]: refusing }),
|
||||||
expect(await listCommandHistory("user-1", 4, 25)).toBeNull();
|
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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,22 +101,22 @@ describe("evaluateEgress", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("parseAllowlist", () => {
|
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(null)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||||
expect(parseAllowlist("not json")).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
expect(parseAllowlist(undefined)).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
||||||
expect(parseAllowlist('{"a":1}')).toEqual(DEFAULT_PRIVATE_ALLOWLIST);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalises stored entries", () => {
|
it("reads one host per line, normalised, skipping anything that is not a host", () => {
|
||||||
expect(parseAllowlist('[" LocalHost ", "", "10.0.0.5"]')).toEqual([
|
expect(
|
||||||
"localhost",
|
parseAllowlist(
|
||||||
"10.0.0.5",
|
[" 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", () => {
|
it("honours a deliberately empty allowlist", () => {
|
||||||
// An admin clearing the list must actually block everything private,
|
// An admin clearing the list must actually block everything private,
|
||||||
// not silently get the defaults back.
|
// not silently get the defaults back.
|
||||||
expect(parseAllowlist("[]")).toEqual([]);
|
expect(parseAllowlist("")).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,22 +8,6 @@ vi.mock("../../src/backend/providers/registry.js", () => ({
|
|||||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
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");
|
const { runAgent } = await import("../../src/backend/engine.js");
|
||||||
|
|
||||||
function chunks(...values: ChatChunk[]) {
|
function chunks(...values: ChatChunk[]) {
|
||||||
@@ -33,14 +17,25 @@ function chunks(...values: ChatChunk[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BASE = {
|
const BASE = {
|
||||||
config: { providerType: "ollama" as const },
|
config: { providerType: "ollama" as const, fetch: vi.fn() },
|
||||||
model: "test",
|
model: "test",
|
||||||
system: "system",
|
system: "system",
|
||||||
context: {
|
context: {
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
conversationId: 1,
|
conversationId: 1,
|
||||||
allowReadOnlyCommands: false,
|
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[] = []) {
|
async function collect(history: any[] = []) {
|
||||||
|
|||||||
@@ -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() };
|
/** The plugin's settings as ctx.settings reads them: admin, then per user. */
|
||||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
function settings(
|
||||||
|
admin: Record<string, unknown>,
|
||||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
user: Record<string, unknown> = {},
|
||||||
createCurrentSettingsRepository: () => settingsRepository,
|
) {
|
||||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
return {
|
||||||
}));
|
get: vi.fn(async (key: string) => admin[key]) as never,
|
||||||
|
getUser: vi.fn(async (_userId: string, key: string) => user[key]) as never,
|
||||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
};
|
||||||
await import("../../src/backend/gating.js");
|
}
|
||||||
|
|
||||||
describe("AI gating", () => {
|
describe("AI gating", () => {
|
||||||
beforeEach(() => {
|
it("is off until an admin turns it on, so an upgrade enables nothing", async () => {
|
||||||
vi.clearAllMocks();
|
expect(await isAiGloballyEnabled(settings({}))).toBe(false);
|
||||||
});
|
expect(await isAiGloballyEnabled(settings({ globallyEnabled: true }))).toBe(
|
||||||
|
true,
|
||||||
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("blocks everyone when the admin global is off", async () => {
|
it("blocks everyone when the admin switch is off", async () => {
|
||||||
settingsRepository.getBoolean.mockResolvedValue(false);
|
const reader = settings(
|
||||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
{ globallyEnabled: false },
|
||||||
aiAssistantEnabled: true,
|
{ enabled: true, allowReadOnlyCommands: true },
|
||||||
aiReadOnlyCommands: 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.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 () => {
|
it("allows only when both gates are open", async () => {
|
||||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
const access = await resolveAiAccess(
|
||||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
settings(
|
||||||
aiAssistantEnabled: true,
|
{ globallyEnabled: true },
|
||||||
aiReadOnlyCommands: true,
|
{ enabled: true, allowReadOnlyCommands: true },
|
||||||
});
|
),
|
||||||
|
"user-1",
|
||||||
const access = await resolveAiAccess("user-1");
|
);
|
||||||
|
expect(access).toEqual({ enabled: true, allowReadOnlyCommands: true });
|
||||||
expect(access.enabled).toBe(true);
|
|
||||||
expect(access.allowReadOnlyCommands).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps read-only commands off unless separately opted in", async () => {
|
it("keeps read-only commands off unless separately opted in", async () => {
|
||||||
settingsRepository.getBoolean.mockResolvedValue(true);
|
const access = await resolveAiAccess(
|
||||||
userPreferenceRepository.findByUserId.mockResolvedValue({
|
settings({ globallyEnabled: true }, { enabled: true }),
|
||||||
aiAssistantEnabled: true,
|
"user-1",
|
||||||
aiReadOnlyCommands: null,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const access = await resolveAiAccess("user-1");
|
|
||||||
|
|
||||||
expect(access.enabled).toBe(true);
|
|
||||||
expect(access.allowReadOnlyCommands).toBe(false);
|
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
Reference in new issue
Block a user