From 87d6bdfbd9be9a34138a5642b6ba7957ad49b34f Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 04:20:33 +0300 Subject: [PATCH] feat(ai): redesign AI settings page with per-endpoint test buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../public/locales/en-US/translation.json | 33 +- .../settings/components/ai-chat-settings.tsx | 61 -- .../settings/components/ai-mcp-servers.tsx | 117 ++-- .../components/ai-provider-settings.tsx | 643 +++++++++++++----- .../settings/components/mcp-settings.tsx | 102 +-- .../workspace/queries/ai-settings-query.ts | 5 +- .../workspace/services/ai-settings-service.ts | 11 +- .../settings/workspace/workspace-settings.tsx | 27 +- .../integrations/ai/ai-settings.controller.ts | 4 +- apps/server/src/integrations/ai/ai.service.ts | 66 +- .../ai/dto/test-ai-connection.dto.ts | 9 + 11 files changed, 692 insertions(+), 386 deletions(-) delete mode 100644 apps/client/src/features/workspace/components/settings/components/ai-chat-settings.tsx create mode 100644 apps/server/src/integrations/ai/dto/test-ai-connection.dto.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index f99cfc2b..59a498e5 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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" } diff --git a/apps/client/src/features/workspace/components/settings/components/ai-chat-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-chat-settings.tsx deleted file mode 100644 index e09f169a..00000000 --- a/apps/client/src/features/workspace/components/settings/components/ai-chat-settings.tsx +++ /dev/null @@ -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( - 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 ( - - handleToggle(event.currentTarget.checked)} - /> - - ); -} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx index 86db3e4a..15db8c22 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { ActionIcon, Badge, + Box, Button, Group, Modal, @@ -75,71 +76,91 @@ export default function AiMcpServers() { } return ( - - + + {/* Header: status dot + title + "MCP client" badge + Add server */} + + + + {t("External tools")} + + {t("Gitmost as MCP client")} + + + + {t("Servers the agent calls out to.")} + {!isLoading && (!servers || servers.length === 0) && ( - + {t("No external servers configured")} )} - + {servers?.map((server) => ( - - - - - - {server.name} - - - {server.transport.toUpperCase()} - - - - {server.url} + + + + + {server.name} - - - - - updateMutation.mutate({ - id: server.id, - enabled: event.currentTarget.checked, - }) - } - /> - openEdit(server)} - > - - - confirmDelete(server)} - > - - + + {server.transport.toUpperCase()} + + + {server.url} + + + + + + updateMutation.mutate({ + id: server.id, + enabled: event.currentTarget.checked, + }) + } + /> + openEdit(server)} + > + + + confirmDelete(server)} + > + + - + ))} @@ -156,6 +177,6 @@ export default function AiMcpServers() { onClose={close} /> - + ); } diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index dc3a1fb7..827297ea 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -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; +// 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 ( + + ); +} + 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( + workspace?.settings?.ai?.chat ?? false, + ); + const [searchEnabled, setSearchEnabled] = useState( + 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({ validate: zod4Resolver(formSchema), initialValues: { - driver: "openai", chatModel: "", embeddingModel: "", baseUrl: "", @@ -75,11 +128,11 @@ export default function AiProviderSettings() { }, }); - // Hydrate the form once the masked settings load. + // Hydrate the form once the masked settings load. We ignore `settings.driver` + // entirely — the driver is always "openai". useEffect(() => { if (!settings) return; form.setValues({ - driver: settings.driver ?? "openai", chatModel: settings.chatModel ?? "", embeddingModel: settings.embeddingModel ?? "", baseUrl: settings.baseUrl ?? "", @@ -93,23 +146,19 @@ export default function AiProviderSettings() { setKeyCleared(false); setHasEmbeddingApiKey(settings.hasEmbeddingApiKey); setEmbeddingKeyCleared(false); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings]); - const driver = form.values.driver as AiDriver; - // Ollama runs locally and needs no API key. - const showApiKey = driver === "openai" || driver === "gemini"; - // OpenAI and Ollama accept a custom base URL; Gemini does not. - const showBaseUrl = driver === "openai" || driver === "ollama"; - function buildPayload(values: FormValues): IAiSettingsUpdate { const payload: IAiSettingsUpdate = { - driver: values.driver, + // Everything is OpenAI-compatible. + driver: "openai", chatModel: values.chatModel, embeddingModel: values.embeddingModel, - // Send the base URLs only for providers that use them. The embedding base - // URL is optional; empty falls back to the chat base URL server-side. - baseUrl: showBaseUrl ? values.baseUrl : "", - embeddingBaseUrl: showBaseUrl ? values.embeddingBaseUrl : "", + // The embedding base URL is optional; empty falls back to the chat base + // URL server-side. + baseUrl: values.baseUrl, + embeddingBaseUrl: values.embeddingBaseUrl, systemPrompt: values.systemPrompt, }; @@ -117,19 +166,17 @@ export default function AiProviderSettings() { // - typed a value -> set it // - explicitly cleared -> send '' to clear // - untouched -> omit the key entirely (leave unchanged) - if (showApiKey) { - if (values.apiKey.length > 0) { - payload.apiKey = values.apiKey; - } else if (keyCleared) { - payload.apiKey = ""; - } + if (values.apiKey.length > 0) { + payload.apiKey = values.apiKey; + } else if (keyCleared) { + payload.apiKey = ""; + } - // Same write-only semantics for the embedding-specific key. - if (values.embeddingApiKey.length > 0) { - payload.embeddingApiKey = values.embeddingApiKey; - } else if (embeddingKeyCleared) { - payload.embeddingApiKey = ""; - } + // Same write-only semantics for the embedding-specific key. + if (values.embeddingApiKey.length > 0) { + payload.embeddingApiKey = values.embeddingApiKey; + } else if (embeddingKeyCleared) { + payload.embeddingApiKey = ""; } return payload; @@ -159,176 +206,426 @@ export default function AiProviderSettings() { form.setFieldValue("embeddingApiKey", ""); } - const driverOptions = [ - { value: "openai", label: "OpenAI" }, - { value: "gemini", label: "Gemini" }, - { value: "ollama", label: "Ollama" }, - ]; + // Optimistic toggle for the "AI chat" feature (settings.ai.chat). + async function handleToggleChat(value: boolean) { + setChatToggleLoading(true); + const previous = chatEnabled; + setChatEnabled(value); + try { + const updated = await updateWorkspace({ aiChat: value }); + setWorkspace({ + ...updated, + settings: { + ...updated.settings, + ai: { ...updated.settings?.ai, chat: value }, + }, + }); + notifications.show({ message: t("Updated successfully") }); + } catch (err) { + setChatEnabled(previous); // revert on failure + // Surface the server-side error message (e.g. missing pgvector) instead of + // a generic fallback, mirroring useUpdateAiSettingsMutation. + const message = (err as { response?: { data?: { message?: string } } }) + ?.response?.data?.message; + notifications.show({ + message: message ?? t("Failed to update data"), + color: "red", + }); + } finally { + setChatToggleLoading(false); + } + } - const testResult = testMutation.data; + // Optimistic toggle for the "Semantic search" feature (settings.ai.search). + // Enabling can fail server-side when pgvector is missing — the error + // notification surfaces that and we revert. + async function handleToggleSearch(value: boolean) { + setSearchToggleLoading(true); + const previous = searchEnabled; + setSearchEnabled(value); + try { + const updated = await updateWorkspace({ aiSearch: value }); + setWorkspace({ + ...updated, + settings: { + ...updated.settings, + ai: { ...updated.settings?.ai, search: value }, + }, + }); + notifications.show({ message: t("Updated successfully") }); + } catch (err) { + setSearchEnabled(previous); // revert on failure + // Surface the server-side error message (e.g. missing pgvector) instead of + // a generic fallback, mirroring useUpdateAiSettingsMutation. + const message = (err as { response?: { data?: { message?: string } } }) + ?.response?.data?.message; + notifications.show({ + message: message ?? t("Failed to update data"), + color: "red", + }); + } finally { + setSearchToggleLoading(false); + } + } + + // Admins only — match the previous behavior. + if (!isAdmin) { + return ( + + {t("Only workspace admins can manage AI provider settings.")} + + ); + } + + 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 ( -