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