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",
"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"
}

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 {
ActionIcon,
Badge,
Box,
Button,
Group,
Modal,
@@ -75,27 +76,43 @@ 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">
<Group key={server.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
@@ -105,7 +122,12 @@ export default function AiMcpServers() {
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
@@ -139,7 +161,6 @@ export default function AiMcpServers() {
</ActionIcon>
</Group>
</Group>
</Paper>
))}
</Stack>
@@ -156,6 +177,6 @@ export default function AiMcpServers() {
onClose={close}
/>
</Modal>
</Stack>
</Paper>
);
}

View File

@@ -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,7 +166,6 @@ 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) {
@@ -130,7 +178,6 @@ export default function AiProviderSettings() {
} 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 && (
{/* 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 hints whether a key is already stored; the value is never shown.
placeholder={hasApiKey ? t("•••• set") : ""}
readOnly={!isAdmin}
autoComplete="off"
{...form.getInputProps("apiKey")}
/>
{hasApiKey && (
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
{t("Clear")}
</Anchor>
)}
{showApiKey && isAdmin && hasApiKey && (
<Group justify="flex-start" mt={-8}>
<Button
variant="subtle"
size="compact-sm"
color="red"
onClick={handleClearKey}
>
{t("Clear key")}
</Button>
</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>
{/* 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("Embedding model")}
readOnly={!isAdmin}
label={t("Model")}
disabled={isLoading}
{...form.getInputProps("embeddingModel")}
/>
{showBaseUrl && (
<TextInput
label={t("Embedding base URL")}
placeholder={t("Leave empty to use the chat base URL")}
readOnly={!isAdmin}
{...form.getInputProps("embeddingBaseUrl")}
/>
)}
{showApiKey && (
<Stack gap={4}>
<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
variant="subtle"
size="compact-sm"
color="red"
{hasEmbeddingApiKey && (
<Anchor
component="button"
type="button"
c="red"
size="xs"
onClick={handleClearEmbeddingKey}
>
{t("Clear key")}
</Button>
</Group>
{t("Clear")}
</Anchor>
)}
</Stack>
</Group>
{settings && (
<Group justify="space-between" mt={-8}>
<TextInput
mt="sm"
label={t("Base URL")}
placeholder={t("Leave empty to use the chat base URL")}
disabled={isLoading}
{...form.getInputProps("embeddingBaseUrl")}
/>
<Text size="xs" c="dimmed" mt={4} style={{ fontFamily: monoFont }} truncate>
{t("Resolves to {{url}}", { url: embedResolved })}
</Text>
<Group mt="md" align="center">
<Button
variant="default"
size="sm"
loading={embedTest.isPending}
onClick={() => embedTest.mutate("embeddings")}
>
{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>
{/* 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>
<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,
indexed: settings?.indexedPages ?? 0,
total: settings?.totalPages ?? 0,
})}
</Text>
{isAdmin && (
<Button
variant="subtle"
size="compact-sm"
onClick={() => reindexMutation.mutate()}
loading={reindexMutation.isPending}
onClick={() => reindexMutation.mutate()}
>
{t("Reindex now")}
</Button>
)}
</Group>
)}
</Box>
</Paper>
<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")}
{/* 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
/>
{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>
</Group>
<Text size="xs" c="dimmed" mt={4} mb="md">
{t(
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)",
)}
</Text>
{isAdmin && (
<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={() => handleSubmit(form.values)}
onClick={() => void handleSubmit(form.values).catch(() => {})}
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")}
{t("Save endpoints")}
</Button>
</Group>
)}
{!isAdmin && (
<Text size="sm" c="dimmed">
{t("Only workspace admins can manage AI provider settings.")}
</Text>
)}
{/* 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>
);
}

View File

@@ -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,10 +61,21 @@ export default function McpSettings() {
return (
<Stack mt="sm">
{/* 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>
<Paper withBorder radius="md" p="lg">
<Switch
label={t("Model Context Protocol (MCP)")}
label={t("Enable MCP server")}
description={t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
)}
checked={checked}
disabled={!isAdmin || isLoading}
@@ -64,6 +84,7 @@ export default function McpSettings() {
{checked && (
<TextInput
mt="md"
label={t("MCP Server URL")}
value={mcpUrl}
readOnly
@@ -93,6 +114,7 @@ export default function McpSettings() {
}
/>
)}
</Paper>
</Stack>
);
}

View File

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

View File

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

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

View File

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

View File

@@ -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;
} catch (err) {
if (!(err instanceof AiNotConfiguredException)) {
this.logger.error('AI chat test connection failed', err as Error);
return { ok: false, error: `Chat: ${describeProviderError(err)}` };
}
// Chat not configured: skip — embeddings may still be configured.
}
// 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 };
} catch (err) {
if (err instanceof AiNotConfiguredException) {
return { ok: false, error: 'Chat is not configured' };
}
this.logger.error('AI chat test connection failed', err as Error);
return { ok: false, error: describeProviderError(err) };
}
}
}

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