feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
This commit is contained in:
LukeGus committed 2026-09-21 15:20:05 -05:00
1 parent c58ccbfc3f
commit 504710b065
43 files changed
+568 -262

No files matched your search

+4 -5
View File
@@ -205,7 +205,7 @@ export async function markAiProposalRunInTerminal(
export async function getAiGloballyEnabled(): Promise<boolean> {
try {
return (await authApi.get("/users/ai-enabled")).data.enabled;
return (await authApi.get("/ai/enabled")).data.enabled;
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
@@ -213,7 +213,7 @@ export async function getAiGloballyEnabled(): Promise<boolean> {
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/users/ai-enabled", { enabled })).data.enabled;
return (await authApi.patch("/ai/enabled", { enabled })).data.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
@@ -221,7 +221,7 @@ export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/ai-private-endpoints")).data.hosts;
return (await authApi.get("/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
@@ -231,8 +231,7 @@ export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/users/ai-private-endpoints", { hosts })).data
.hosts;
return (await authApi.patch("/ai/private-endpoints", { hosts })).data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
}
+8
View File
@@ -22,9 +22,17 @@ import { getPlugins, type PluginSummary } from "@/api/plugins-api";
/**
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
* no rail item: it opens from a host, the command palette or quick connect.
*
* ai is a rail-view/right-dock panel rather than a tab opened via
* registerTabComponent (AiPanel is rendered directly by AppShell.tsx and
* tabUtils.tsx, the same way TerminalTabContent is for the terminal), so it
* belongs here for the same reason: a built-in surface gated by whether its
* owning plugin is installed and enabled, not something registered at
* runtime.
*/
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
"ssh-terminal": ["terminal"],
ai: ["ai"],
};
let pluginEnabled = new Map<string, boolean>();
+1 -1
View File
@@ -5,7 +5,7 @@ import {
mentionLabel,
toolLabel,
} from "@/features/ai/labels";
import { AI_TOOLS } from "../../../../backend/ai/tools/catalog";
import { AI_TOOLS } from "../../../../../plugins/ai/backend/tools/catalog";
describe("humanize", () => {
it("splits camelCase into words", () => {
+42
View File
@@ -49,6 +49,24 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
};
}
function aiPlugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return plugin({
id: "ai",
name: "AI Assistant",
contributes: {
tabs: [
{
id: "ai",
titleKey: "nav.ai",
icon: "Sparkles",
openFrom: ["rail", "palette"],
},
],
},
...overrides,
});
}
describe("plugin state applied to the shell", () => {
afterEach(() => {
resetPluginState();
@@ -139,6 +157,30 @@ describe("plugin state applied to the shell", () => {
);
});
it("allows the ai tab while the plugin is enabled", () => {
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("blocks the ai tab when the plugin is disabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
});
it("allows the ai tab again when the plugin is re-enabled", () => {
applyPluginState([aiPlugin({ enabled: false })]);
expect(isTabTypeAvailable("ai")).toBe(false);
applyPluginState([aiPlugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(true);
});
it("disabling ai does not affect the terminal, and vice versa", () => {
applyPluginState([aiPlugin({ enabled: false }), plugin({ enabled: true })]);
expect(isTabTypeAvailable("ai")).toBe(false);
expect(isTabTypeAvailable("terminal")).toBe(true);
});
it("reports enablement, defaulting to true before the first load", () => {
expect(isPluginEnabled("ssh-terminal")).toBe(true);