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:
vvzvlad
2026-06-18 04:20:33 +03:00
parent c292894c59
commit 87d6bdfbd9
11 changed files with 692 additions and 386 deletions

View File

@@ -1148,5 +1148,36 @@
"Resolved comment": "Resolved comment", "Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}", "Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent", "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"
} }

View File

@@ -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>
);
}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
Box,
Button, Button,
Group, Group,
Modal, Modal,
@@ -75,71 +76,91 @@ export default function AiMcpServers() {
} }
return ( return (
<Stack mt="sm"> <Paper withBorder radius="md" p="lg">
<Group justify="flex-start"> {/* 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 <Button
leftSection={<IconPlus size={16} />} leftSection={<IconPlus size={16} />}
variant="default" variant="default"
size="xs"
onClick={openCreate} onClick={openCreate}
> >
{t("Add server")} {t("Add server")}
</Button> </Button>
</Group> </Group>
<Text size="xs" c="dimmed" mt={4}>
{t("Servers the agent calls out to.")}
</Text>
{!isLoading && (!servers || servers.length === 0) && ( {!isLoading && (!servers || servers.length === 0) && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed" mt="sm">
{t("No external servers configured")} {t("No external servers configured")}
</Text> </Text>
)} )}
<Stack gap="xs"> <Stack gap="xs" mt="sm">
{servers?.map((server) => ( {servers?.map((server) => (
<Paper key={server.id} withBorder p="sm" radius="sm"> <Group key={server.id} justify="space-between" wrap="nowrap">
<Group justify="space-between" wrap="nowrap"> <Stack gap={2} style={{ minWidth: 0 }}>
<Stack gap={2} style={{ minWidth: 0 }}> <Group gap="xs">
<Group gap="xs"> <Text fw={500} truncate>
<Text fw={500} truncate> {server.name}
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
{server.url}
</Text> </Text>
</Stack> <Badge size="xs" variant="light">
{server.transport.toUpperCase()}
<Group gap="xs" wrap="nowrap"> </Badge>
<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> </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> </Group>
</Paper> </Group>
))} ))}
</Stack> </Stack>
@@ -156,6 +177,6 @@ export default function AiMcpServers() {
onClose={close} onClose={close}
/> />
</Modal> </Modal>
</Stack> </Paper>
); );
} }

View File

@@ -1,48 +1,85 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { import {
Alert, Anchor,
Badge,
Box,
Button, Button,
Group, Group,
Modal,
Paper,
PasswordInput, PasswordInput,
Select,
Stack, Stack,
Switch,
Text, Text,
Textarea, Textarea,
TextInput, TextInput,
useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { zod4Resolver } from "mantine-form-zod-resolver"; 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 { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx"; 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 { import {
useAiSettingsQuery, useAiSettingsQuery,
useReindexAiEmbeddingsMutation, useReindexAiEmbeddingsMutation,
useTestAiConnectionMutation, useTestAiConnectionMutation,
useUpdateAiSettingsMutation, useUpdateAiSettingsMutation,
} from "@/features/workspace/queries/ai-settings-query.ts"; } from "@/features/workspace/queries/ai-settings-query.ts";
import { import { IAiSettingsUpdate } from "@/features/workspace/services/ai-settings-service.ts";
AiDriver, import AiMcpServers from "./ai-mcp-servers.tsx";
IAiSettingsUpdate,
} from "@/features/workspace/services/ai-settings-service.ts";
// 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({ const formSchema = z.object({
driver: z.enum(["openai", "gemini", "ollama"]),
chatModel: z.string(), chatModel: z.string(),
embeddingModel: z.string(), embeddingModel: z.string(),
baseUrl: z.string(), baseUrl: z.string(),
// Embedding-specific base URL. Empty means "use the chat base URL". // Embedding-specific base URL. Empty means "use the chat base URL".
embeddingBaseUrl: z.string(), embeddingBaseUrl: z.string(),
systemPrompt: z.string(), systemPrompt: z.string(),
// Write-only key buffer. Empty string means "do not change" (unless explicitly cleared).
apiKey: z.string(), apiKey: z.string(),
// Write-only embedding key buffer. Same semantics as `apiKey`.
embeddingApiKey: z.string(), embeddingApiKey: z.string(),
}); });
type FormValues = z.infer<typeof formSchema>; 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() { export default function AiProviderSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@@ -50,9 +87,23 @@ export default function AiProviderSettings() {
// Only admins may read the (masked) AI settings; the server enforces this too. // Only admins may read the (masked) AI settings; the server enforces this too.
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin); const { data: settings, isLoading } = useAiSettingsQuery(isAdmin);
const updateMutation = useUpdateAiSettingsMutation(); const updateMutation = useUpdateAiSettingsMutation();
const testMutation = useTestAiConnectionMutation();
const reindexMutation = useReindexAiEmbeddingsMutation(); 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). // Whether a key is currently stored server-side (drives the placeholder).
const [hasApiKey, setHasApiKey] = useState(false); const [hasApiKey, setHasApiKey] = useState(false);
// Tracks whether the user explicitly cleared the stored key. // Tracks whether the user explicitly cleared the stored key.
@@ -61,10 +112,12 @@ export default function AiProviderSettings() {
const [hasEmbeddingApiKey, setHasEmbeddingApiKey] = useState(false); const [hasEmbeddingApiKey, setHasEmbeddingApiKey] = useState(false);
const [embeddingKeyCleared, setEmbeddingKeyCleared] = useState(false); const [embeddingKeyCleared, setEmbeddingKeyCleared] = useState(false);
// Modal for the (large) system message editor.
const [promptOpened, promptHandlers] = useDisclosure(false);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
driver: "openai",
chatModel: "", chatModel: "",
embeddingModel: "", embeddingModel: "",
baseUrl: "", 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(() => { useEffect(() => {
if (!settings) return; if (!settings) return;
form.setValues({ form.setValues({
driver: settings.driver ?? "openai",
chatModel: settings.chatModel ?? "", chatModel: settings.chatModel ?? "",
embeddingModel: settings.embeddingModel ?? "", embeddingModel: settings.embeddingModel ?? "",
baseUrl: settings.baseUrl ?? "", baseUrl: settings.baseUrl ?? "",
@@ -93,23 +146,19 @@ export default function AiProviderSettings() {
setKeyCleared(false); setKeyCleared(false);
setHasEmbeddingApiKey(settings.hasEmbeddingApiKey); setHasEmbeddingApiKey(settings.hasEmbeddingApiKey);
setEmbeddingKeyCleared(false); setEmbeddingKeyCleared(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings]); }, [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 { function buildPayload(values: FormValues): IAiSettingsUpdate {
const payload: IAiSettingsUpdate = { const payload: IAiSettingsUpdate = {
driver: values.driver, // Everything is OpenAI-compatible.
driver: "openai",
chatModel: values.chatModel, chatModel: values.chatModel,
embeddingModel: values.embeddingModel, embeddingModel: values.embeddingModel,
// Send the base URLs only for providers that use them. The embedding base // The embedding base URL is optional; empty falls back to the chat base
// URL is optional; empty falls back to the chat base URL server-side. // URL server-side.
baseUrl: showBaseUrl ? values.baseUrl : "", baseUrl: values.baseUrl,
embeddingBaseUrl: showBaseUrl ? values.embeddingBaseUrl : "", embeddingBaseUrl: values.embeddingBaseUrl,
systemPrompt: values.systemPrompt, systemPrompt: values.systemPrompt,
}; };
@@ -117,19 +166,17 @@ export default function AiProviderSettings() {
// - typed a value -> set it // - typed a value -> set it
// - explicitly cleared -> send '' to clear // - explicitly cleared -> send '' to clear
// - untouched -> omit the key entirely (leave unchanged) // - untouched -> omit the key entirely (leave unchanged)
if (showApiKey) { if (values.apiKey.length > 0) {
if (values.apiKey.length > 0) { payload.apiKey = values.apiKey;
payload.apiKey = values.apiKey; } else if (keyCleared) {
} else if (keyCleared) { payload.apiKey = "";
payload.apiKey = ""; }
}
// Same write-only semantics for the embedding-specific key. // Same write-only semantics for the embedding-specific key.
if (values.embeddingApiKey.length > 0) { if (values.embeddingApiKey.length > 0) {
payload.embeddingApiKey = values.embeddingApiKey; payload.embeddingApiKey = values.embeddingApiKey;
} else if (embeddingKeyCleared) { } else if (embeddingKeyCleared) {
payload.embeddingApiKey = ""; payload.embeddingApiKey = "";
}
} }
return payload; return payload;
@@ -159,176 +206,426 @@ export default function AiProviderSettings() {
form.setFieldValue("embeddingApiKey", ""); form.setFieldValue("embeddingApiKey", "");
} }
const driverOptions = [ // Optimistic toggle for the "AI chat" feature (settings.ai.chat).
{ value: "openai", label: "OpenAI" }, async function handleToggleChat(value: boolean) {
{ value: "gemini", label: "Gemini" }, setChatToggleLoading(true);
{ value: "ollama", label: "Ollama" }, 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 ( return (
<Stack mt="sm"> <Stack mt="sm">
<Select {/* Section header */}
label={t("Provider")} <Group justify="space-between" align="center">
data={driverOptions} <Text fw={700} size="lg">
allowDeselect={false} {t("Endpoints")}
disabled={!isAdmin || isLoading} </Text>
{...form.getInputProps("driver")} <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 && ( {/* Card 1 — Chat / LLM (root endpoint) */}
<PasswordInput <Paper withBorder radius="md" p="lg">
label={t("API key")} <Group justify="space-between" align="center" wrap="nowrap">
// Placeholder hints whether a key is already stored; the value is never shown. <Group gap="xs" align="center" wrap="nowrap">
placeholder={hasApiKey ? t("•••• set") : ""} <StatusDot status={chatStatus} />
readOnly={!isAdmin} <Text fw={600}>{t("Chat / LLM")}</Text>
autoComplete="off" <Badge size="sm" variant="light" color="gray">
{...form.getInputProps("apiKey")} {t("root")}
/> </Badge>
)} </Group>
<Switch
{showApiKey && isAdmin && hasApiKey && ( label={t("AI chat")}
<Group justify="flex-start" mt={-8}> labelPosition="left"
<Button checked={chatEnabled}
variant="subtle" disabled={chatToggleLoading}
size="compact-sm" onChange={(e) => handleToggleChat(e.currentTarget.checked)}
color="red" />
onClick={handleClearKey} </Group>
> <Text size="xs" c="dimmed" mt={4} mb="md">
{t("Clear key")} {t(
</Button> "/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> </Group>
)}
{showBaseUrl && (
<TextInput <TextInput
mt="sm"
label={t("Base URL")} label={t("Base URL")}
readOnly={!isAdmin} disabled={isLoading}
{...form.getInputProps("baseUrl")} {...form.getInputProps("baseUrl")}
/> />
)} <Text size="xs" c="dimmed" mt={4} style={{ fontFamily: monoFont }} truncate>
{t("Resolves to {{url}}", { url: chatResolved })}
</Text>
<TextInput <Group mt="md" align="center">
label={t("Chat model")} <Button
readOnly={!isAdmin} variant="default"
{...form.getInputProps("chatModel")} 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 {/* Footer: system message editor */}
label={t("Embedding model")} <Box
readOnly={!isAdmin} mt="md"
{...form.getInputProps("embeddingModel")} 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 <TextInput
label={t("Embedding base URL")} mt="sm"
label={t("Base URL")}
placeholder={t("Leave empty to use the chat base URL")} placeholder={t("Leave empty to use the chat base URL")}
readOnly={!isAdmin} disabled={isLoading}
{...form.getInputProps("embeddingBaseUrl")} {...form.getInputProps("embeddingBaseUrl")}
/> />
)} <Text size="xs" c="dimmed" mt={4} style={{ fontFamily: monoFont }} truncate>
{t("Resolves to {{url}}", { url: embedResolved })}
</Text>
{showApiKey && ( <Group mt="md" align="center">
<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}>
<Button <Button
variant="subtle" variant="default"
size="compact-sm" size="sm"
color="red" loading={embedTest.isPending}
onClick={handleClearEmbeddingKey} onClick={() => embedTest.mutate("embeddings")}
> >
{t("Clear key")} {t("Test endpoint")}
</Button> </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> </Group>
)}
{settings && ( {/* Footer: vector search / reindex */}
<Group justify="space-between" mt={-8}> <Box
<Text size="sm" c="dimmed"> mt="md"
{t("Indexed {{indexed}} of {{total}} pages", { mx="calc(var(--mantine-spacing-lg) * -1)"
indexed: settings.indexedPages ?? 0, mb="calc(var(--mantine-spacing-lg) * -1)"
total: settings.totalPages ?? 0, 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> </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 <Button
variant="subtle" variant="subtle"
size="compact-sm" size="compact-sm"
onClick={() => reindexMutation.mutate()}
loading={reindexMutation.isPending} loading={reindexMutation.isPending}
onClick={() => reindexMutation.mutate()}
> >
{t("Reindex now")} {t("Reindex now")}
</Button> </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> </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> </Stack>
); );
} }

View File

@@ -2,7 +2,16 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; 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 { notifications } from "@mantine/notifications";
import { IconCopy, IconCheck } from "@tabler/icons-react"; import { IconCopy, IconCheck } from "@tabler/icons-react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
@@ -52,47 +61,60 @@ export default function McpSettings() {
return ( return (
<Stack mt="sm"> <Stack mt="sm">
<Switch {/* Section header */}
label={t("Model Context Protocol (MCP)")} <Group justify="space-between" align="center">
description={t( <Text fw={700} size="lg">
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", {t("MCP server")}
)} </Text>
checked={checked} <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
disabled={!isAdmin || isLoading} {t("expose the workspace")}
onChange={(event) => handleToggle(event.currentTarget.checked)} </Text>
/> </Group>
{checked && ( <Paper withBorder radius="md" p="lg">
<TextInput <Switch
label={t("MCP Server URL")} label={t("Enable MCP server")}
value={mcpUrl} description={t(
readOnly "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
variant="filled" )}
rightSection={ checked={checked}
<CopyButton value={mcpUrl}> disabled={!isAdmin || isLoading}
{({ copied, copy }) => ( onChange={(event) => handleToggle(event.currentTarget.checked)}
<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>
}
/> />
)}
{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> </Stack>
); );
} }

View File

@@ -12,6 +12,7 @@ import {
IAiSettings, IAiSettings,
IAiSettingsUpdate, IAiSettingsUpdate,
IAiTestResult, IAiTestResult,
AiTestCapability,
} from "@/features/workspace/services/ai-settings-service.ts"; } from "@/features/workspace/services/ai-settings-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -49,8 +50,8 @@ export function useUpdateAiSettingsMutation() {
} }
export function useTestAiConnectionMutation() { export function useTestAiConnectionMutation() {
return useMutation<IAiTestResult, Error, void>({ return useMutation<IAiTestResult, Error, AiTestCapability>({
mutationFn: () => testAiConnection(), mutationFn: (capability) => testAiConnection(capability),
}); });
} }

