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:
vvzvlad
2026-06-17 02:39:26 +03:00
parent 683da7a4c5
commit 44b340dc1a
38 changed files with 2384 additions and 21 deletions

View File

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

View File

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

View File

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