feat(ai-chat): agent write tools, provenance wiring, chat panel + provider settings UI" -m "Backend:
- Add reversible write tools to the per-user agent toolset (page create/update/ move/soft-delete; comment reply + resolve), exposed under the user's JWT and enforced by Docmost CASL; no permanent/force delete (D3). - Non-spoofable agent provenance: sign actor/aiChatId into the access and collab tokens (TokenService), propagate via jwt.strategy onto the request, and set pages.last_updated_source/last_updated_ai_chat_id on REST create/update/move and comments.created_source/resolved_source/ai_chat_id. - packages/mcp: add an optional getCollabToken provider (content-edit provenance) and guard against empty tokens; service-account /mcp path unchanged. Frontend: - Admin 'AI / Models' settings section: provider/model/embedding/base URL, a write-only API key field, system prompt, and Test connection. - AI chat panel (useChat + DefaultChatTransport): conversation list, streamed messages, tool-call action log and page citations; header entry point gated on settings.ai.chat. Compile-verified (server nest build + client tsc/vite); not yet live-tested. Known gaps: history 'AI agent' badge (C3), vector RAG (D), external MCP (E); chat tool-card citation links pending a fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import {
|
||||
useAiSettingsQuery,
|
||||
useTestAiConnectionMutation,
|
||||
useUpdateAiSettingsMutation,
|
||||
} from "@/features/workspace/queries/ai-settings-query.ts";
|
||||
import {
|
||||
AiDriver,
|
||||
IAiSettingsUpdate,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
driver: z.enum(["openai", "gemini", "ollama"]),
|
||||
chatModel: z.string(),
|
||||
embeddingModel: z.string(),
|
||||
baseUrl: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
// Write-only key buffer. Empty string means "do not change" (unless explicitly cleared).
|
||||
apiKey: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export default function AiProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
const [keyCleared, setKeyCleared] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
driver: "openai",
|
||||
chatModel: "",
|
||||
embeddingModel: "",
|
||||
baseUrl: "",
|
||||
systemPrompt: "",
|
||||
apiKey: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Hydrate the form once the masked settings load.
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
form.setValues({
|
||||
driver: settings.driver ?? "openai",
|
||||
chatModel: settings.chatModel ?? "",
|
||||
embeddingModel: settings.embeddingModel ?? "",
|
||||
baseUrl: settings.baseUrl ?? "",
|
||||
systemPrompt: settings.systemPrompt ?? "",
|
||||
apiKey: "",
|
||||
});
|
||||
form.resetDirty();
|
||||
setHasApiKey(settings.hasApiKey);
|
||||
setKeyCleared(false);
|
||||
}, [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,
|
||||
chatModel: values.chatModel,
|
||||
embeddingModel: values.embeddingModel,
|
||||
// Send the base URL only for providers that use it.
|
||||
baseUrl: showBaseUrl ? values.baseUrl : "",
|
||||
systemPrompt: values.systemPrompt,
|
||||
};
|
||||
|
||||
// Key semantics (never send the stored key back):
|
||||
// - typed a value -> set it
|
||||
// - explicitly cleared -> send '' to clear
|
||||
// - untouched -> omit `apiKey` entirely (leave unchanged)
|
||||
if (showApiKey) {
|
||||
if (values.apiKey.length > 0) {
|
||||
payload.apiKey = values.apiKey;
|
||||
} else if (keyCleared) {
|
||||
payload.apiKey = "";
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const updated = await updateMutation.mutateAsync(buildPayload(values));
|
||||
// Reflect the new key state and reset the write-only buffer.
|
||||
setHasApiKey(updated.hasApiKey);
|
||||
setKeyCleared(false);
|
||||
form.setFieldValue("apiKey", "");
|
||||
form.resetDirty();
|
||||
}
|
||||
|
||||
function handleClearKey() {
|
||||
setKeyCleared(true);
|
||||
setHasApiKey(false);
|
||||
form.setFieldValue("apiKey", "");
|
||||
}
|
||||
|
||||
const driverOptions = [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "ollama", label: "Ollama" },
|
||||
];
|
||||
|
||||
const testResult = testMutation.data;
|
||||
|
||||
return (
|
||||
<Stack mt="sm">
|
||||
<Select
|
||||
label={t("Provider")}
|
||||
data={driverOptions}
|
||||
allowDeselect={false}
|
||||
disabled={!isAdmin || isLoading}
|
||||
{...form.getInputProps("driver")}
|
||||
/>
|
||||
|
||||
{showApiKey && (
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
// Placeholder hints whether a key is already stored; the value is never shown.
|
||||
placeholder={hasApiKey ? t("•••• set") : ""}
|
||||
readOnly={!isAdmin}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showApiKey && isAdmin && hasApiKey && (
|
||||
<Group justify="flex-start" mt={-8}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
onClick={handleClearKey}
|
||||
>
|
||||
{t("Clear key")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{showBaseUrl && (
|
||||
<TextInput
|
||||
label={t("Base URL")}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("baseUrl")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label={t("Chat model")}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("chatModel")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Embedding model")}
|
||||
readOnly={!isAdmin}
|
||||
{...form.getInputProps("embeddingModel")}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getAiSettings,
|
||||
updateAiSettings,
|
||||
testAiConnection,
|
||||
IAiSettings,
|
||||
IAiSettingsUpdate,
|
||||
IAiTestResult,
|
||||
} from "@/features/workspace/services/ai-settings-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const aiSettingsKey = ["ai-settings"];
|
||||
|
||||
export function useAiSettingsQuery(
|
||||
enabled: boolean = true,
|
||||
): UseQueryResult<IAiSettings, Error> {
|
||||
return useQuery({
|
||||
queryKey: aiSettingsKey,
|
||||
queryFn: () => getAiSettings(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAiSettingsMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IAiSettings, Error, IAiSettingsUpdate>({
|
||||
mutationFn: (data) => updateAiSettings(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
queryClient.invalidateQueries({ queryKey: aiSettingsKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestAiConnectionMutation() {
|
||||
return useMutation<IAiTestResult, Error, void>({
|
||||
mutationFn: () => testAiConnection(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
// Supported LLM providers/drivers.
|
||||
export type AiDriver = "openai" | "gemini" | "ollama";
|
||||
|
||||
// Masked AI provider settings returned by the server.
|
||||
// The API key is NEVER returned; only `hasApiKey` indicates whether one is stored.
|
||||
export interface IAiSettings {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
systemPrompt?: string;
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
// Update payload. Key semantics:
|
||||
// - omit `apiKey` -> key unchanged
|
||||
// - `apiKey: ''` -> clear the stored key
|
||||
// - `apiKey: 'non-empty'`-> set the key
|
||||
// Non-secret fields are saved as given.
|
||||
export interface IAiSettingsUpdate {
|
||||
driver?: AiDriver;
|
||||
chatModel?: string;
|
||||
embeddingModel?: string;
|
||||
baseUrl?: string;
|
||||
systemPrompt?: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
// Result of a connection test against the configured provider.
|
||||
// The error string is already sanitized server-side.
|
||||
export interface IAiTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getAiSettings(): Promise<IAiSettings> {
|
||||
const req = await api.post<IAiSettings>("/workspace/ai-settings");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateAiSettings(
|
||||
data: IAiSettingsUpdate,
|
||||
): Promise<IAiSettings> {
|
||||
const req = await api.post<IAiSettings>("/workspace/ai-settings/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function testAiConnection(): Promise<IAiTestResult> {
|
||||
const req = await api.post<IAiTestResult>("/workspace/ai-settings/test");
|
||||
return req.data;
|
||||
}
|
||||
Reference in New Issue
Block a user