feat(ai): redesign AI settings page with per-endpoint test buttons
Rebuild the workspace AI settings page into card-based "Endpoints"
(Chat / Embeddings / Voice) matching the new design, and split the
single connection test into independent per-endpoint Test buttons.
- server: testConnection(workspaceId, capability) probes only the
requested capability ('chat' | 'embeddings'); add TestAiConnectionDto
and wire it through the /workspace/ai-settings/test controller
- client: testAiConnection(capability) + capability-typed mutation; two
independent test mutation instances so Chat/Embeddings results are isolated
- client: full rewrite of ai-provider-settings into Endpoints section —
drop the provider dropdown (driver is always openai, base URL + key
always shown), move the "AI chat" and surface the "Semantic search"
feature toggles into card headers, system message behind an Edit modal,
pgvector/reindex footer, and a disabled Voice/STT stub
- client: restyle external MCP tools and the MCP server section; collapse
the AI sections in workspace-settings; remove the standalone
ai-chat-settings component
- toggles now surface the server error message (e.g. missing pgvector)
- i18n: add new English strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1148,5 +1148,36 @@
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}"
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||
"Endpoints": "Endpoints",
|
||||
"where we fetch models": "where we fetch models",
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
"Chat / LLM": "Chat / LLM",
|
||||
"root": "root",
|
||||
"Semantic search": "Semantic search",
|
||||
"Voice / STT": "Voice / STT",
|
||||
"Voice dictation": "Voice dictation",
|
||||
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
|
||||
"Test endpoint": "Test endpoint",
|
||||
"Save endpoints": "Save endpoints",
|
||||
"External tools": "External tools",
|
||||
"Gitmost as MCP client": "Gitmost as MCP client",
|
||||
"Servers the agent calls out to.": "Servers the agent calls out to.",
|
||||
"MCP server": "MCP server",
|
||||
"expose the workspace": "expose the workspace",
|
||||
"Enable MCP server": "Enable MCP server",
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
"Resolves to {{url}}": "Resolves to {{url}}",
|
||||
"Model": "Model",
|
||||
"Done": "Done",
|
||||
"shared prompt · safety framework appended automatically": "shared prompt · safety framework appended automatically",
|
||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key",
|
||||
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · embeds pages so semantic search can find them",
|
||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)",
|
||||
"Vector search · requires pgvector": "Vector search · requires pgvector",
|
||||
"Embedding API key": "Embedding API key",
|
||||
"Embeddings": "Embeddings",
|
||||
"Leave empty to use the chat API key": "Leave empty to use the chat API key",
|
||||
"Leave empty to use the chat base URL": "Leave empty to use the chat base URL",
|
||||
"Reindex now": "Reindex now"
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { Switch, Stack } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AiChatSettings() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const [checked, setChecked] = useState<boolean>(
|
||||
workspace?.settings?.ai?.chat ?? false,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function handleToggle(value: boolean) {
|
||||
setIsLoading(true);
|
||||
const previous = checked;
|
||||
setChecked(value); // optimistic update
|
||||
try {
|
||||
const updated = await updateWorkspace({ aiChat: value });
|
||||
// Force settings.ai.chat to the new value so the atom is consistent
|
||||
// even if the response shape omits it.
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, chat: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setChecked(previous); // revert on failure
|
||||
notifications.show({
|
||||
message: t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Switch
|
||||
label={t("AI chat")}
|
||||
description={t(
|
||||
"Enable the AI chat assistant so users can have multi-turn conversations with AI about your workspace content.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
onChange={(event) => handleToggle(event.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
@@ -75,71 +76,91 @@ export default function AiMcpServers() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Group justify="flex-start">
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
{/* Header: status dot + title + "MCP client" badge + Add server */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
bg="green.6"
|
||||
style={{ borderRadius: "50%", flex: "none" }}
|
||||
/>
|
||||
<Text fw={600}>{t("External tools")}</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("Gitmost as MCP client")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onClick={openCreate}
|
||||
>
|
||||
{t("Add server")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("Servers the agent calls out to.")}
|
||||
</Text>
|
||||
|
||||
{!isLoading && (!servers || servers.length === 0) && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
{t("No external servers configured")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs" mt="sm">
|
||||
{servers?.map((server) => (
|
||||
<Paper key={server.id} withBorder p="sm" radius="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{server.url}
|
||||
<Group key={server.id} justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={500} truncate>
|
||||
{server.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: server.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
<Badge size="xs" variant="light">
|
||||
{server.transport.toUpperCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
truncate
|
||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||
>
|
||||
{server.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={server.enabled}
|
||||
aria-label={t("Enabled")}
|
||||
onChange={(event) =>
|
||||
updateMutation.mutate({
|
||||
id: server.id,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
aria-label={t("Edit")}
|
||||
onClick={() => openEdit(server)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
onClick={() => confirmDelete(server)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -156,6 +177,6 @@ export default function AiMcpServers() {
|
||||
onClose={close}
|
||||
/>
|
||||
</Modal>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import {
|
||||
useAiSettingsQuery,
|
||||
useReindexAiEmbeddingsMutation,
|
||||
useTestAiConnectionMutation,
|
||||
useUpdateAiSettingsMutation,
|
||||
} from "@/features/workspace/queries/ai-settings-query.ts";
|
||||
import {
|
||||
AiDriver,
|
||||
IAiSettingsUpdate,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import { IAiSettingsUpdate } from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import AiMcpServers from "./ai-mcp-servers.tsx";
|
||||
|
||||
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
|
||||
// the user-editable fields. `apiKey` / `embeddingApiKey` are write-only buffers
|
||||
// (empty means "leave unchanged" unless explicitly cleared).
|
||||
const formSchema = z.object({
|
||||
driver: z.enum(["openai", "gemini", "ollama"]),
|
||||
chatModel: z.string(),
|
||||
embeddingModel: z.string(),
|
||||
baseUrl: z.string(),
|
||||
// Embedding-specific base URL. Empty means "use the chat base URL".
|
||||
embeddingBaseUrl: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
// Write-only key buffer. Empty string means "do not change" (unless explicitly cleared).
|
||||
apiKey: z.string(),
|
||||
// Write-only embedding key buffer. Same semantics as `apiKey`.
|
||||
embeddingApiKey: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
// Status of an endpoint card, drives the little status dot color.
|
||||
type CardStatus = "ok" | "error" | "idle";
|
||||
|
||||
// Resolve a "Base URL + path" hint defensively: trim a single trailing slash
|
||||
// off the base, then append the path. Empty base falls back to `fallback`
|
||||
// (the chat base URL for the embedding/voice endpoints). Purely cosmetic.
|
||||
function resolveUrl(base: string, path: string, fallback = ""): string {
|
||||
const trimmed = (base.trim() || fallback.trim()).replace(/\/$/, "");
|
||||
return `${trimmed}${path}`;
|
||||
}
|
||||
|
||||
// Small colored dot used in each card header.
|
||||
function StatusDot({ status }: { status: CardStatus }) {
|
||||
const theme = useMantineTheme();
|
||||
const color =
|
||||
status === "ok"
|
||||
? theme.colors.green[6]
|
||||
: status === "error"
|
||||
? theme.colors.red[6]
|
||||
: theme.colors.gray[5];
|
||||
return (
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: "50%", background: color, flex: "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AiProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
@@ -50,9 +87,23 @@ export default function AiProviderSettings() {
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin);
|
||||
const updateMutation = useUpdateAiSettingsMutation();
|
||||
const testMutation = useTestAiConnectionMutation();
|
||||
const reindexMutation = useReindexAiEmbeddingsMutation();
|
||||
|
||||
// Two independent test mutations so each card has its own loading + result.
|
||||
const chatTest = useTestAiConnectionMutation();
|
||||
const embedTest = useTestAiConnectionMutation();
|
||||
|
||||
// Workspace-level feature toggles live in the card headers.
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [chatEnabled, setChatEnabled] = useState<boolean>(
|
||||
workspace?.settings?.ai?.chat ?? false,
|
||||
);
|
||||
const [searchEnabled, setSearchEnabled] = useState<boolean>(
|
||||
workspace?.settings?.ai?.search ?? false,
|
||||
);
|
||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||
|
||||
// Whether a key is currently stored server-side (drives the placeholder).
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
// Tracks whether the user explicitly cleared the stored key.
|
||||
@@ -61,10 +112,12 @@ export default function AiProviderSettings() {
|
||||
const [hasEmbeddingApiKey, setHasEmbeddingApiKey] = useState(false);
|
||||
const [embeddingKeyCleared, setEmbeddingKeyCleared] = useState(false);
|
||||
|
||||
// Modal for the (large) system message editor.
|
||||
const [promptOpened, promptHandlers] = useDisclosure(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
driver: "openai",
|
||||
chatModel: "",
|
||||
embeddingModel: "",
|
||||
baseUrl: "",
|
||||
@@ -75,11 +128,11 @@ export default function AiProviderSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
// Hydrate the form once the masked settings load.
|
||||
// Hydrate the form once the masked settings load. We ignore `settings.driver`
|
||||
// entirely — the driver is always "openai".
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
driver: settings.driver ?? "openai",
|
||||
chatModel: settings.chatModel ?? "",
|
||||
embeddingModel: settings.embeddingModel ?? "",
|
||||
baseUrl: settings.baseUrl ?? "",
|
||||
@@ -93,23 +146,19 @@ export default function AiProviderSettings() {
|
||||
setKeyCleared(false);
|
||||
setHasEmbeddingApiKey(settings.hasEmbeddingApiKey);
|
||||
setEmbeddingKeyCleared(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings]);
|
||||
|
||||
const driver = form.values.driver as AiDriver;
|
||||
// Ollama runs locally and needs no API key.
|
||||
const showApiKey = driver === "openai" || driver === "gemini";
|
||||
// OpenAI and Ollama accept a custom base URL; Gemini does not.
|
||||
const showBaseUrl = driver === "openai" || driver === "ollama";
|
||||
|
||||
function buildPayload(values: FormValues): IAiSettingsUpdate {
|
||||
const payload: IAiSettingsUpdate = {
|
||||
driver: values.driver,
|
||||
// Everything is OpenAI-compatible.
|
||||
driver: "openai",
|
||||
chatModel: values.chatModel,
|
||||
embeddingModel: values.embeddingModel,
|
||||
// Send the base URLs only for providers that use them. The embedding base
|
||||
// URL is optional; empty falls back to the chat base URL server-side.
|
||||
baseUrl: showBaseUrl ? values.baseUrl : "",
|
||||
embeddingBaseUrl: showBaseUrl ? values.embeddingBaseUrl : "",
|
||||
// The embedding base URL is optional; empty falls back to the chat base
|
||||
// URL server-side.
|
||||
baseUrl: values.baseUrl,
|
||||
embeddingBaseUrl: values.embeddingBaseUrl,
|
||||
systemPrompt: values.systemPrompt,
|
||||
};
|
||||
|
||||
@@ -117,19 +166,17 @@ export default function AiProviderSettings() {
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit the key entirely (leave unchanged)
|
||||
if (showApiKey) {
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
// Same write-only semantics for the embedding-specific key.
|
||||
if (values.embeddingApiKey.length > 0) {
|
||||
payload.embeddingApiKey = values.embeddingApiKey;
|
||||
} else if (embeddingKeyCleared) {
|
||||
payload.embeddingApiKey = "";
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -159,176 +206,426 @@ export default function AiProviderSettings() {
|
||||
form.setFieldValue("embeddingApiKey", "");
|
||||
}
|
||||
|
||||
const driverOptions = [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
];
|
||||
// Optimistic toggle for the "AI chat" feature (settings.ai.chat).
|
||||
async function handleToggleChat(value: boolean) {
|
||||
setChatToggleLoading(true);
|
||||
const previous = chatEnabled;
|
||||
setChatEnabled(value);
|
||||
try {
|
||||
const updated = await updateWorkspace({ aiChat: value });
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, chat: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
setChatEnabled(previous); // revert on failure
|
||||
// Surface the server-side error message (e.g. missing pgvector) instead of
|
||||
// a generic fallback, mirroring useUpdateAiSettingsMutation.
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setChatToggleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const testResult = testMutation.data;
|
||||
// Optimistic toggle for the "Semantic search" feature (settings.ai.search).
|
||||
// Enabling can fail server-side when pgvector is missing — the error
|
||||
// notification surfaces that and we revert.
|
||||
async function handleToggleSearch(value: boolean) {
|
||||
setSearchToggleLoading(true);
|
||||
const previous = searchEnabled;
|
||||
setSearchEnabled(value);
|
||||
try {
|
||||
const updated = await updateWorkspace({ aiSearch: value });
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, search: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
setSearchEnabled(previous); // revert on failure
|
||||
// Surface the server-side error message (e.g. missing pgvector) instead of
|
||||
// a generic fallback, mirroring useUpdateAiSettingsMutation.
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setSearchToggleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Admins only — match the previous behavior.
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Only workspace admins can manage AI provider settings.")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const chatStatus: CardStatus = chatTest.data
|
||||
? chatTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
const embedStatus: CardStatus = embedTest.data
|
||||
? embedTest.data.ok
|
||||
? "ok"
|
||||
: "error"
|
||||
: "idle";
|
||||
|
||||
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
|
||||
const embedResolved = resolveUrl(
|
||||
form.values.embeddingBaseUrl,
|
||||
"/embeddings",
|
||||
form.values.baseUrl,
|
||||
);
|
||||
|
||||
const monoFont = "ui-monospace, Menlo, monospace";
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Select
|
||||
label={t("Provider")}
|
||||
data={driverOptions}
|
||||
allowDeselect={false}
|
||||
disabled={!isAdmin || isLoading}
|
||||
{...form.getInputProps("driver")}
|
||||
/>
|
||||
{/* Section header */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("Endpoints")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("where we fetch models")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={-8}>
|
||||
{t(
|
||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{showApiKey && (
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
// Placeholder hints whether a key is already stored; the value is never shown.
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
readOnly={!isAdmin}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showApiKey && isAdmin && hasApiKey && (
|
||||
<Group justify="flex-start" mt={-8}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
{t("Clear key")}
|
||||
</Button>
|
||||
{/* Card 1 — Chat / LLM (root endpoint) */}
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={chatStatus} />
|
||||
<Text fw={600}>{t("Chat / LLM")}</Text>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("root")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Switch
|
||||
label={t("AI chat")}
|
||||
labelPosition="left"
|
||||
checked={chatEnabled}
|
||||
disabled={chatToggleLoading}
|
||||
onChange={(e) => handleToggleChat(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} mb="md">
|
||||
{t(
|
||||
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label={t("Model")}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{showBaseUrl && (
|
||||
<TextInput
|
||||
mt="sm"
|
||||
label={t("Base URL")}
|
||||
readOnly={!isAdmin}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("baseUrl")}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={4} style={{ fontFamily: monoFont }} truncate>
|
||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t("Chat model")}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
<Group mt="md" align="center">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
loading={chatTest.isPending}
|
||||
onClick={() => chatTest.mutate("chat")}
|
||||
>
|
||||
{t("Test endpoint")}
|
||||
</Button>
|
||||
{chatTest.data &&
|
||||
(chatTest.data.ok ? (
|
||||
<Text size="sm" c="green">
|
||||
{t("Connection successful")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="red">
|
||||
{chatTest.data.error || t("Connection failed")}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label={t("Embedding model")}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
{/* Footer: system message editor */}
|
||||
<Box
|
||||
mt="md"
|
||||
mx="calc(var(--mantine-spacing-lg) * -1)"
|
||||
mb="calc(var(--mantine-spacing-lg) * -1)"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
background: "var(--mantine-color-default-hover)",
|
||||
borderRadius: "0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">
|
||||
{t("System message")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("shared prompt · safety framework appended automatically")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={promptHandlers.open}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Card 2 — Embeddings */}
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status={embedStatus} />
|
||||
<Text fw={600}>{t("Embeddings")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
label={t("Semantic search")}
|
||||
labelPosition="left"
|
||||
checked={searchEnabled}
|
||||
disabled={searchToggleLoading}
|
||||
onChange={(e) => handleToggleSearch(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} mb="md">
|
||||
{t("/v1/embeddings · embeds pages so semantic search can find them")}
|
||||
</Text>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label={t("Model")}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
{hasEmbeddingApiKey && (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
c="red"
|
||||
size="xs"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{showBaseUrl && (
|
||||
<TextInput
|
||||
label={t("Embedding base URL")}
|
||||
mt="sm"
|
||||
label={t("Base URL")}
|
||||
placeholder={t("Leave empty to use the chat base URL")}
|
||||
readOnly={!isAdmin}
|
||||
disabled={isLoading}
|
||||
{...form.getInputProps("embeddingBaseUrl")}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={4} style={{ fontFamily: monoFont }} truncate>
|
||||
{t("Resolves to {{url}}", { url: embedResolved })}
|
||||
</Text>
|
||||
|
||||
{showApiKey && (
|
||||
<PasswordInput
|
||||
label={t("Embedding API key")}
|
||||
// Placeholder hints whether a dedicated key is stored and the fallback;
|
||||
// the value is never shown.
|
||||
placeholder={
|
||||
hasEmbeddingApiKey
|
||||
? t("•••• set")
|
||||
: t("Leave empty to use the chat API key")
|
||||
}
|
||||
readOnly={!isAdmin}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("embeddingApiKey")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showApiKey && isAdmin && hasEmbeddingApiKey && (
|
||||
<Group justify="flex-start" mt={-8}>
|
||||
<Group mt="md" align="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
onClick={handleClearEmbeddingKey}
|
||||
variant="default"
|
||||
size="sm"
|
||||
loading={embedTest.isPending}
|
||||
onClick={() => embedTest.mutate("embeddings")}
|
||||
>
|
||||
{t("Clear key")}
|
||||
{t("Test endpoint")}
|
||||
</Button>
|
||||
{embedTest.data &&
|
||||
(embedTest.data.ok ? (
|
||||
<Text size="sm" c="green">
|
||||
{t("Connection successful")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="red">
|
||||
{embedTest.data.error || t("Connection failed")}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<Group justify="space-between" mt={-8}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Indexed {{indexed}} of {{total}} pages", {
|
||||
indexed: settings.indexedPages ?? 0,
|
||||
total: settings.totalPages ?? 0,
|
||||
})}
|
||||
{/* Footer: vector search / reindex */}
|
||||
<Box
|
||||
mt="md"
|
||||
mx="calc(var(--mantine-spacing-lg) * -1)"
|
||||
mb="calc(var(--mantine-spacing-lg) * -1)"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
background: "var(--mantine-color-default-hover)",
|
||||
borderRadius: "0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Vector search · requires pgvector")}
|
||||
</Text>
|
||||
{isAdmin && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Indexed {{indexed}} of {{total}} pages", {
|
||||
indexed: settings?.indexedPages ?? 0,
|
||||
total: settings?.totalPages ?? 0,
|
||||
})}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => reindexMutation.mutate()}
|
||||
loading={reindexMutation.isPending}
|
||||
onClick={() => reindexMutation.mutate()}
|
||||
>
|
||||
{t("Reindex now")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Card 3 — Voice / STT (disabled stub, not wired to the form/backend) */}
|
||||
<Paper withBorder radius="md" p="lg" opacity={0.6}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<StatusDot status="idle" />
|
||||
<Text fw={600}>{t("Voice / STT")}</Text>
|
||||
</Group>
|
||||
<Switch
|
||||
label={t("Voice dictation")}
|
||||
labelPosition="left"
|
||||
checked={false}
|
||||
disabled
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4} mb="md">
|
||||
{t(
|
||||
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)",
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label={t("System message")}
|
||||
description={t(
|
||||
"A built-in safety framework is always appended.",
|
||||
)}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={10}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("systemPrompt")}
|
||||
/>
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
color={testResult.ok ? "green" : "red"}
|
||||
icon={testResult.ok ? <IconCheck size={16} /> : <IconX size={16} />}
|
||||
>
|
||||
{testResult.ok
|
||||
? t("Connection successful")
|
||||
: testResult.error || t("Connection failed")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Group>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleSubmit(form.values)}
|
||||
disabled={updateMutation.isPending || !form.isValid()}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => testMutation.mutate()}
|
||||
loading={testMutation.isPending}
|
||||
>
|
||||
{t("Test connection")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isAdmin && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Only workspace admins can manage AI provider settings.")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<TextInput label={t("Model")} value="" disabled readOnly />
|
||||
<PasswordInput label={t("API key")} value="" disabled readOnly />
|
||||
</Group>
|
||||
<TextInput mt="sm" label={t("Base URL")} value="" disabled readOnly />
|
||||
|
||||
<Group mt="md">
|
||||
<Button variant="default" size="sm" disabled>
|
||||
{t("Test endpoint")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
mx="calc(var(--mantine-spacing-lg) * -1)"
|
||||
mb="calc(var(--mantine-spacing-lg) * -1)"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
background: "var(--mantine-color-default-hover)",
|
||||
borderRadius: "0 0 var(--mantine-radius-md) var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Voice dictation is not available yet.")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Nested: external MCP tools the agent calls out to */}
|
||||
<AiMcpServers />
|
||||
|
||||
{/* Save all endpoint settings */}
|
||||
<Group>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit(form.values).catch(() => {})}
|
||||
disabled={updateMutation.isPending || !form.isValid()}
|
||||
loading={updateMutation.isPending}
|
||||
>
|
||||
{t("Save endpoints")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* System message editor modal (edits form state; persisted on Save) */}
|
||||
<Modal
|
||||
opened={promptOpened}
|
||||
onClose={promptHandlers.close}
|
||||
title={t("System message")}
|
||||
size="lg"
|
||||
>
|
||||
<Stack>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={6}
|
||||
maxRows={20}
|
||||
description={t("A built-in safety framework is always appended.")}
|
||||
{...form.getInputProps("systemPrompt")}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={promptHandlers.close}>{t("Done")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { Switch, TextInput, Stack, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Switch,
|
||||
TextInput,
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Group,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconCopy, IconCheck } from "@tabler/icons-react";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
@@ -52,47 +61,60 @@ export default function McpSettings() {
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Switch
|
||||
label={t("Model Context Protocol (MCP)")}
|
||||
description={t(
|
||||
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
onChange={(event) => handleToggle(event.currentTarget.checked)}
|
||||
/>
|
||||
{/* Section header */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={700} size="lg">
|
||||
{t("MCP server")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{t("expose the workspace")}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{checked && (
|
||||
<TextInput
|
||||
label={t("MCP Server URL")}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
variant="filled"
|
||||
rightSection={
|
||||
<CopyButton value={mcpUrl}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
withArrow
|
||||
position="left"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
}
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Switch
|
||||
label={t("Enable MCP server")}
|
||||
description={t(
|
||||
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
|
||||
)}
|
||||
checked={checked}
|
||||
disabled={!isAdmin || isLoading}
|
||||
onChange={(event) => handleToggle(event.currentTarget.checked)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{checked && (
|
||||
<TextInput
|
||||
mt="md"
|
||||
label={t("MCP Server URL")}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
variant="filled"
|
||||
rightSection={
|
||||
<CopyButton value={mcpUrl}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? t("Copied") : t("Copy")}
|
||||
withArrow
|
||||
position="left"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
variant="subtle"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IAiSettings,
|
||||
IAiSettingsUpdate,
|
||||
IAiTestResult,
|
||||
AiTestCapability,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -49,8 +50,8 @@ export function useUpdateAiSettingsMutation() {
|
||||
}
|
||||
|
||||
export function useTestAiConnectionMutation() {
|
||||
return useMutation<IAiTestResult, Error, void>({
|
||||
mutationFn: () => testAiConnection(),
|
||||
return useMutation<IAiTestResult, Error, AiTestCapability>({
|
||||
mutationFn: (capability) => testAiConnection(capability),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface IAiTestResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Which endpoint a connection test probes.
|
||||
export type AiTestCapability = "chat" | "embeddings";
|
||||
|
||||
export async function getAiSettings(): Promise<IAiSettings> {
|
||||
const req = await api.post<IAiSettings>("/workspace/ai-settings");
|
||||
return req.data;
|
||||
@@ -56,8 +59,12 @@ export async function updateAiSettings(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function testAiConnection(): Promise<IAiTestResult> {
|
||||
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test");
|
||||
export async function testAiConnection(
|
||||
capability: AiTestCapability,
|
||||
): Promise<IAiTestResult> {
|
||||
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test", {
|
||||
capability,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
|
||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
|
||||
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
|
||||
import AiChatSettings from "@/features/workspace/components/settings/components/ai-chat-settings.tsx";
|
||||
import AiMcpServers from "@/features/workspace/components/settings/components/ai-mcp-servers.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
@@ -25,27 +23,12 @@ export default function WorkspaceSettings() {
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SettingsTitle title={t("AI & MCP")} />
|
||||
<SettingsTitle title={t("AI")} />
|
||||
{isAdmin && <AiProviderSettings />}
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<McpSettings />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
<SettingsTitle title={t("AI / Models")} />
|
||||
<AiProviderSettings />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SettingsTitle title={t("AI / Chat")} />
|
||||
<AiChatSettings />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SettingsTitle title={t("AI / External tools (MCP)")} />
|
||||
<AiMcpServers />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { AiService } from './ai.service';
|
||||
import { AiSettingsService } from './ai-settings.service';
|
||||
import { UpdateAiSettingsDto } from './dto/update-ai-settings.dto';
|
||||
import { TestAiConnectionDto } from './dto/test-ai-connection.dto';
|
||||
|
||||
/**
|
||||
* Admin-only AI provider settings (§6.4). Routes are POST to match the rest of
|
||||
@@ -69,11 +70,12 @@ export class AiSettingsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('test')
|
||||
async testConnection(
|
||||
@Body() dto: TestAiConnectionDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
this.assertAdmin(user, workspace);
|
||||
return this.aiService.testConnection(workspace.id);
|
||||
return this.aiService.testConnection(workspace.id, dto.capability);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -161,59 +161,53 @@ export class AiService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap connectivity check for the "Test connection" button. Probes the
|
||||
* configured chat model (a one-word generation) AND the configured embeddings
|
||||
* model (embedding a tiny string) independently:
|
||||
* - a probe is skipped when that capability is not configured (its
|
||||
* NotConfigured exception), so a chat-only or embeddings-only workspace
|
||||
* still tests fine;
|
||||
* - any real failure returns ok:false with the provider's own cause
|
||||
* (statusCode + truncated response body via describeProviderError),
|
||||
* prefixed Chat: / Embeddings: so the failing side is obvious;
|
||||
* - if neither capability is configured, reports "not configured".
|
||||
* Cheap connectivity check for a single "Test endpoint" button. Probes ONLY
|
||||
* the requested capability so each card in the UI surfaces its own result:
|
||||
* - `chat`: a one-word generation against the configured chat model;
|
||||
* - `embeddings`: embedding a tiny string against the embedding model.
|
||||
*
|
||||
* A capability that is not configured returns a plain "… is not configured"
|
||||
* message; any real failure returns ok:false with the provider's own cause
|
||||
* (statusCode + truncated response body via describeProviderError). The
|
||||
* decrypted key is never logged or returned — AI SDK error fields do not carry
|
||||
* it, and the resolved config is never dumped.
|
||||
*
|
||||
* Probing embeddings here catches a misconfigured embeddings endpoint (e.g.
|
||||
* one returning non-JSON, which the background RAG indexer would otherwise hit
|
||||
* as an opaque "Invalid JSON response") at config time instead of silently
|
||||
* during indexing. The decrypted key is never logged or returned — AI SDK
|
||||
* error fields do not carry it, and the resolved config is never dumped.
|
||||
* during indexing.
|
||||
*/
|
||||
async testConnection(
|
||||
workspaceId: string,
|
||||
capability: 'chat' | 'embeddings' = 'chat',
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
let probed = false;
|
||||
if (capability === 'embeddings') {
|
||||
try {
|
||||
await this.embedTexts(workspaceId, ['ping']);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof AiEmbeddingNotConfiguredException) {
|
||||
return { ok: false, error: 'Embeddings are not configured' };
|
||||
}
|
||||
this.logger.error('AI embedding test connection failed', err as Error);
|
||||
return { ok: false, error: describeProviderError(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Chat probe — only when a chat model is configured.
|
||||
// Default: chat probe.
|
||||
try {
|
||||
const model = await this.getChatModel(workspaceId);
|
||||
// maxOutputTokens keeps the probe cheap and avoids providers (e.g.
|
||||
// OpenRouter) reserving/charging for the model's full max-token budget,
|
||||
// which would 402 on a key with limited credit.
|
||||
await generateText({ model, prompt: 'ping', maxOutputTokens: 16 });
|
||||
probed = true;
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (!(err instanceof AiNotConfiguredException)) {
|
||||
this.logger.error('AI chat test connection failed', err as Error);
|
||||
return { ok: false, error: `Chat: ${describeProviderError(err)}` };
|
||||
if (err instanceof AiNotConfiguredException) {
|
||||
return { ok: false, error: 'Chat is not configured' };
|
||||
}
|
||||
// Chat not configured: skip — embeddings may still be configured.
|
||||
this.logger.error('AI chat test connection failed', err as Error);
|
||||
return { ok: false, error: describeProviderError(err) };
|
||||
}
|
||||
|
||||
// Embedding probe — only when an embedding model is configured.
|
||||
try {
|
||||
await this.embedTexts(workspaceId, ['ping']);
|
||||
probed = true;
|
||||
} catch (err) {
|
||||
if (!(err instanceof AiEmbeddingNotConfiguredException)) {
|
||||
this.logger.error('AI embedding test connection failed', err as Error);
|
||||
return { ok: false, error: `Embeddings: ${describeProviderError(err)}` };
|
||||
}
|
||||
// Embeddings not configured: skip.
|
||||
}
|
||||
|
||||
if (!probed) {
|
||||
return { ok: false, error: 'AI provider not configured' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
// Body for POST /workspace/ai-settings/test. Selects which endpoint to probe;
|
||||
// defaults to the chat endpoint server-side when omitted.
|
||||
export class TestAiConnectionDto {
|
||||
@IsOptional()
|
||||
@IsIn(['chat', 'embeddings'])
|
||||
capability?: 'chat' | 'embeddings';
|
||||
}
|
||||
Reference in New Issue
Block a user