mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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,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