View File

@@ -44,6 +44,9 @@ export interface IAiTestResult {
error?: string; error?: string;
} }
// Which endpoint a connection test probes.
export type AiTestCapability = "chat" | "embeddings";
export async function getAiSettings(): Promise<IAiSettings> { export async function getAiSettings(): Promise<IAiSettings> {
const req = await api.post<IAiSettings>("/workspace/ai-settings"); const req = await api.post<IAiSettings>("/workspace/ai-settings");
return req.data; return req.data;
@@ -56,8 +59,12 @@ export async function updateAiSettings(
return req.data; return req.data;
} }
export async function testAiConnection(): Promise<IAiTestResult> { export async function testAiConnection(
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test"); capability: AiTestCapability,
): Promise<IAiTestResult> {
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test", {
capability,
});
return req.data; return req.data;
} }

View File

@@ -3,8 +3,6 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.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 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 { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts"; import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
@@ -25,27 +23,12 @@ export default function WorkspaceSettings() {
<Divider my="lg" /> <Divider my="lg" />
<SettingsTitle title={t("AI & MCP")} /> <SettingsTitle title={t("AI")} />
{isAdmin && <AiProviderSettings />}
<Divider my="lg" />
<McpSettings /> <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 />
</>
)}
</> </>
); );
} }

