feat: finish the ai plugin (B18)

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

No files matched your search

+3 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
import { setAiOptIn } from "./ai-api";
/**
* Asks once, plainly.
@@ -17,7 +17,8 @@ export function AiAssistantStep() {
function apply(next: boolean) {
setEnabled(next);
setAssistantEnabledForUser(next);
// The local choice still shows if the write fails.
setAiOptIn(next).catch(() => undefined);
}
// Skipping the step entirely still has to mean off, not unset.
+5 -5
View File
@@ -1,20 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
import { Button } from "@/components/button";
import { Textarea } from "@/components/textarea";
import { saveUserPreferences } from "@/main-axios";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
Textarea,
} from "@termix/plugin-sdk/ui";
import {
getAiConversation,
getAiProviders,
getAiStatus,
setAiOptIn,
type AiProposal,
type AiProvider,
} from "./ai-api";
@@ -133,7 +133,7 @@ export function AiPanel({ activeTab }: { activeTab?: string | null }) {
// 403s through the gate, which surfaces as a bare permission error
// rather than anything the user can act on.
if (status.globallyEnabled && !status.enabled) {
await saveUserPreferences({ aiAssistantEnabled: true });
await setAiOptIn(true);
}
if (!status.globallyEnabled) return;
@@ -1,18 +1,18 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
} from "@termix/plugin-sdk/ui";
import {
createAiProvider,
deleteAiProvider,
+2 -2
View File
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
import { fieldLabel, toolLabel } from "./labels";
+67 -70
View File
@@ -1,4 +1,35 @@
import { authApi, handleApiError } from "@/main-axios";
import { aiApp } from "./app-ref";
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "termix-ai:status-changed";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/** The server's own message when it sent one, so the user sees why. */
function apiError(error: unknown, action: string): Error {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string" && data.error) {
return new Error(data.error);
}
if (error instanceof Error && error.message) return error;
return new Error(`Failed to ${action}`);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// Response bodies are typed per function below, at the edge.
interface LooseClient {
get(url: string): Promise<{ data: any }>;
delete(url: string): Promise<{ data: any }>;
post(url: string, body?: unknown): Promise<{ data: any }>;
put(url: string, body?: unknown): Promise<{ data: any }>;
patch(url: string, body?: unknown): Promise<{ data: any }>;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
const api = () => aiApp().api as unknown as LooseClient;
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
@@ -52,17 +83,17 @@ export interface AiStatus {
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/plugin-api/ai/status")).data;
return (await api().get("/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
throw apiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
return (await api().get("/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
throw apiError(error, "list AI providers");
}
}
@@ -74,10 +105,9 @@ export async function createAiProvider(input: {
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/plugin-api/ai/providers", input)).data
.provider;
return (await api().post("/providers", input)).data.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
throw apiError(error, "create AI provider");
}
}
@@ -92,18 +122,17 @@ export async function updateAiProvider(
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
.provider;
return (await api().patch(`/providers/${id}`, input)).data.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
throw apiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/providers/${id}`);
await api().delete(`/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
throw apiError(error, "delete AI provider");
}
}
@@ -122,27 +151,25 @@ export async function probeAiModels(input: {
warning?: string;
}> {
try {
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
return (await api().post("/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
throw apiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
.models;
return (await api().get(`/providers/${id}/models`)).data.models;
} catch (error) {
throw handleApiError(error, "list provider models");
throw apiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/plugin-api/ai/conversations")).data
.conversations;
return (await api().get("/conversations")).data.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
throw apiError(error, "list AI conversations");
}
}
@@ -152,17 +179,17 @@ export async function getAiConversation(id: number): Promise<{
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
return (await api().get(`/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
throw apiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
await api().delete(`/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
throw apiError(error, "delete AI conversation");
}
}
@@ -170,17 +197,17 @@ export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
return (await api().post(`/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
throw apiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
await api().post(`/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
throw apiError(error, "reject AI proposal");
}
}
@@ -195,53 +222,23 @@ export async function markAiProposalRunInTerminal(
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
{
hostId,
summary,
},
)
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
hostId,
summary,
})
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
throw apiError(error, "mark AI proposal run in terminal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
/** Turns the assistant on or off for the current user. */
export async function setAiOptIn(enabled: boolean): Promise<void> {
try {
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
await api().put("/opt-in", { enabled });
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
throw apiError(error, "update the AI setting");
} finally {
notifyAiStatusChanged();
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setAiApp(app: TermixApp | null): void {
current = app;
}
export function aiApp(): TermixApp {
if (!current) throw new Error("The AI plugin is not active");
return current;
}
+4
View File
@@ -0,0 +1,4 @@
/** A readable message from anything thrown, or the fallback. */
export function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
+28 -12
View File
@@ -12,11 +12,12 @@ import type {
import { AiPanel } from "./AiPanel";
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
import { AiAssistantStep } from "./AiAssistantStep";
import { AiAccessSettings } from "./settings/AiAccessSettings";
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
import { getAiStatus } from "./ai-api";
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
import { ProvidersSetting } from "./settings/ProvidersSetting";
import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api";
import { setAiApp } from "./app-ref";
/** The rail id the assistant registers under. */
const AI_RAIL_ID = "ai";
const ASSISTANT_ACTION = "ai.openWithContext";
const SIDE_PANEL_ID = "ai.assistant";
@@ -57,6 +58,9 @@ function TerminalSidePanel({
}
export function activate(app: TermixApp): void {
setAiApp(app);
app.onDispose(() => setAiApp(null));
let globallyEnabled = false;
let userEnabled = false;
const surface: (() => void)[] = [];
@@ -99,10 +103,14 @@ export function activate(app: TermixApp): void {
}),
);
// The per-host switch is this plugin's host setting.
const offeredOnHost = (context: Record<string, unknown>) =>
userEnabled &&
(context.host as { enableAiAssistant?: boolean } | undefined)
?.enableAiAssistant === true;
(
context.host as
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
| undefined
)?.pluginSettings?.ai?.enableAiAssistant === true;
surface.push(
app.registerSlotContribution("terminal.toolbar", {
@@ -171,15 +179,23 @@ export function activate(app: TermixApp): void {
{ permission: "services.use" },
);
app.registerSettingsComponent("access", AiAccessSettings);
app.registerSettingsComponent("assistant", AiUserSettings);
app.registerSettingsComponent("providers", ProvidersSetting);
applyStatus();
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.addEventListener("hiddenRailTabsChanged", refresh);
// The admin switch and the user's own choice are settings, saved from
// the settings screen, so re-read when plugin state or focus changes too.
let lastFocusRefresh = Date.now();
const onFocus = () => {
if (Date.now() - lastFocusRefresh < 30_000) return;
lastFocusRefresh = Date.now();
refresh();
};
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
for (const event of events) window.addEventListener(event, refresh);
window.addEventListener("focus", onFocus);
app.onDispose(() => {
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
for (const event of events) window.removeEventListener(event, refresh);
window.removeEventListener("focus", onFocus);
});
}
@@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
setAiGloballyEnabled,
setAiPrivateEndpoints,
} from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/**
* The instance-wide switch and the private address allowlist. Stored in the
* assistant's own backend rather than plugin settings, so this saves on
* change instead of through the page's Save.
*/
export function AiAccessSettings() {
const { t } = useTranslation();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [endpoints, setEndpoints] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
void Promise.allSettled([
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
]).then(([enabledResult, endpointsResult]) => {
if (cancelled) return;
if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value);
setEndpoints(
endpointsResult.status === "fulfilled" ? endpointsResult.value : [],
);
});
return () => {
cancelled = true;
};
}, []);
const toggle = async (next: boolean) => {
setEnabled(next);
try {
await setAiGloballyEnabled(next);
notifyAiStatusChanged();
} catch {
setEnabled(!next);
toast.error(t("admin.updateAiEnabledFailed"));
}
};
const saveEndpoints = async (hosts: string[]) => {
const previous = endpoints ?? [];
setEndpoints(hosts);
try {
setEndpoints(await setAiPrivateEndpoints(hosts));
} catch {
setEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
};
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("admin.aiGloballyEnabled")}
description={t("admin.aiGloballyEnabledDesc")}
>
<FakeSwitch checked={enabled} onChange={(next) => void toggle(next)} />
</SettingRow>
{enabled && endpoints !== null && (
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.aiPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.aiPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={endpoints.join(", ")}
placeholder="localhost, 127.0.0.1"
onBlur={(event) =>
void saveEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
)}
</div>
);
}
@@ -1,112 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui";
import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { getAiStatus } from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/** The rail id the assistant registers under. */
export const AI_RAIL_ID = "ai";
/**
* Turns the assistant on or off for one person. Off also hides its rail
* entry, so declining removes the feature from view rather than leaving an
* inert button behind. Stored in core user preferences, where it has always
* lived, so it roams with the rest of a user's settings.
*/
export function setAssistantEnabledForUser(enabled: boolean): void {
const hidden = readHiddenRailTabs();
if (enabled) hidden.delete(AI_RAIL_ID);
else hidden.add(AI_RAIL_ID);
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
saveUserPreferences({
aiAssistantEnabled: enabled,
hiddenRailTabs: serialized,
})
.catch(() => {
// The local choice still applies if the write fails.
})
.finally(() => notifyAiStatusChanged());
}
export function AiUserSettings() {
const { t } = useTranslation();
const [status, setStatus] = useState<{
globallyEnabled: boolean;
enabled: boolean;
readOnly: boolean;
} | null>(null);
useEffect(() => {
let cancelled = false;
getAiStatus()
.then((next) => {
if (!cancelled) {
setStatus({
globallyEnabled: next.globallyEnabled,
enabled: next.enabled,
readOnly: next.allowReadOnlyCommands,
});
}
})
.catch(() => {
if (!cancelled) {
setStatus({
globallyEnabled: false,
enabled: false,
readOnly: false,
});
}
});
return () => {
cancelled = true;
};
}, []);
// A user who cannot have the feature is not told about it.
if (!status?.globallyEnabled) {
return (
<p className="text-xs text-muted-foreground">
{t("settings.assistantUnavailable")}
</p>
);
}
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("ai.enableTitle")}
description={t("ai.enableDescription")}
>
<FakeSwitch
checked={status.enabled}
onChange={(enabled) => {
setStatus({ ...status, enabled });
setAssistantEnabledForUser(enabled);
}}
/>
</SettingRow>
{status.enabled && (
<SettingRow
label={t("ai.readOnlyCommands")}
description={t("ai.readOnlyCommandsDescription")}
>
<FakeSwitch
checked={status.readOnly}
onChange={(readOnly) => {
setStatus({ ...status, readOnly });
saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch(
() => {},
);
}}
/>
</SettingRow>
)}
</div>
);
}
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from "react";
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
import { AiProviderSettings } from "../AiProviderSettings";
import {
AI_STATUS_CHANGED_EVENT,
getAiProviders,
getAiStatus,
type AiProvider,
} from "../ai-api";
/**
* The "providers" custom field in the plugin's user settings: a list with
* per-provider forms, which a schema field cannot express.
*/
export function ProvidersSetting() {
const { t } = useTranslation();
const [status, setStatus] = useState<{
globallyEnabled: boolean;
enabled: boolean;
} | null>(null);
const [providers, setProviders] = useState<AiProvider[]>([]);
// Re-read when the "enabled" switch above this field is saved.
const optedIn = useSettings("user").values.enabled;
const load = useCallback(async () => {
try {
const next = await getAiStatus();
setStatus(next);
if (next.enabled) setProviders(await getAiProviders());
} catch {
setStatus({ globallyEnabled: false, enabled: false });
}
}, []);
useEffect(() => {
void load();
}, [load, optedIn]);
useEffect(() => {
window.addEventListener(AI_STATUS_CHANGED_EVENT, load);
return () => window.removeEventListener(AI_STATUS_CHANGED_EVENT, load);
}, [load]);
if (status === null) return null;
if (!status.enabled) {
return (
<p className="text-xs text-muted-foreground">
{status.globallyEnabled
? t("settings.providersNeedOptIn")
: t("settings.assistantUnavailable")}
</p>
);
}
return (
<AiProviderSettings providers={providers} onChanged={() => void load()} />
);
}
@@ -7,7 +7,7 @@ import {
} from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
import { AiMessage } from "../AiMessage";
import { AiToolCall } from "../AiToolCall";
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "../errors";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import {
markAiProposalRunInTerminal,
rejectAiProposal,
@@ -7,7 +7,7 @@ interface TerminalHost {
id: number | string;
name?: string;
ip?: string;
enableAiAssistant?: boolean;
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
[key: string]: unknown;
}
@@ -1,68 +0,0 @@
import { useEffect, useState } from "react";
export interface AiAvailability {
/** Admin kill switch. When false the feature does not exist for anyone. */
globallyEnabled: boolean;
/** Global switch and the user's own opt-in, which is what the rail needs. */
userEnabled: boolean;
/** False until the status call answers, so nothing flashes in or out. */
loaded: boolean;
}
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/**
* One source of truth for whether any AI surface should render.
*
* The admin global is a hard kill switch: when it is off the assistant is
* hidden everywhere for everyone, including the tab bar, command palette,
* mobile bar, and the navigation visibility toggles, so nobody can reach or
* even see an entry the server would refuse anyway.
*/
export function useAiAvailability(): AiAvailability {
const [state, setState] = useState<AiAvailability>({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
useEffect(() => {
let cancelled = false;
const refresh = () => {
import("./ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setState({
globallyEnabled: status.globallyEnabled,
userEnabled: status.globallyEnabled && status.enabled,
loaded: true,
});
})
.catch(() => {
if (!cancelled)
setState({
globallyEnabled: false,
userEnabled: false,
loaded: true,
});
});
};
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
// The profile toggle and onboarding still fire this older event.
window.addEventListener("hiddenRailTabsChanged", refresh);
return () => {
cancelled = true;
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
};
}, []);
return state;
}
+3 -14
View File
@@ -1,18 +1,8 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useCallback, useRef, useState } from "react";
import { authApi } from "@/main-axios";
import { aiApp } from "./app-ref";
import type { AiProposal } from "./ai-api";
/**
* Reuses whatever base authApi resolved to, so the stream follows the same
* dev proxy, Electron localhost and reverse-proxy base path rules as every
* other call instead of hardcoding an origin.
*/
function streamUrl(): string {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
return `${base}/ai/chat/stream`;
}
/**
* Drives the chat stream.
*
@@ -86,10 +76,9 @@ export function useAiStream() {
let toolSequence = 0;
try {
const response = await fetch(streamUrl(), {
const response = await aiApp().fetch("chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
message: input.message,
+22 -28
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { invokeAction } from "@termix/plugin-sdk/frontend";
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
/**
* Backs the @-mention picker in the composer.
@@ -39,11 +37,14 @@ export function activeMentionQuery(
return { query, start: at };
}
/** The automations plugin's list, or nothing when it is not running. */
async function listAutomationsForMentions(): Promise<
{ id: number; name: string }[]
> {
const result = await invokeAction("automations.list");
/**
* Another plugin's list through its action, or nothing when it is not
* running, so the assistant works without it and never imports its code.
*/
async function listFromAction(
actionId: string,
): Promise<{ id: number; name: string }[]> {
const result = await invokeAction(actionId);
return Array.isArray(result)
? (result as { id: number; name: string }[])
: [];
@@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise<
export function useMentions(enabled: boolean) {
const [items, setItems] = useState<MentionItem[]>([]);
const { hosts } = useHosts();
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const [hosts, snippets, automations] = await Promise.allSettled([
getSSHHosts(),
getSnippets(),
// Through the automations plugin's action, so the assistant works
// without it and never imports its code.
listAutomationsForMentions(),
const [snippets, automations] = await Promise.allSettled([
listFromAction("snippets.list"),
listFromAction("automations.list"),
]);
if (cancelled) return;
const collected: MentionItem[] = [];
const collected: MentionItem[] = hosts.map((host) => ({
kind: "host" as const,
id: Number(host.id),
label: host.name || host.ip,
detail: host.ip,
}));
if (hosts.status === "fulfilled") {
for (const host of (hosts.value ?? []) as any[]) {
collected.push({
kind: "host",
id: host.id,
label: host.name || host.ip,
detail: host.ip,
});
}
}
if (snippets.status === "fulfilled") {
for (const snippet of (snippets.value ?? []) as any[]) {
for (const snippet of snippets.value) {
collected.push({
kind: "snippet",
id: snippet.id,
@@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) {
}
}
if (automations.status === "fulfilled") {
for (const automation of (automations.value ?? []) as any[]) {
for (const automation of automations.value) {
collected.push({
kind: "automation",
id: automation.id,
@@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) {
return () => {
cancelled = true;
};
}, [enabled]);
}, [enabled, hosts]);
const search = useCallback(
(query: string): MentionItem[] => {