mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -1,20 +1,20 @@
|
||||
import { isIP } from "net";
|
||||
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { BlockList, isIP } from "net";
|
||||
|
||||
/**
|
||||
* Where the assistant is allowed to send requests.
|
||||
*
|
||||
* Cloud providers are reached through the SSRF-guarded path, which refuses to
|
||||
* resolve to a private address. That guard is exactly what a self-hosted Ollama
|
||||
* on localhost trips over, so private destinations are permitted only when an
|
||||
* admin has named the host. Without that split, any logged-in user could point
|
||||
* a "provider" at an internal service and use the backend as an authenticated
|
||||
* probe of the server's own network.
|
||||
* Every provider request goes through ctx.fetch, which refuses private and
|
||||
* loopback addresses (after DNS, too) unless the exact host is listed. That
|
||||
* guard is exactly what a self-hosted Ollama on localhost trips over, so the
|
||||
* admin setting "privateEndpoints" names the hosts that may be private.
|
||||
* Without that split, any logged-in user could point a "provider" at an
|
||||
* internal service and use the backend as an authenticated probe of the
|
||||
* server's own network.
|
||||
*
|
||||
* The checks here only exist to give a useful message before the request is
|
||||
* made. ctx.fetch is the control.
|
||||
*/
|
||||
|
||||
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
|
||||
|
||||
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
|
||||
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||
"localhost",
|
||||
@@ -23,35 +23,53 @@ export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||
"host.docker.internal",
|
||||
];
|
||||
|
||||
export function parseAllowlist(raw: string | null): string[] {
|
||||
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
return parsed
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
export const PRIVATE_DESTINATION_MESSAGE =
|
||||
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.";
|
||||
|
||||
/** A bare host, not a URL: no scheme, path, port or whitespace. */
|
||||
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
|
||||
|
||||
/**
|
||||
* The admin setting is one host per line. Anything that is not a bare host
|
||||
* is ignored rather than failing the whole list.
|
||||
*/
|
||||
export function parseAllowlist(raw: unknown): string[] {
|
||||
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
const hosts: string[] = [];
|
||||
for (const line of raw.split(/[\r\n,]+/)) {
|
||||
const host = line.trim().toLowerCase();
|
||||
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export async function readPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
AI_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseAllowlist(raw);
|
||||
}
|
||||
const PRIVATE_RANGES = (() => {
|
||||
const list = new BlockList();
|
||||
list.addSubnet("0.0.0.0", 8, "ipv4");
|
||||
list.addSubnet("10.0.0.0", 8, "ipv4");
|
||||
list.addSubnet("100.64.0.0", 10, "ipv4");
|
||||
list.addSubnet("127.0.0.0", 8, "ipv4");
|
||||
list.addSubnet("169.254.0.0", 16, "ipv4");
|
||||
list.addSubnet("172.16.0.0", 12, "ipv4");
|
||||
list.addSubnet("192.168.0.0", 16, "ipv4");
|
||||
list.addAddress("::", "ipv6");
|
||||
list.addAddress("::1", "ipv6");
|
||||
list.addSubnet("fc00::", 7, "ipv6");
|
||||
list.addSubnet("fe80::", 10, "ipv6");
|
||||
return list;
|
||||
})();
|
||||
|
||||
function normalizeHost(hostname: string): string {
|
||||
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||
* True when the URL names a destination the outbound guard would refuse. A
|
||||
* bare hostname that is not an IP literal (e.g. "ollama.internal") counts as
|
||||
* private only if it is "localhost"; anything else needs DNS resolution,
|
||||
* which ctx.fetch does.
|
||||
*/
|
||||
export function isPrivateDestination(rawUrl: string): boolean {
|
||||
let url: URL;
|
||||
@@ -62,13 +80,15 @@ export function isPrivateDestination(rawUrl: string): boolean {
|
||||
}
|
||||
const host = normalizeHost(url.hostname);
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (isIP(host)) return isBlockedAddress(host);
|
||||
const family = isIP(host);
|
||||
if (family === 4) return PRIVATE_RANGES.check(host, "ipv4");
|
||||
if (family === 6) return PRIVATE_RANGES.check(host, "ipv6");
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface EgressDecision {
|
||||
allowed: boolean;
|
||||
/** True when the destination needs the allowlisted-private path. */
|
||||
/** True when the destination is an allowlisted private host. */
|
||||
isPrivate: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
@@ -96,23 +116,19 @@ export function evaluateEgress(
|
||||
}
|
||||
|
||||
const host = normalizeHost(url.hostname);
|
||||
const isPrivate = isPrivateDestination(rawUrl);
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
|
||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||
// must use the private fetch path; sending it through safeOutboundFetch
|
||||
// would reject it after DNS resolution and make hostname allowlist entries
|
||||
// ineffective. Only administrators can write this list.
|
||||
// An allowlisted hostname may resolve to a private address; ctx.fetch
|
||||
// lets it through because the same list is passed as allowPrivateHosts.
|
||||
if (normalized.includes(host)) {
|
||||
return { allowed: true, isPrivate: true };
|
||||
}
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
if (!isPrivateDestination(rawUrl)) return { allowed: true, isPrivate: false };
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
reason:
|
||||
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
|
||||
reason: PRIVATE_DESTINATION_MESSAGE,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { getErrorMessage } from "./errors.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -6,9 +6,10 @@ import type {
|
||||
ToolCall,
|
||||
} from "./providers/types.js";
|
||||
import { redact, redactToJson } from "./redaction.js";
|
||||
import { getTool, toolDefinitions } from "./tools/catalog.js";
|
||||
import { toolDefinitions } from "./tools/catalog.js";
|
||||
import {
|
||||
isProposalDraft,
|
||||
type AiTool,
|
||||
type ProposalDraft,
|
||||
type ToolContext,
|
||||
} from "./tools/types.js";
|
||||
@@ -36,6 +37,8 @@ export interface EngineOptions {
|
||||
system: string;
|
||||
history: ChatMessage[];
|
||||
context: ToolContext;
|
||||
/** What the model may call this turn; availableTools() decides. */
|
||||
tools: AiTool[];
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
@@ -43,7 +46,8 @@ export async function* runAgent(
|
||||
options: EngineOptions,
|
||||
): AsyncGenerator<EngineEvent> {
|
||||
const adapter = getAdapter(options.config.providerType);
|
||||
const tools = toolDefinitions();
|
||||
const tools = toolDefinitions(options.tools);
|
||||
const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
|
||||
const messages: ChatMessage[] = [...options.history];
|
||||
|
||||
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
||||
@@ -89,7 +93,7 @@ export async function* runAgent(
|
||||
for (const call of calls) {
|
||||
yield { type: "tool_call", name: call.name, arguments: call.arguments };
|
||||
|
||||
const result = await runTool(call, options.context);
|
||||
const result = await runTool(byName, call, options.context);
|
||||
|
||||
if (isProposalDraft(result)) {
|
||||
// Closes the tool call before the proposal card is emitted. Without
|
||||
@@ -132,10 +136,14 @@ export async function* runAgent(
|
||||
};
|
||||
}
|
||||
|
||||
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
|
||||
const tool = getTool(call.name);
|
||||
async function runTool(
|
||||
tools: Map<string, AiTool>,
|
||||
call: ToolCall,
|
||||
context: ToolContext,
|
||||
): Promise<unknown> {
|
||||
const tool = tools.get(call.name);
|
||||
|
||||
// A model can emit any name it likes; only the catalog decides what runs.
|
||||
// A model can emit any name it likes; only what was offered runs.
|
||||
if (!tool) {
|
||||
return { error: `Unknown tool: ${call.name}` };
|
||||
}
|
||||
|
||||
@@ -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 { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import type { PluginSettings } from "@termix/plugin-sdk/backend";
|
||||
import { DEFAULT_PRIVATE_ALLOWLIST, parseAllowlist } from "./egress.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the feature does not
|
||||
* The admin switch is a hard kill switch: when it is off the feature does not
|
||||
* exist for anyone, regardless of what any user has enabled. It defaults to
|
||||
* false so upgrading an existing install turns nothing on by surprise.
|
||||
*
|
||||
* Mirrors session sharing's own check, where the global also wins over the
|
||||
* per-host setting.
|
||||
* false so upgrading an existing install turns nothing on by surprise. Then
|
||||
* each user opts in for themselves, and RBAC decides what they may do.
|
||||
*/
|
||||
|
||||
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
|
||||
export type SettingsReader = Pick<PluginSettings, "get" | "getUser">;
|
||||
|
||||
export async function isAiGloballyEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
AI_GLOBAL_ENABLED_KEY,
|
||||
false,
|
||||
);
|
||||
export async function isAiGloballyEnabled(
|
||||
settings: SettingsReader,
|
||||
): Promise<boolean> {
|
||||
return (await settings.get<boolean>("globallyEnabled")) === true;
|
||||
}
|
||||
|
||||
/** The admin's private endpoint hosts, one per line. */
|
||||
export async function readPrivateAllowlist(
|
||||
settings: SettingsReader,
|
||||
): Promise<string[]> {
|
||||
const raw = await settings.get<string>("privateEndpoints");
|
||||
if (raw === undefined || raw === null) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
return parseAllowlist(raw);
|
||||
}
|
||||
|
||||
export interface AiAccess {
|
||||
@@ -30,41 +32,59 @@ export interface AiAccess {
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
|
||||
const globalEnabled = await isAiGloballyEnabled();
|
||||
if (!globalEnabled) {
|
||||
export async function resolveAiAccess(
|
||||
settings: SettingsReader,
|
||||
userId: string,
|
||||
): Promise<AiAccess> {
|
||||
if (!(await isAiGloballyEnabled(settings))) {
|
||||
return { enabled: false, allowReadOnlyCommands: false };
|
||||
}
|
||||
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
|
||||
// Unset means the user was never asked, which is not consent.
|
||||
const enabled = (await settings.getUser<boolean>(userId, "enabled")) === true;
|
||||
return {
|
||||
// Null means the user was never asked, which is not consent.
|
||||
enabled: preferences?.aiAssistantEnabled === true,
|
||||
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
|
||||
enabled,
|
||||
allowReadOnlyCommands:
|
||||
enabled &&
|
||||
(await settings.getUser<boolean>(userId, "allowReadOnlyCommands")) ===
|
||||
true,
|
||||
};
|
||||
}
|
||||
|
||||
interface GateRequest {
|
||||
aiAccess?: AiAccess;
|
||||
}
|
||||
|
||||
interface GateResponse {
|
||||
status: (code: number) => { json: (body: unknown) => unknown };
|
||||
}
|
||||
|
||||
/** Rejects any AI request unless both gates are open. */
|
||||
export function createAiGate() {
|
||||
export function createAiGate(
|
||||
settings: SettingsReader,
|
||||
currentActor: () => string | undefined,
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
req: GateRequest,
|
||||
res: GateResponse,
|
||||
next: (error?: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!req.userId) {
|
||||
const userId = currentActor();
|
||||
if (!userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await resolveAiAccess(req.userId);
|
||||
if (!access.enabled) {
|
||||
res.status(403).json({ error: "The AI assistant is not enabled" });
|
||||
return;
|
||||
try {
|
||||
const access = await resolveAiAccess(settings, userId);
|
||||
if (!access.enabled) {
|
||||
res.status(403).json({ error: "The AI assistant is not enabled" });
|
||||
return;
|
||||
}
|
||||
req.aiAccess = access;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -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 { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./services.js";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { createAiRepository } from "./repository.js";
|
||||
import { registerAiRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
setPluginServices(ctx.services);
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
startAiService(ctx.http.router());
|
||||
const repository = await createAiRepository(ctx);
|
||||
|
||||
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
|
||||
|
||||
ctx.registry.provide("ai.hostImportNormalizer", hostImportNormalizer);
|
||||
ctx.disposables.add(
|
||||
() =>
|
||||
void ctx.registry.revoke("ai.hostImportNormalizer", hostImportNormalizer),
|
||||
);
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||
// read by any borrower, and deactivate() below withdraws the offer entirely.
|
||||
// The resolve is per-user because the keys are stored per-user and encrypted
|
||||
// under that user's data key, so a borrowed read returns the acting user's
|
||||
// own default key, never someone else's.
|
||||
ctx.secrets.offer("api-key", async (userId: string) => {
|
||||
const repository = createCurrentAiRepository();
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
});
|
||||
// read by any borrower, and deactivate withdraws the offer. Keys are per
|
||||
// user, so a borrowed read returns the acting user's own key.
|
||||
ctx.secrets.offer("api-key", (userId: string) =>
|
||||
ctx.asUser(userId, async () => {
|
||||
const providers = await repository.listProviders(userId);
|
||||
const preferred = providers.find((provider) => provider.apiKeyPrefix);
|
||||
if (!preferred) return null;
|
||||
const withSecret = await repository.findProviderWithSecret(
|
||||
preferred.id,
|
||||
userId,
|
||||
);
|
||||
return withSecret?.apiKey ?? null;
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopAiService();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -1,5 +1,4 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { providerFetch } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
@@ -26,7 +25,7 @@ function createClient(config: ProviderConfig): Anthropic {
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
||||
// Routes the SDK's HTTP through the shared egress guard.
|
||||
fetch: providerFetch as unknown as typeof fetch,
|
||||
fetch: config.fetch as unknown as typeof fetch,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertOk, providerFetch, readSseLines } from "./http.js";
|
||||
import { assertOk, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
@@ -104,7 +104,7 @@ export const geminiAdapter: ProviderAdapter = {
|
||||
|
||||
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
const response = await config.fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -176,7 +176,7 @@ export const geminiAdapter: ProviderAdapter = {
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
if (!config.apiKey) return [];
|
||||
|
||||
const response = await providerFetch(
|
||||
const response = await config.fetch(
|
||||
`${baseFor(config).replace(/\/+$/, "")}/models`,
|
||||
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
|
||||
);
|
||||
|
||||
@@ -1,33 +1,63 @@
|
||||
import { fetchWithProxy } from "../../../../../src/backend/utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../../../../src/backend/utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
import type { PluginFetch } from "@termix/plugin-sdk/backend";
|
||||
import { evaluateEgress, PRIVATE_DESTINATION_MESSAGE } from "../egress.js";
|
||||
import { AiProviderError, type ProviderFetch } from "./types.js";
|
||||
|
||||
/** A model can take a while to load before the first token. */
|
||||
const PROVIDER_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function toHeaderRecord(
|
||||
headers: RequestInit["headers"] | undefined,
|
||||
): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
new Headers(headers ?? {}).forEach((value, key) => {
|
||||
record[key] = value;
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every outbound provider request goes through here so the egress rules cannot
|
||||
* be bypassed by an adapter calling fetch directly.
|
||||
*
|
||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
||||
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
||||
* refuse them), so they use the installed Undici fetch implementation with
|
||||
* its matching proxy dispatcher -- still respecting proxy configuration.
|
||||
* be bypassed by an adapter calling fetch directly. The actual request is
|
||||
* ctx.fetch, which pins DNS and refuses private addresses unless the host is
|
||||
* on the admin allowlist.
|
||||
*/
|
||||
export async function providerFetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const allowlist = await readPrivateAllowlist();
|
||||
const decision = evaluateEgress(url, allowlist);
|
||||
export function createProviderFetch(
|
||||
fetch: PluginFetch,
|
||||
allowlist: string[],
|
||||
): ProviderFetch {
|
||||
return async (url, init) => {
|
||||
const decision = evaluateEgress(url, allowlist);
|
||||
if (!decision.allowed) {
|
||||
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
||||
}
|
||||
|
||||
if (!decision.allowed) {
|
||||
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
||||
}
|
||||
if (
|
||||
init.body !== undefined &&
|
||||
init.body !== null &&
|
||||
typeof init.body !== "string"
|
||||
) {
|
||||
throw new AiProviderError("Provider requests must send a text body");
|
||||
}
|
||||
|
||||
if (decision.isPrivate) {
|
||||
return fetchWithProxy(url, init);
|
||||
}
|
||||
|
||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
||||
try {
|
||||
return await fetch(url, {
|
||||
method: init.method ?? "GET",
|
||||
headers: toHeaderRecord(init.headers),
|
||||
body: (init.body as string | null | undefined) ?? undefined,
|
||||
signal: init.signal ?? undefined,
|
||||
timeoutMs: PROVIDER_TIMEOUT_MS,
|
||||
allowPrivateHosts: allowlist,
|
||||
});
|
||||
} catch (error) {
|
||||
// A hostname that resolved to a private address is refused after DNS,
|
||||
// which the check above cannot see coming.
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/private destinations/i.test(message)) {
|
||||
throw new AiProviderError(PRIVATE_DESTINATION_MESSAGE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
|
||||
import { assertOk, joinUrl, readJsonLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
@@ -47,7 +47,7 @@ export const ollamaAdapter: ProviderAdapter = {
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
|
||||
const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: request.signal,
|
||||
@@ -117,7 +117,7 @@ export const ollamaAdapter: ProviderAdapter = {
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
|
||||
const response = await config.fetch(joinUrl(baseFor(config), "api/tags"), {
|
||||
method: "GET",
|
||||
});
|
||||
await assertOk(response, "Ollama");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
|
||||
import { assertOk, joinUrl, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
@@ -73,7 +73,7 @@ export const openAiAdapter: ProviderAdapter = {
|
||||
};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
const response = await config.fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: request.signal,
|
||||
@@ -145,7 +145,7 @@ export const openAiAdapter: ProviderAdapter = {
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
|
||||
const response = await config.fetch(joinUrl(baseFor(config), "models"), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
@@ -63,10 +63,20 @@ export type ChatChunk =
|
||||
| { type: "done"; stopReason?: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
/**
|
||||
* How an adapter reaches its provider. Always the egress-checked fetch from
|
||||
* http.ts, never the global one.
|
||||
*/
|
||||
export type ProviderFetch = (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ProviderConfig {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
fetch: ProviderFetch;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
|
||||
@@ -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";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
|
||||
interface SavedWorkspaces {
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's saved workspaces, or null when the workspaces plugin is off or
|
||||
* the user may not use it. Optional: the assistant works without it.
|
||||
* Other plugins the assistant reads and changes through, each optional. A
|
||||
* service call runs as the acting user and the provider checks that user's
|
||||
* permissions, so nothing here takes a user id.
|
||||
*/
|
||||
export async function listSavedWorkspaces(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
|
||||
if (!current) return null;
|
||||
|
||||
export const SERVICE = {
|
||||
snippets: "snippets.access",
|
||||
fleets: "fleets.access",
|
||||
automations: "automations.access",
|
||||
workspaces: "workspaces.saved",
|
||||
topology: "network-topology.graph",
|
||||
history: "terminal.history",
|
||||
homepage: "homepage.items",
|
||||
} as const;
|
||||
|
||||
export type ServiceName = (typeof SERVICE)[keyof typeof SERVICE];
|
||||
|
||||
/** Whether a compatible provider is running right now. */
|
||||
export function serviceAvailable(
|
||||
services: Pick<PluginServices, "providers">,
|
||||
service: string,
|
||||
): boolean {
|
||||
try {
|
||||
return await current
|
||||
.get<SavedWorkspaces>("workspaces.saved", { userId })
|
||||
.list();
|
||||
return services.providers(service).length > 0;
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Another plugin's graph, as ctx.services.get("network-topology.graph") returns it. */
|
||||
interface NetworkTopologyGraph {
|
||||
get: () => Promise<unknown | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's saved network topology, or null when the network-topology
|
||||
* plugin is off or the user may not use it. Optional: the tool disappears
|
||||
* without it.
|
||||
*/
|
||||
export async function getNetworkTopology(
|
||||
userId: string,
|
||||
): Promise<unknown | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<NetworkTopologyGraph>("network-topology.graph", { userId })
|
||||
.get();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
|
||||
interface FleetsAccess {
|
||||
list: () => Promise<
|
||||
Array<{ id: number; name: string; color: string | null }>
|
||||
>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's fleets, or null when the fleets plugin is off or the user may
|
||||
* not use it. Optional: the list_fleets tool disappears without it.
|
||||
*/
|
||||
export async function listFleets(
|
||||
userId: string,
|
||||
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
|
||||
* surface a failure as an error the user sees, not silently no-op.
|
||||
*/
|
||||
function requireFleetsAccess(userId: string): FleetsAccess {
|
||||
if (!current) {
|
||||
throw new Error("The fleets plugin is disabled");
|
||||
}
|
||||
return current.get<FleetsAccess>("fleets.access", { userId });
|
||||
}
|
||||
|
||||
export async function createFleet(
|
||||
userId: string,
|
||||
input: { name: string; description?: string | null },
|
||||
): Promise<{ id: number; name: string } | null> {
|
||||
if (!current) return null;
|
||||
return requireFleetsAccess(userId).create(input);
|
||||
}
|
||||
|
||||
export async function addFleetMember(
|
||||
userId: string,
|
||||
fleetId: number,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await requireFleetsAccess(userId).addMember(fleetId, hostId);
|
||||
}
|
||||
|
||||
export interface SnippetSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -115,8 +39,7 @@ export interface SnippetSummary {
|
||||
folder: string | null;
|
||||
}
|
||||
|
||||
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
|
||||
interface SnippetsAccess {
|
||||
export interface SnippetsAccess {
|
||||
list: () => Promise<SnippetSummary[]>;
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
@@ -142,115 +65,18 @@ interface SnippetsAccess {
|
||||
remove: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own snippets, or null when the snippets plugin is off or the
|
||||
* user may not use it. Optional: the list_snippets tool disappears without
|
||||
* it, and the propose_*_snippet tools fail with a clear message.
|
||||
*/
|
||||
export async function listSnippets(
|
||||
userId: string,
|
||||
): Promise<SnippetSummary[] | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.list();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<SnippetsAccess>("snippets.access", { userId })
|
||||
.get(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires the snippets plugin, unlike the read helpers above: a failed
|
||||
* proposal apply must surface as an error the user sees, not silently no-op.
|
||||
*/
|
||||
function requireSnippetsAccess(userId: string): SnippetsAccess {
|
||||
if (!current) {
|
||||
throw new Error("The snippets plugin is disabled");
|
||||
}
|
||||
return current.get<SnippetsAccess>("snippets.access", { userId });
|
||||
}
|
||||
|
||||
export async function createSnippet(
|
||||
userId: string,
|
||||
input: {
|
||||
export interface FleetsAccess {
|
||||
list: () => Promise<
|
||||
Array<{ id: number; name: string; color: string | null }>
|
||||
>;
|
||||
create: (input: {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
): Promise<{ id: number; name: string }> {
|
||||
return requireSnippetsAccess(userId).create(input);
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
addMember: (fleetId: number, hostId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
changes: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
return requireSnippetsAccess(userId).update(id, changes);
|
||||
}
|
||||
|
||||
export async function deleteSnippet(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<boolean> {
|
||||
return requireSnippetsAccess(userId).remove(id);
|
||||
}
|
||||
|
||||
/** The terminal's command history, as ctx.services.get("terminal.history") returns it. */
|
||||
interface TerminalHistory {
|
||||
list: (
|
||||
hostId: number,
|
||||
limit?: number,
|
||||
) => Promise<Array<{ command: string; executedAt: string }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's recent commands on one host, or null when the ssh-terminal
|
||||
* plugin is off or the user may not read its history.
|
||||
*/
|
||||
export async function listCommandHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit: number,
|
||||
): Promise<Array<{ command: string; executedAt: string }> | null> {
|
||||
if (!current) return null;
|
||||
try {
|
||||
return await current
|
||||
.get<TerminalHistory>("terminal.history", { userId })
|
||||
.list(hostId, limit);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
|
||||
interface AutomationsAccess {
|
||||
export interface AutomationsAccess {
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
@@ -277,55 +103,61 @@ interface AutomationsAccess {
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
function automations(userId: string): Partial<AutomationsAccess> {
|
||||
if (!current) return {};
|
||||
try {
|
||||
return current.get<AutomationsAccess>("automations.access", { userId });
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
export interface SavedWorkspaces {
|
||||
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
|
||||
}
|
||||
|
||||
export interface NetworkTopologyGraph {
|
||||
get: () => Promise<unknown | null>;
|
||||
}
|
||||
|
||||
export interface TerminalHistory {
|
||||
list: (
|
||||
hostId: number,
|
||||
limit?: number,
|
||||
) => Promise<Array<{ command: string; executedAt: string }>>;
|
||||
}
|
||||
|
||||
/** homepage.items v1, which the homepage plugin provides from B19. */
|
||||
export interface HomepageItems {
|
||||
list: () => Promise<
|
||||
Array<{ id: number; typeId: string; title: string | null }>
|
||||
>;
|
||||
}
|
||||
|
||||
interface ServiceTypes {
|
||||
"snippets.access": SnippetsAccess;
|
||||
"fleets.access": FleetsAccess;
|
||||
"automations.access": AutomationsAccess;
|
||||
"workspaces.saved": SavedWorkspaces;
|
||||
"network-topology.graph": NetworkTopologyGraph;
|
||||
"terminal.history": TerminalHistory;
|
||||
"homepage.items": HomepageItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's automations, or null when the automations plugin is off or the
|
||||
* user may not use it.
|
||||
* The service handle. Throws when the provider is missing or the user may
|
||||
* not use it; a proposal apply wants that error, a read tool catches it.
|
||||
*/
|
||||
export async function listAutomations(
|
||||
userId: string,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
|
||||
const list = automations(userId).list;
|
||||
if (typeof list !== "function") return null;
|
||||
export function requireService<S extends ServiceName>(
|
||||
services: Pick<PluginServices, "get" | "providers">,
|
||||
service: S,
|
||||
): ServiceTypes[S] {
|
||||
if (!serviceAvailable(services, service)) {
|
||||
throw new Error(`The ${service.split(".")[0]} plugin is not available`);
|
||||
}
|
||||
return services.get<ServiceTypes[S]>(service);
|
||||
}
|
||||
|
||||
/** A read through a service, or null when it is missing or refused. */
|
||||
export async function readService<S extends ServiceName, T>(
|
||||
services: Pick<PluginServices, "get" | "providers">,
|
||||
service: S,
|
||||
read: (handle: ServiceTypes[S]) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await list();
|
||||
return await read(requireService(services, service));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAutomation(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
|
||||
const get = automations(userId).get;
|
||||
if (typeof get !== "function") return undefined;
|
||||
try {
|
||||
return await get(id);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an automation through the plugin, which validates it exactly as
|
||||
* its own route does. Throws when the plugin is off.
|
||||
*/
|
||||
export async function createAutomation(
|
||||
userId: string,
|
||||
input: Parameters<AutomationsAccess["create"]>[0],
|
||||
): Promise<{ id: number; name: string }> {
|
||||
const create = automations(userId).create;
|
||||
if (typeof create !== "function") {
|
||||
throw new Error("The automations plugin is not available");
|
||||
}
|
||||
return create(input);
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
* The tools offered right now. A tool whose service has no running provider
|
||||
* is left out, so the model never sees something it cannot use.
|
||||
*/
|
||||
export function availableTools(
|
||||
isAvailable: (service: string) => boolean,
|
||||
): AiTool[] {
|
||||
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
|
||||
}
|
||||
|
||||
/** Tool definitions in the shape the provider adapters expect. */
|
||||
export function toolDefinitions(): ToolDefinitionShape[] {
|
||||
return AI_TOOLS.map((tool) => ({
|
||||
export function toolDefinitions(
|
||||
tools: AiTool[] = AI_TOOLS,
|
||||
): ToolDefinitionShape[] {
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
import {
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
getSnippet,
|
||||
createFleet,
|
||||
addFleetMember,
|
||||
createAutomation,
|
||||
} from "../services.js";
|
||||
import type { ToolDeps } from "./types.js";
|
||||
import { requireService, SERVICE } from "../services.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
@@ -22,8 +13,8 @@ const COMMAND_TIMEOUT_MS = 60_000;
|
||||
* The stored payload is treated as untrusted input even though the server wrote
|
||||
* it: the proposal could have sat in the table across a release, and defending
|
||||
* the apply path rather than the create path means one place to get right.
|
||||
* Everything goes through the same repositories a human action uses, scoped to
|
||||
* the approving user.
|
||||
* Everything goes through the same core APIs and plugin services a human
|
||||
* action uses, and runs as the approving user: the apply request's actor.
|
||||
*/
|
||||
|
||||
export interface ApplyResult {
|
||||
@@ -52,10 +43,29 @@ function optionalString(value: unknown): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Tags as ssh_data stores them, comma-separated. */
|
||||
function joinTags(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "";
|
||||
return value
|
||||
.filter((tag): tag is string => typeof tag === "string")
|
||||
.map((tag) => tag.trim().replace(/,/g, ""))
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
async function requirePermission(
|
||||
deps: ToolDeps,
|
||||
permission: string,
|
||||
): Promise<void> {
|
||||
if (!(await deps.rbac.has(permission))) {
|
||||
throw new Error("You do not have permission to do this");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyProposal(
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
userId: string,
|
||||
deps: ToolDeps,
|
||||
): Promise<ApplyResult> {
|
||||
// A payload whose tool no longer exists is refused rather than guessed at.
|
||||
if (!getTool(kind)) {
|
||||
@@ -64,31 +74,25 @@ export async function applyProposal(
|
||||
|
||||
switch (kind) {
|
||||
case "propose_create_host": {
|
||||
const created = await createCurrentHostRepository().create({
|
||||
userId,
|
||||
await requirePermission(deps, "hosts.create");
|
||||
const created = await deps.hosts.create({
|
||||
name: requireString(payload.name, "name"),
|
||||
ip: requireString(payload.ip, "ip"),
|
||||
port: Number(payload.port) || 22,
|
||||
username: optionalString(payload.username) ?? "",
|
||||
folder: optionalString(payload.folder) ?? "",
|
||||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created host ${(created as any).name ?? ""}`.trim(),
|
||||
};
|
||||
// Credentials are attached by the user after approving.
|
||||
authType: "password",
|
||||
folder: optionalString(payload.folder),
|
||||
tags: joinTags(payload.tags),
|
||||
});
|
||||
return { ok: true, summary: `Created host ${created.name ?? ""}`.trim() };
|
||||
}
|
||||
|
||||
case "propose_update_host": {
|
||||
await requirePermission(deps, "hosts.edit");
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!existing) throw new Error("Host not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
@@ -98,37 +102,32 @@ export async function applyProposal(
|
||||
if (changes.username !== undefined)
|
||||
updates.username = optionalString(changes.username) ?? "";
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder) ?? "";
|
||||
if (changes.tags !== undefined) {
|
||||
updates.tags = JSON.stringify(
|
||||
Array.isArray(changes.tags) ? changes.tags : [],
|
||||
);
|
||||
}
|
||||
updates.folder = optionalString(changes.folder);
|
||||
if (changes.tags !== undefined) updates.tags = joinTags(changes.tags);
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateForUser(
|
||||
userId,
|
||||
hostId,
|
||||
updates as any,
|
||||
);
|
||||
// update() refuses a host the approving user does not own.
|
||||
const updated = await deps.hosts.update(hostId, updates);
|
||||
if (!updated) throw new Error("Host not found");
|
||||
return { ok: true, summary: `Updated host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const deleted = await createCurrentHostRepository().deleteForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
// delete() checks hosts.delete and ownership itself.
|
||||
const deleted = await deps.hosts.delete(hostId);
|
||||
if (!deleted) throw new Error("Host not found");
|
||||
return { ok: true, summary: `Deleted host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_create_snippet": {
|
||||
const created = await createSnippet(userId, {
|
||||
const created = await requireService(
|
||||
deps.services,
|
||||
SERVICE.snippets,
|
||||
).create({
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
@@ -144,7 +143,8 @@ export async function applyProposal(
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await getSnippet(userId, snippetId);
|
||||
const snippets = requireService(deps.services, SERVICE.snippets);
|
||||
const existing = await snippets.get(snippetId);
|
||||
if (!existing) throw new Error("Snippet not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
@@ -161,23 +161,26 @@ export async function applyProposal(
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await updateSnippet(userId, snippetId, updates);
|
||||
await snippets.update(snippetId, updates);
|
||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const deleted = await deleteSnippet(userId, snippetId);
|
||||
const deleted = await requireService(
|
||||
deps.services,
|
||||
SERVICE.snippets,
|
||||
).remove(snippetId);
|
||||
if (!deleted) throw new Error("Snippet not found");
|
||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_create_fleet": {
|
||||
const fleet = await createFleet(userId, {
|
||||
const fleets = requireService(deps.services, SERVICE.fleets);
|
||||
const fleet = await fleets.create({
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
});
|
||||
if (!fleet) throw new Error("The fleets plugin is disabled");
|
||||
|
||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||
let added = 0;
|
||||
@@ -185,12 +188,9 @@ export async function applyProposal(
|
||||
const hostId = Number(raw);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
||||
// Only hosts the approving user owns can join their fleet.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) continue;
|
||||
await addFleetMember(userId, fleet.id, hostId);
|
||||
const access = await deps.hosts.checkAccess(hostId, "view");
|
||||
if (!access.isOwner) continue;
|
||||
await fleets.addMember(fleet.id, hostId);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
@@ -203,7 +203,10 @@ export async function applyProposal(
|
||||
case "propose_create_automation": {
|
||||
// The automations plugin runs the same validator its own route does,
|
||||
// so an LLM-authored definition is held to exactly the human standard.
|
||||
const created = await createAutomation(userId, {
|
||||
const created = await requireService(
|
||||
deps.services,
|
||||
SERVICE.automations,
|
||||
).create({
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: payload.definition,
|
||||
@@ -222,14 +225,10 @@ export async function applyProposal(
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const command = requireString(payload.command, "command");
|
||||
|
||||
// resolveHostById, not the raw repository row: it runs the connect-level
|
||||
// permission check, decrypts auth under the owner's key, and resolves the
|
||||
// jump host chain. A plain row has none of that, so the SSH factory saw
|
||||
// an unresolved jumpHosts field and failed the connection.
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) throw new Error("Host not found");
|
||||
|
||||
const result = await runCommandOnHost(host, command);
|
||||
// ctx.ssh resolves the host for the approving user, with the same
|
||||
// connect-level access check, owner-key decryption and jump chain a
|
||||
// terminal gets.
|
||||
const result = await runCommandOnHost(deps, hostId, command);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
@@ -245,18 +244,20 @@ export async function applyProposal(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one approved command over the shared SSH pool, mirroring how an
|
||||
* Runs one approved command over this plugin's SSH pool, mirroring how an
|
||||
* automation run_command step executes.
|
||||
*/
|
||||
async function runCommandOnHost(
|
||||
host: Record<string, any>,
|
||||
deps: ToolDeps,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const result = await withSshConnection(
|
||||
host as any as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
const result = await deps.ssh.withConnection<
|
||||
Awaited<ReturnType<typeof execCommand>>,
|
||||
Client
|
||||
>(hostId, { pool: "ai", purpose: "fleet" }, (client) =>
|
||||
execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
|
||||
import { SERVICE } from "../services.js";
|
||||
|
||||
/**
|
||||
* Propose tools never mutate anything. They return a draft that is stored as a
|
||||
@@ -100,6 +101,7 @@ export const proposeTools: AiTool[] = [
|
||||
description:
|
||||
"Propose saving a new command snippet the user can run against their hosts.",
|
||||
category: "propose",
|
||||
service: SERVICE.snippets,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Snippet name"),
|
||||
@@ -125,6 +127,7 @@ export const proposeTools: AiTool[] = [
|
||||
name: "propose_update_snippet",
|
||||
description: "Propose editing an existing snippet.",
|
||||
category: "propose",
|
||||
service: SERVICE.snippets,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to update"),
|
||||
@@ -151,6 +154,7 @@ export const proposeTools: AiTool[] = [
|
||||
name: "propose_delete_snippet",
|
||||
description: "Propose deleting a snippet.",
|
||||
category: "propose",
|
||||
service: SERVICE.snippets,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to delete"),
|
||||
@@ -170,6 +174,7 @@ export const proposeTools: AiTool[] = [
|
||||
description:
|
||||
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
|
||||
category: "propose",
|
||||
service: SERVICE.automations,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Automation name"),
|
||||
@@ -197,6 +202,7 @@ export const proposeTools: AiTool[] = [
|
||||
name: "propose_create_fleet",
|
||||
description: "Propose grouping hosts into a new fleet.",
|
||||
category: "propose",
|
||||
service: SERVICE.fleets,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Fleet name"),
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
import {
|
||||
listSavedWorkspaces,
|
||||
getNetworkTopology,
|
||||
listSnippets,
|
||||
listFleets,
|
||||
listCommandHistory,
|
||||
listAutomations,
|
||||
getAutomation,
|
||||
} from "../services.js";
|
||||
import { readService, SERVICE } from "../services.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
@@ -20,35 +8,24 @@ import {
|
||||
* primary control: a field that is never selected cannot leak.
|
||||
*/
|
||||
|
||||
interface HostSummary {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
folder: string | null;
|
||||
tags: unknown;
|
||||
protocol: string | null;
|
||||
enableTerminal: boolean | null;
|
||||
enableFileManager: boolean | null;
|
||||
enableTunnel: boolean | null;
|
||||
enableDocker: boolean | null;
|
||||
function splitTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toHostSummary(host: Record<string, any>): HostSummary {
|
||||
function toHostSummary(host: PluginHostSummary) {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name ?? null,
|
||||
ip: host.ip ?? null,
|
||||
port: host.port ?? null,
|
||||
username: host.username ?? null,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder ?? null,
|
||||
tags: host.tags ?? null,
|
||||
protocol: host.protocol ?? null,
|
||||
enableTerminal: host.enableTerminal ?? null,
|
||||
enableFileManager: host.enableFileManager ?? null,
|
||||
enableTunnel: host.enableTunnel ?? null,
|
||||
enableDocker: host.enableDocker ?? null,
|
||||
tags: splitTags(host.tags),
|
||||
authType: host.authType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,10 +37,8 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const hosts = await createCurrentHostRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
|
||||
const hosts = await context.deps.hosts.list();
|
||||
return { hosts: hosts.map(toHostSummary) };
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -76,40 +51,43 @@ export const readTools: AiTool[] = [
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const hostId = Number(args.hostId);
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
const host = await context.deps.hosts.get(Number(args.hostId));
|
||||
if (!host) return { error: "Host not found" };
|
||||
return { host: toHostSummary(host as any) };
|
||||
return { host: toHostSummary(host) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_fleets",
|
||||
description:
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
||||
category: "read",
|
||||
service: SERVICE.fleets,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const fleets = await listFleets(context.userId);
|
||||
const fleets = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.fleets,
|
||||
(fleetsAccess) => fleetsAccess.list(),
|
||||
);
|
||||
if (fleets === null) return { unavailable: true, fleets: [] };
|
||||
return {
|
||||
fleets: fleets.map((fleet) => ({
|
||||
id: fleet.id,
|
||||
name: fleet.name,
|
||||
})),
|
||||
fleets: fleets.map((fleet) => ({ id: fleet.id, name: fleet.name })),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_snippets",
|
||||
description:
|
||||
"List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.",
|
||||
"List the user's saved command snippets, including their folder and the command text.",
|
||||
category: "read",
|
||||
service: SERVICE.snippets,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const snippets = await listSnippets(context.userId);
|
||||
const snippets = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.snippets,
|
||||
(snippetsAccess) => snippetsAccess.list(),
|
||||
);
|
||||
if (!snippets) return { unavailable: true, snippets: [] };
|
||||
return {
|
||||
snippets: snippets.map((snippet) => ({
|
||||
@@ -127,9 +105,14 @@ export const readTools: AiTool[] = [
|
||||
description:
|
||||
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
||||
category: "read",
|
||||
service: SERVICE.automations,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await listAutomations(context.userId);
|
||||
const automations = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.automations,
|
||||
(automationsAccess) => automationsAccess.list(),
|
||||
);
|
||||
if (!automations) return { automations: [], unavailable: true };
|
||||
return {
|
||||
automations: automations.map((automation) => ({
|
||||
@@ -150,6 +133,7 @@ export const readTools: AiTool[] = [
|
||||
description:
|
||||
"Get one automation's full definition (trigger and steps) by id.",
|
||||
category: "read",
|
||||
service: SERVICE.automations,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
automationId: num("The automation id, as returned by list_automations"),
|
||||
@@ -157,11 +141,15 @@ export const readTools: AiTool[] = [
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await getAutomation(
|
||||
context.userId,
|
||||
Number(args.automationId),
|
||||
const result = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.automations,
|
||||
async (automationsAccess) => ({
|
||||
automation: await automationsAccess.get(Number(args.automationId)),
|
||||
}),
|
||||
);
|
||||
if (automation === undefined) return { unavailable: true };
|
||||
if (result === null) return { unavailable: true };
|
||||
const { automation } = result;
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
@@ -178,9 +166,14 @@ export const readTools: AiTool[] = [
|
||||
description:
|
||||
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
||||
category: "read",
|
||||
service: SERVICE.workspaces,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await listSavedWorkspaces(context.userId);
|
||||
const workspaces = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.workspaces,
|
||||
(saved) => saved.list(),
|
||||
);
|
||||
if (workspaces === null) return { workspaces: [], unavailable: true };
|
||||
return { workspaces };
|
||||
},
|
||||
@@ -192,16 +185,13 @@ export const readTools: AiTool[] = [
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const channels =
|
||||
await createCurrentNotificationChannelRepository().listNotificationChannels(
|
||||
context.userId,
|
||||
);
|
||||
const channels = await context.deps.notify.channels();
|
||||
return {
|
||||
channels: channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled === 1,
|
||||
enabled: channel.enabled,
|
||||
})),
|
||||
};
|
||||
},
|
||||
@@ -210,13 +200,17 @@ export const readTools: AiTool[] = [
|
||||
name: "list_homepage_items",
|
||||
description: "List the user's homepage service-link tiles.",
|
||||
category: "read",
|
||||
service: SERVICE.homepage,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const items = await createCurrentHomepageItemRepository().listByUserId(
|
||||
context.userId,
|
||||
const items = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.homepage,
|
||||
(homepage) => homepage.list(),
|
||||
);
|
||||
if (items === null) return { items: [], unavailable: true };
|
||||
return {
|
||||
items: (items as any[]).map((item) => ({
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
typeId: item.typeId,
|
||||
title: item.title ?? null,
|
||||
@@ -229,6 +223,7 @@ export const readTools: AiTool[] = [
|
||||
description:
|
||||
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
||||
category: "read",
|
||||
service: SERVICE.history,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id, as returned by list_hosts"),
|
||||
@@ -240,14 +235,15 @@ export const readTools: AiTool[] = [
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const hostId = Number(args.hostId);
|
||||
|
||||
// Ownership is enforced here rather than trusted from the model.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
// Access is checked here rather than trusted from the model.
|
||||
const host = await context.deps.hosts.get(hostId);
|
||||
if (!host) return { error: "Host not found" };
|
||||
|
||||
const history = await listCommandHistory(context.userId, hostId, limit);
|
||||
const history = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.history,
|
||||
(terminalHistory) => terminalHistory.list(hostId, limit),
|
||||
);
|
||||
if (history === null) return { commands: [], unavailable: true };
|
||||
return { commands: history.map((entry) => entry.command) };
|
||||
},
|
||||
@@ -256,11 +252,16 @@ export const readTools: AiTool[] = [
|
||||
name: "get_network_topology",
|
||||
description: "The user's saved network topology graph, if they have one.",
|
||||
category: "read",
|
||||
service: SERVICE.topology,
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const topology = await getNetworkTopology(context.userId);
|
||||
if (topology === null) return { topology: null, unavailable: true };
|
||||
return { topology };
|
||||
const result = await readService(
|
||||
context.deps.services,
|
||||
SERVICE.topology,
|
||||
async (graph) => ({ topology: await graph.get() }),
|
||||
);
|
||||
if (result === null) return { topology: null, unavailable: true };
|
||||
return { topology: result.topology };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,17 +1,31 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type ToolCategory = "read" | "propose";
|
||||
|
||||
/** What tools and the proposal executor reach core and other plugins through. */
|
||||
export type ToolDeps = Pick<
|
||||
PluginContext,
|
||||
"hosts" | "services" | "notify" | "rbac" | "ssh"
|
||||
>;
|
||||
|
||||
export interface ToolContext {
|
||||
/** Always taken from the verified JWT, never from model input. */
|
||||
/** Always the request's authenticated user, never from model input. */
|
||||
userId: string;
|
||||
conversationId: number;
|
||||
/** Per-user opt-in for running allowlisted read-only commands. */
|
||||
allowReadOnlyCommands: boolean;
|
||||
deps: ToolDeps;
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
/**
|
||||
* The service this tool needs. While no plugin provides it, the tool is
|
||||
* not offered to the model at all.
|
||||
*/
|
||||
service?: string;
|
||||
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
|
||||
import { setAiOptIn } from "./ai-api";
|
||||
|
||||
/**
|
||||
* Asks once, plainly.
|
||||
@@ -17,7 +17,8 @@ export function AiAssistantStep() {
|
||||
|
||||
function apply(next: boolean) {
|
||||
setEnabled(next);
|
||||
setAssistantEnabledForUser(next);
|
||||
// The local choice still shows if the write fails.
|
||||
setAiOptIn(next).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Skipping the step entirely still has to mean off, not unset.
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
Textarea,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getAiConversation,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
setAiOptIn,
|
||||
type AiProposal,
|
||||
type AiProvider,
|
||||
} from "./ai-api";
|
||||
@@ -133,7 +133,7 @@ export function AiPanel({ activeTab }: { activeTab?: string | null }) {
|
||||
// 403s through the gate, which surfaces as a bare permission error
|
||||
// rather than anything the user can act on.
|
||||
if (status.globallyEnabled && !status.enabled) {
|
||||
await saveUserPreferences({ aiAssistantEnabled: true });
|
||||
await setAiOptIn(true);
|
||||
}
|
||||
if (!status.globallyEnabled) return;
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
|
||||
import { fieldLabel, toolLabel } from "./labels";
|
||||
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import { aiApp } from "./app-ref";
|
||||
|
||||
/** Fired when the AI status may have changed, so every surface re-reads it. */
|
||||
export const AI_STATUS_CHANGED_EVENT = "termix-ai:status-changed";
|
||||
|
||||
export function notifyAiStatusChanged(): void {
|
||||
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/** The server's own message when it sent one, so the user sees why. */
|
||||
function apiError(error: unknown, action: string): Error {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
if (typeof data?.error === "string" && data.error) {
|
||||
return new Error(data.error);
|
||||
}
|
||||
if (error instanceof Error && error.message) return error;
|
||||
return new Error(`Failed to ${action}`);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Response bodies are typed per function below, at the edge.
|
||||
interface LooseClient {
|
||||
get(url: string): Promise<{ data: any }>;
|
||||
delete(url: string): Promise<{ data: any }>;
|
||||
post(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
put(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
patch(url: string, body?: unknown): Promise<{ data: any }>;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const api = () => aiApp().api as unknown as LooseClient;
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
@@ -52,17 +83,17 @@ export interface AiStatus {
|
||||
|
||||
export async function getAiStatus(): Promise<AiStatus> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/status")).data;
|
||||
return (await api().get("/status")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI status");
|
||||
throw apiError(error, "get AI status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
|
||||
return (await api().get("/providers")).data.providers;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI providers");
|
||||
throw apiError(error, "list AI providers");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +105,9 @@ export async function createAiProvider(input: {
|
||||
defaultModel?: string | null;
|
||||
}): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/providers", input)).data
|
||||
.provider;
|
||||
return (await api().post("/providers", input)).data.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create AI provider");
|
||||
throw apiError(error, "create AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,18 +122,17 @@ export async function updateAiProvider(
|
||||
}>,
|
||||
): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
|
||||
.provider;
|
||||
return (await api().patch(`/providers/${id}`, input)).data.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI provider");
|
||||
throw apiError(error, "update AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/providers/${id}`);
|
||||
await api().delete(`/providers/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI provider");
|
||||
throw apiError(error, "delete AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,27 +151,25 @@ export async function probeAiModels(input: {
|
||||
warning?: string;
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
|
||||
return (await api().post("/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect models");
|
||||
throw apiError(error, "detect models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
|
||||
.models;
|
||||
return (await api().get(`/providers/${id}/models`)).data.models;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list provider models");
|
||||
throw apiError(error, "list provider models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/conversations")).data
|
||||
.conversations;
|
||||
return (await api().get("/conversations")).data.conversations;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI conversations");
|
||||
throw apiError(error, "list AI conversations");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,17 +179,17 @@ export async function getAiConversation(id: number): Promise<{
|
||||
proposals: AiProposal[];
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
|
||||
return (await api().get(`/conversations/${id}`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "load AI conversation");
|
||||
throw apiError(error, "load AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
|
||||
await api().delete(`/conversations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI conversation");
|
||||
throw apiError(error, "delete AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,17 +197,17 @@ export async function applyAiProposal(
|
||||
id: number,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
|
||||
return (await api().post(`/proposals/${id}/apply`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply AI proposal");
|
||||
throw apiError(error, "apply AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectAiProposal(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
|
||||
await api().post(`/proposals/${id}/reject`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reject AI proposal");
|
||||
throw apiError(error, "reject AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,53 +222,23 @@ export async function markAiProposalRunInTerminal(
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (
|
||||
await authApi.post(
|
||||
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
|
||||
{
|
||||
hostId,
|
||||
summary,
|
||||
},
|
||||
)
|
||||
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
|
||||
hostId,
|
||||
summary,
|
||||
})
|
||||
).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "mark AI proposal run in terminal");
|
||||
throw apiError(error, "mark AI proposal run in terminal");
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
||||
/** Turns the assistant on or off for the current user. */
|
||||
export async function setAiOptIn(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
|
||||
await api().put("/opt-in", { enabled });
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
|
||||
.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
|
||||
.data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI endpoint allowlist");
|
||||
throw apiError(error, "update the AI setting");
|
||||
} finally {
|
||||
notifyAiStatusChanged();
|
||||
}
|
||||
}
|
||||
@@ -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 { TerminalAiPanel } from "./terminal/TerminalAiPanel";
|
||||
import { AiAssistantStep } from "./AiAssistantStep";
|
||||
import { AiAccessSettings } from "./settings/AiAccessSettings";
|
||||
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
|
||||
import { getAiStatus } from "./ai-api";
|
||||
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
|
||||
import { ProvidersSetting } from "./settings/ProvidersSetting";
|
||||
import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api";
|
||||
import { setAiApp } from "./app-ref";
|
||||
|
||||
/** The rail id the assistant registers under. */
|
||||
const AI_RAIL_ID = "ai";
|
||||
const ASSISTANT_ACTION = "ai.openWithContext";
|
||||
const SIDE_PANEL_ID = "ai.assistant";
|
||||
|
||||
@@ -57,6 +58,9 @@ function TerminalSidePanel({
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setAiApp(app);
|
||||
app.onDispose(() => setAiApp(null));
|
||||
|
||||
let globallyEnabled = false;
|
||||
let userEnabled = false;
|
||||
const surface: (() => void)[] = [];
|
||||
@@ -99,10 +103,14 @@ export function activate(app: TermixApp): void {
|
||||
}),
|
||||
);
|
||||
|
||||
// The per-host switch is this plugin's host setting.
|
||||
const offeredOnHost = (context: Record<string, unknown>) =>
|
||||
userEnabled &&
|
||||
(context.host as { enableAiAssistant?: boolean } | undefined)
|
||||
?.enableAiAssistant === true;
|
||||
(
|
||||
context.host as
|
||||
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
|
||||
| undefined
|
||||
)?.pluginSettings?.ai?.enableAiAssistant === true;
|
||||
|
||||
surface.push(
|
||||
app.registerSlotContribution("terminal.toolbar", {
|
||||
@@ -171,15 +179,23 @@ export function activate(app: TermixApp): void {
|
||||
{ permission: "services.use" },
|
||||
);
|
||||
|
||||
app.registerSettingsComponent("access", AiAccessSettings);
|
||||
app.registerSettingsComponent("assistant", AiUserSettings);
|
||||
app.registerSettingsComponent("providers", ProvidersSetting);
|
||||
|
||||
applyStatus();
|
||||
refresh();
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
||||
// The admin switch and the user's own choice are settings, saved from
|
||||
// the settings screen, so re-read when plugin state or focus changes too.
|
||||
let lastFocusRefresh = Date.now();
|
||||
const onFocus = () => {
|
||||
if (Date.now() - lastFocusRefresh < 30_000) return;
|
||||
lastFocusRefresh = Date.now();
|
||||
refresh();
|
||||
};
|
||||
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
|
||||
for (const event of events) window.addEventListener(event, refresh);
|
||||
window.addEventListener("focus", onFocus);
|
||||
app.onDispose(() => {
|
||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("hiddenRailTabsChanged", refresh);
|
||||
for (const event of events) window.removeEventListener(event, refresh);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getAiGloballyEnabled,
|
||||
getAiPrivateEndpoints,
|
||||
setAiGloballyEnabled,
|
||||
setAiPrivateEndpoints,
|
||||
} from "../ai-api";
|
||||
import { notifyAiStatusChanged } from "../use-ai-availability";
|
||||
|
||||
/**
|
||||
* The instance-wide switch and the private address allowlist. Stored in the
|
||||
* assistant's own backend rather than plugin settings, so this saves on
|
||||
* change instead of through the page's Save.
|
||||
*/
|
||||
export function AiAccessSettings() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [endpoints, setEndpoints] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.allSettled([
|
||||
getAiGloballyEnabled(),
|
||||
getAiPrivateEndpoints(),
|
||||
]).then(([enabledResult, endpointsResult]) => {
|
||||
if (cancelled) return;
|
||||
if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value);
|
||||
setEndpoints(
|
||||
endpointsResult.status === "fulfilled" ? endpointsResult.value : [],
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
try {
|
||||
await setAiGloballyEnabled(next);
|
||||
notifyAiStatusChanged();
|
||||
} catch {
|
||||
setEnabled(!next);
|
||||
toast.error(t("admin.updateAiEnabledFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const saveEndpoints = async (hosts: string[]) => {
|
||||
const previous = endpoints ?? [];
|
||||
setEndpoints(hosts);
|
||||
try {
|
||||
setEndpoints(await setAiPrivateEndpoints(hosts));
|
||||
} catch {
|
||||
setEndpoints(previous);
|
||||
toast.error(t("admin.updateAiEndpointsFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingRow
|
||||
label={t("admin.aiGloballyEnabled")}
|
||||
description={t("admin.aiGloballyEnabledDesc")}
|
||||
>
|
||||
<FakeSwitch checked={enabled} onChange={(next) => void toggle(next)} />
|
||||
</SettingRow>
|
||||
{enabled && endpoints !== null && (
|
||||
<div className="flex flex-col gap-1.5 py-2">
|
||||
<span className="text-xs font-medium">
|
||||
{t("admin.aiPrivateEndpoints")}
|
||||
</span>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("admin.aiPrivateEndpointsDesc")}
|
||||
</span>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
defaultValue={endpoints.join(", ")}
|
||||
placeholder="localhost, 127.0.0.1"
|
||||
onBlur={(event) =>
|
||||
void saveEndpoints(
|
||||
event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
import { getAiStatus } from "../ai-api";
|
||||
import { notifyAiStatusChanged } from "../use-ai-availability";
|
||||
|
||||
/** The rail id the assistant registers under. */
|
||||
export const AI_RAIL_ID = "ai";
|
||||
|
||||
/**
|
||||
* Turns the assistant on or off for one person. Off also hides its rail
|
||||
* entry, so declining removes the feature from view rather than leaving an
|
||||
* inert button behind. Stored in core user preferences, where it has always
|
||||
* lived, so it roams with the rest of a user's settings.
|
||||
*/
|
||||
export function setAssistantEnabledForUser(enabled: boolean): void {
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (enabled) hidden.delete(AI_RAIL_ID);
|
||||
else hidden.add(AI_RAIL_ID);
|
||||
|
||||
const serialized = JSON.stringify([...hidden]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
|
||||
saveUserPreferences({
|
||||
aiAssistantEnabled: enabled,
|
||||
hiddenRailTabs: serialized,
|
||||
})
|
||||
.catch(() => {
|
||||
// The local choice still applies if the write fails.
|
||||
})
|
||||
.finally(() => notifyAiStatusChanged());
|
||||
}
|
||||
|
||||
export function AiUserSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<{
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
readOnly: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiStatus()
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
globallyEnabled: next.globallyEnabled,
|
||||
enabled: next.enabled,
|
||||
readOnly: next.allowReadOnlyCommands,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatus({
|
||||
globallyEnabled: false,
|
||||
enabled: false,
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// A user who cannot have the feature is not told about it.
|
||||
if (!status?.globallyEnabled) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.assistantUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SettingRow
|
||||
label={t("ai.enableTitle")}
|
||||
description={t("ai.enableDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={status.enabled}
|
||||
onChange={(enabled) => {
|
||||
setStatus({ ...status, enabled });
|
||||
setAssistantEnabledForUser(enabled);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
{status.enabled && (
|
||||
<SettingRow
|
||||
label={t("ai.readOnlyCommands")}
|
||||
description={t("ai.readOnlyCommandsDescription")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={status.readOnly}
|
||||
onChange={(readOnly) => {
|
||||
setStatus({ ...status, readOnly });
|
||||
saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch(
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { AiProviderSettings } from "../AiProviderSettings";
|
||||
import {
|
||||
AI_STATUS_CHANGED_EVENT,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
type AiProvider,
|
||||
} from "../ai-api";
|
||||
|
||||
/**
|
||||
* The "providers" custom field in the plugin's user settings: a list with
|
||||
* per-provider forms, which a schema field cannot express.
|
||||
*/
|
||||
export function ProvidersSetting() {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<{
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
} | null>(null);
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
// Re-read when the "enabled" switch above this field is saved.
|
||||
const optedIn = useSettings("user").values.enabled;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await getAiStatus();
|
||||
setStatus(next);
|
||||
if (next.enabled) setProviders(await getAiProviders());
|
||||
} catch {
|
||||
setStatus({ globallyEnabled: false, enabled: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, optedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, load);
|
||||
return () => window.removeEventListener(AI_STATUS_CHANGED_EVENT, load);
|
||||
}, [load]);
|
||||
|
||||
if (status === null) return null;
|
||||
|
||||
if (!status.enabled) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.globallyEnabled
|
||||
? t("settings.providersNeedOptIn")
|
||||
: t("settings.assistantUnavailable")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AiProviderSettings providers={providers} onChanged={() => void load()} />
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
|
||||
import { AiMessage } from "../AiMessage";
|
||||
import { AiToolCall } from "../AiToolCall";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { getErrorMessage } from "../errors";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
markAiProposalRunInTerminal,
|
||||
rejectAiProposal,
|
||||
|
||||
@@ -7,7 +7,7 @@ interface TerminalHost {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
enableAiAssistant?: boolean;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface AiAvailability {
|
||||
/** Admin kill switch. When false the feature does not exist for anyone. */
|
||||
globallyEnabled: boolean;
|
||||
/** Global switch and the user's own opt-in, which is what the rail needs. */
|
||||
userEnabled: boolean;
|
||||
/** False until the status call answers, so nothing flashes in or out. */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/** Fired when the AI status may have changed, so every surface re-reads it. */
|
||||
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
|
||||
|
||||
export function notifyAiStatusChanged(): void {
|
||||
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* One source of truth for whether any AI surface should render.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the assistant is
|
||||
* hidden everywhere for everyone, including the tab bar, command palette,
|
||||
* mobile bar, and the navigation visibility toggles, so nobody can reach or
|
||||
* even see an entry the server would refuse anyway.
|
||||
*/
|
||||
export function useAiAvailability(): AiAvailability {
|
||||
const [state, setState] = useState<AiAvailability>({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
import("./ai-api")
|
||||
.then(({ getAiStatus }) => getAiStatus())
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
globallyEnabled: status.globallyEnabled,
|
||||
userEnabled: status.globallyEnabled && status.enabled,
|
||||
loaded: true,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled)
|
||||
setState({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
// The profile toggle and onboarding still fire this older event.
|
||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("hiddenRailTabsChanged", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { getErrorMessage } from "./errors";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { authApi } from "@/main-axios";
|
||||
import { aiApp } from "./app-ref";
|
||||
import type { AiProposal } from "./ai-api";
|
||||
|
||||
/**
|
||||
* Reuses whatever base authApi resolved to, so the stream follows the same
|
||||
* dev proxy, Electron localhost and reverse-proxy base path rules as every
|
||||
* other call instead of hardcoding an origin.
|
||||
*/
|
||||
function streamUrl(): string {
|
||||
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
return `${base}/ai/chat/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the chat stream.
|
||||
*
|
||||
@@ -86,10 +76,9 @@ export function useAiStream() {
|
||||
let toolSequence = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(streamUrl(), {
|
||||
const response = await aiApp().fetch("chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
@@ -39,11 +37,14 @@ export function activeMentionQuery(
|
||||
return { query, start: at };
|
||||
}
|
||||
|
||||
/** The automations plugin's list, or nothing when it is not running. */
|
||||
async function listAutomationsForMentions(): Promise<
|
||||
{ id: number; name: string }[]
|
||||
> {
|
||||
const result = await invokeAction("automations.list");
|
||||
/**
|
||||
* Another plugin's list through its action, or nothing when it is not
|
||||
* running, so the assistant works without it and never imports its code.
|
||||
*/
|
||||
async function listFromAction(
|
||||
actionId: string,
|
||||
): Promise<{ id: number; name: string }[]> {
|
||||
const result = await invokeAction(actionId);
|
||||
return Array.isArray(result)
|
||||
? (result as { id: number; name: string }[])
|
||||
: [];
|
||||
@@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise<
|
||||
|
||||
export function useMentions(enabled: boolean) {
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
const { hosts } = useHosts();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [hosts, snippets, automations] = await Promise.allSettled([
|
||||
getSSHHosts(),
|
||||
getSnippets(),
|
||||
// Through the automations plugin's action, so the assistant works
|
||||
// without it and never imports its code.
|
||||
listAutomationsForMentions(),
|
||||
const [snippets, automations] = await Promise.allSettled([
|
||||
listFromAction("snippets.list"),
|
||||
listFromAction("automations.list"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const collected: MentionItem[] = [];
|
||||
const collected: MentionItem[] = hosts.map((host) => ({
|
||||
kind: "host" as const,
|
||||
id: Number(host.id),
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
}));
|
||||
|
||||
if (hosts.status === "fulfilled") {
|
||||
for (const host of (hosts.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "host",
|
||||
id: host.id,
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snippets.status === "fulfilled") {
|
||||
for (const snippet of (snippets.value ?? []) as any[]) {
|
||||
for (const snippet of snippets.value) {
|
||||
collected.push({
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
@@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) {
|
||||
}
|
||||
}
|
||||
if (automations.status === "fulfilled") {
|
||||
for (const automation of (automations.value ?? []) as any[]) {
|
||||
for (const automation of automations.value) {
|
||||
collected.push({
|
||||
kind: "automation",
|
||||
id: automation.id,
|
||||
@@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
}, [enabled, hosts]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): MentionItem[] => {
|
||||
|
||||
Reference in new issue
Block a user