View File

@@ -19,6 +19,7 @@ import {
import { AiService } from './ai.service'; import { AiService } from './ai.service';
import { AiSettingsService } from './ai-settings.service'; import { AiSettingsService } from './ai-settings.service';
import { UpdateAiSettingsDto } from './dto/update-ai-settings.dto'; 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 * 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) @HttpCode(HttpStatus.OK)
@Post('test') @Post('test')
async testConnection( async testConnection(
@Body() dto: TestAiConnectionDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
this.assertAdmin(user, workspace); this.assertAdmin(user, workspace);
return this.aiService.testConnection(workspace.id); return this.aiService.testConnection(workspace.id, dto.capability);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)

View File

@@ -161,59 +161,53 @@ export class AiService {
} }
/** /**
* Cheap connectivity check for the "Test connection" button. Probes the * Cheap connectivity check for a single "Test endpoint" button. Probes ONLY
* configured chat model (a one-word generation) AND the configured embeddings * the requested capability so each card in the UI surfaces its own result:
* model (embedding a tiny string) independently: * - `chat`: a one-word generation against the configured chat model;
* - a probe is skipped when that capability is not configured (its * - `embeddings`: embedding a tiny string against the embedding model.
* NotConfigured exception), so a chat-only or embeddings-only workspace *
* still tests fine; * A capability that is not configured returns a plain "… is not configured"
* - any real failure returns ok:false with the provider's own cause * message; any real failure returns ok:false with the provider's own cause
* (statusCode + truncated response body via describeProviderError), * (statusCode + truncated response body via describeProviderError). The
* prefixed Chat: / Embeddings: so the failing side is obvious; * decrypted key is never logged or returned — AI SDK error fields do not carry
* - if neither capability is configured, reports "not configured". * it, and the resolved config is never dumped.
* *
* Probing embeddings here catches a misconfigured embeddings endpoint (e.g. * Probing embeddings here catches a misconfigured embeddings endpoint (e.g.
* one returning non-JSON, which the background RAG indexer would otherwise hit * one returning non-JSON, which the background RAG indexer would otherwise hit
* as an opaque "Invalid JSON response") at config time instead of silently * as an opaque "Invalid JSON response") at config time instead of silently
* during indexing. The decrypted key is never logged or returned — AI SDK * during indexing.
* error fields do not carry it, and the resolved config is never dumped.
*/ */
async testConnection( async testConnection(
workspaceId: string, workspaceId: string,
capability: 'chat' | 'embeddings' = 'chat',
): Promise<{ ok: true } | { ok: false; error: string }> { ): 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 { try {
const model = await this.getChatModel(workspaceId); const model = await this.getChatModel(workspaceId);
// maxOutputTokens keeps the probe cheap and avoids providers (e.g. // maxOutputTokens keeps the probe cheap and avoids providers (e.g.
// OpenRouter) reserving/charging for the model's full max-token budget, // OpenRouter) reserving/charging for the model's full max-token budget,
// which would 402 on a key with limited credit. // which would 402 on a key with limited credit.
await generateText({ model, prompt: 'ping', maxOutputTokens: 16 }); await generateText({ model, prompt: 'ping', maxOutputTokens: 16 });
probed = true; return { ok: true };
} catch (err) { } catch (err) {
if (!(err instanceof AiNotConfiguredException)) { if (err instanceof AiNotConfiguredException) {
this.logger.error('AI chat test connection failed', err as Error); return { ok: false, error: 'Chat is not configured' };
return { ok: false, error: `Chat: ${describeProviderError(err)}` };
} }
// 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 };
} }
} }

View File

@@ -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';
}