Add AI review workflow for Paperless documents

This commit is contained in:
2026-05-07 20:04:30 +02:00
parent 210b77876d
commit f913bc0ba6
24 changed files with 2169 additions and 15 deletions

View File

@@ -0,0 +1,328 @@
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import LaunchIcon from "@mui/icons-material/Launch";
import ReplayIcon from "@mui/icons-material/Replay";
import {
Alert,
Box,
Button,
Chip,
FormControlLabel,
Grid,
Paper,
Stack,
Switch,
TextField,
Typography
} from "@mui/material";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { approveAiReview, fetchAiReview, rejectAiReview, retryAiReview } from "../api/aiReviews";
import { fetchServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { useSnackbar } from "../hooks/useSnackbar";
import { ContractPayload } from "../types";
type FormState = {
title: string;
provider: string;
category: string;
contractStartDate: string;
contractEndDate: string;
terminationNoticeDays: string;
renewalPeriodMonths: string;
autoRenew: boolean;
price: string;
currency: string;
notes: string;
tags: string;
};
const emptyForm: FormState = {
title: "",
provider: "",
category: "",
contractStartDate: "",
contractEndDate: "",
terminationNoticeDays: "",
renewalPeriodMonths: "",
autoRenew: false,
price: "",
currency: "EUR",
notes: "",
tags: ""
};
function toStringValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
return String(value);
}
function parseNumber(value: string, integer = false): number | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number(trimmed.replace(",", "."));
if (!Number.isFinite(parsed)) {
throw new Error("Invalid number");
}
return integer ? Math.round(parsed) : parsed;
}
export default function AiReviewDetail() {
const { reviewId } = useParams<{ reviewId: string }>();
const id = reviewId ? Number(reviewId) : null;
const navigate = useNavigate();
const { t } = useTranslation();
const { showMessage } = useSnackbar();
const queryClient = useQueryClient();
const [form, setForm] = useState<FormState>(emptyForm);
const {
data: review,
isLoading,
isError
} = useQuery({
queryKey: ["ai-review", id],
queryFn: () => fetchAiReview(id ?? 0),
enabled: id !== null
});
const { data: serverConfig } = useQuery({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
useEffect(() => {
if (!review?.contractPayload) {
return;
}
const payload = review.contractPayload;
setForm({
title: payload.title ?? "",
provider: payload.provider ?? "",
category: payload.category ?? "",
contractStartDate: payload.contractStartDate ?? "",
contractEndDate: payload.contractEndDate ?? "",
terminationNoticeDays: toStringValue(payload.terminationNoticeDays),
renewalPeriodMonths: toStringValue(payload.renewalPeriodMonths),
autoRenew: payload.autoRenew ?? false,
price: toStringValue(payload.price),
currency: payload.currency ?? "EUR",
notes: payload.notes ?? "",
tags: payload.tags?.join(", ") ?? ""
});
}, [review]);
const paperlessUrl = useMemo(() => {
if (!review || !serverConfig) {
return null;
}
const base = serverConfig.paperlessExternalUrl ?? serverConfig.paperlessBaseUrl;
return base ? `${base.replace(/\/$/, "")}/documents/${review.paperlessDocumentId}` : null;
}, [review, serverConfig]);
const refreshQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["ai-reviews"] }),
queryClient.invalidateQueries({ queryKey: ["ai-review", id] }),
queryClient.invalidateQueries({ queryKey: ["contracts"] })
]);
};
const retryMutation = useMutation({
mutationFn: () => retryAiReview(id ?? 0),
onSuccess: async () => {
showMessage(t("aiReviews.retryStarted"), "success");
await refreshQueries();
},
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
});
const rejectMutation = useMutation({
mutationFn: () => rejectAiReview(id ?? 0),
onSuccess: async () => {
showMessage(t("aiReviews.rejected"), "success");
await refreshQueries();
navigate("/ai-reviews");
},
onError: (error: Error) => showMessage(error.message ?? t("aiReviews.actionFailed"), "error")
});
const approveMutation = useMutation({
mutationFn: () => {
const contract: ContractPayload = {
title: form.title.trim(),
provider: form.provider.trim() || null,
category: form.category.trim() || null,
paperlessDocumentId: review?.paperlessDocumentId ?? null,
contractStartDate: form.contractStartDate || null,
contractEndDate: form.contractEndDate || null,
terminationNoticeDays: parseNumber(form.terminationNoticeDays, true),
renewalPeriodMonths: parseNumber(form.renewalPeriodMonths, true),
autoRenew: form.autoRenew,
price: parseNumber(form.price),
currency: form.currency.trim() || "EUR",
notes: form.notes.trim() || null,
tags: form.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
};
return approveAiReview(id ?? 0, contract);
},
onSuccess: async (result) => {
showMessage(t("aiReviews.approved"), "success");
await refreshQueries();
navigate(`/contracts/${result.contract.id}`);
},
onError: (error: Error) => {
const message = error.message === "Invalid number" ? t("contractForm.invalidNumber") : error.message;
showMessage(message ?? t("aiReviews.actionFailed"), "error");
}
});
const updateField = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
if (isLoading) {
return <Typography>{t("messages.loading")}</Typography>;
}
if (isError || !review) {
return <Alert severity="error">{t("aiReviews.loadError")}</Alert>;
}
const canApprove = review.status !== "approved" && review.status !== "rejected" && Boolean(form.title.trim());
return (
<>
<PageHeader
title={review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
subtitle={t("aiReviews.detailSubtitle", { id: review.paperlessDocumentId })}
action={
paperlessUrl ? (
<Button href={paperlessUrl} target="_blank" rel="noreferrer" startIcon={<LaunchIcon />} variant="outlined">
{t("contractDetail.openInPaperless")}
</Button>
) : undefined
}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={5}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Stack spacing={2}>
<Box display="flex" alignItems="center" gap={1}>
<Chip label={t(`aiReviews.status.${review.status}`)} />
{review.confidence !== null && (
<Chip label={`${Math.round(review.confidence * 100)}%`} color="primary" variant="outlined" />
)}
</Box>
{review.error && <Alert severity="error">{review.error}</Alert>}
{review.analysis ? (
<>
<Typography variant="h6">{t("aiReviews.summary")}</Typography>
<Typography whiteSpace="pre-wrap">{review.analysis.summary}</Typography>
<Typography variant="body2" color="text.secondary">
{review.analysis.isContract ? t("aiReviews.detectedContract") : t("aiReviews.detectedNoContract")}
</Typography>
</>
) : (
<Alert severity="info">{t("aiReviews.noAnalysis")}</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<Button
startIcon={<ReplayIcon />}
variant="outlined"
onClick={() => retryMutation.mutate()}
disabled={retryMutation.isPending || review.status === "approved"}
>
{t("aiReviews.retry")}
</Button>
<Button
startIcon={<CloseIcon />}
color="warning"
variant="outlined"
onClick={() => rejectMutation.mutate()}
disabled={rejectMutation.isPending || review.status === "approved" || review.status === "rejected"}
>
{t("aiReviews.reject")}
</Button>
</Stack>
</Stack>
</Paper>
</Grid>
<Grid item xs={12} md={7}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Stack spacing={2}>
<Typography variant="h6">{t("aiReviews.contractDraft")}</Typography>
{!review.contractPayload && (
<Alert severity="warning">{t("aiReviews.noContractDraft")}</Alert>
)}
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.title")} value={form.title} onChange={(event) => updateField("title", event.target.value)} fullWidth required />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.provider")} value={form.provider} onChange={(event) => updateField("provider", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.category")} value={form.category} onChange={(event) => updateField("category", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField label={t("contractForm.fields.currency")} value={form.currency} onChange={(event) => updateField("currency", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField type="date" label={t("contractForm.fields.contractStart")} value={form.contractStartDate} onChange={(event) => updateField("contractStartDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
</Grid>
<Grid item xs={12} md={6}>
<TextField type="date" label={t("contractForm.fields.contractEnd")} value={form.contractEndDate} onChange={(event) => updateField("contractEndDate", event.target.value)} InputLabelProps={{ shrink: true }} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.terminationNotice")} value={form.terminationNoticeDays} onChange={(event) => updateField("terminationNoticeDays", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.renewalPeriod")} value={form.renewalPeriodMonths} onChange={(event) => updateField("renewalPeriodMonths", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12} md={4}>
<TextField label={t("contractForm.fields.price")} value={form.price} onChange={(event) => updateField("price", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={<Switch checked={form.autoRenew} onChange={(event) => updateField("autoRenew", event.target.checked)} />}
label={t("contractForm.fields.autoRenew")}
/>
</Grid>
<Grid item xs={12}>
<TextField label={t("contractForm.fields.tags")} value={form.tags} onChange={(event) => updateField("tags", event.target.value)} fullWidth />
</Grid>
<Grid item xs={12}>
<TextField label={t("contractForm.fields.notes")} value={form.notes} onChange={(event) => updateField("notes", event.target.value)} fullWidth multiline minRows={4} />
</Grid>
</Grid>
<Box display="flex" justifyContent="flex-end">
<Button
startIcon={<CheckIcon />}
variant="contained"
onClick={() => approveMutation.mutate()}
disabled={!canApprove || approveMutation.isPending}
>
{t("aiReviews.approve")}
</Button>
</Box>
</Stack>
</Paper>
</Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,154 @@
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Alert,
Box,
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { fetchAiReviews } from "../api/aiReviews";
import { fetchServerConfig } from "../api/config";
import PageHeader from "../components/PageHeader";
import { formatDate } from "../utils/date";
function statusColor(status: string): "default" | "primary" | "success" | "warning" | "error" {
if (status === "approved") return "success";
if (status === "needs_review") return "primary";
if (status === "failed") return "error";
if (status === "analyzing" || status === "pending") return "warning";
return "default";
}
export default function AiReviewList() {
const { t } = useTranslation();
const navigate = useNavigate();
const {
data: reviews,
isLoading,
isError,
refetch,
isFetching
} = useQuery({
queryKey: ["ai-reviews"],
queryFn: () => fetchAiReviews()
});
const { data: serverConfig } = useQuery({
queryKey: ["server-config"],
queryFn: fetchServerConfig
});
return (
<>
<PageHeader
title={t("aiReviews.title")}
subtitle={t("aiReviews.subtitle")}
action={
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => refetch()}
disabled={isFetching}
>
{t("aiReviews.refresh")}
</Button>
}
/>
{!serverConfig?.aiConfigured && (
<Alert severity="warning" sx={{ mb: 2 }}>
{t("aiReviews.aiNotConfigured")}
</Alert>
)}
{!serverConfig?.paperlessWebhookConfigured && (
<Alert severity="info" sx={{ mb: 2 }}>
{t("aiReviews.webhookNotConfigured")}
</Alert>
)}
<Paper variant="outlined" sx={{ borderRadius: 3, p: 2.5 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("aiReviews.columns.document")}</TableCell>
<TableCell>{t("aiReviews.columns.status")}</TableCell>
<TableCell>{t("aiReviews.columns.confidence")}</TableCell>
<TableCell>{t("aiReviews.columns.updated")}</TableCell>
<TableCell align="right">{t("aiReviews.columns.action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={5}>{t("messages.loading")}</TableCell>
</TableRow>
)}
{isError && (
<TableRow>
<TableCell colSpan={5}>
<Typography color="error">{t("aiReviews.loadError")}</Typography>
</TableCell>
</TableRow>
)}
{!isLoading && !isError && (reviews ?? []).length === 0 && (
<TableRow>
<TableCell colSpan={5}>
<Box display="flex" alignItems="center" gap={1}>
<AutoAwesomeIcon color="disabled" />
<Typography color="text.secondary">{t("aiReviews.empty")}</Typography>
</Box>
</TableCell>
</TableRow>
)}
{(reviews ?? []).map((review) => (
<TableRow
hover
key={review.id}
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/ai-reviews/${review.id}`)}
>
<TableCell>
<Typography fontWeight={600}>
{review.documentTitle ?? `${t("aiReviews.document")} #${review.paperlessDocumentId}`}
</Typography>
<Typography variant="caption" color="text.secondary">
Paperless #{review.paperlessDocumentId}
</Typography>
</TableCell>
<TableCell>
<Chip
size="small"
color={statusColor(review.status)}
label={t(`aiReviews.status.${review.status}`)}
/>
</TableCell>
<TableCell>
{review.confidence !== null ? `${Math.round(review.confidence * 100)}%` : "-"}
</TableCell>
<TableCell>{formatDate(review.updatedAt, "dd.MM.yyyy HH:mm")}</TableCell>
<TableCell align="right">
<Button size="small" onClick={(event) => {
event.stopPropagation();
navigate(`/ai-reviews/${review.id}`);
}}>
{t("aiReviews.review")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Paper>
</>
);
}

View File

@@ -35,6 +35,7 @@ import {
fetchServerConfig,
fetchSettings,
resetIcalSecret,
triggerAiTest,
triggerMailTest,
triggerNtfyTest,
ServerConfig,
@@ -75,6 +76,15 @@ type FormValues = {
ntfyPriority: string;
authUsername: string;
authPassword: string;
aiEnabled: boolean;
aiProvider: "openai" | "openai-compatible" | "gemini" | "";
aiBaseUrl: string;
aiModel: string;
aiApiKey: string;
aiSystemPrompt: string;
aiTimeoutSeconds: number;
aiMaxTokens: number;
paperlessWebhookSecret: string;
};
const defaultValues: FormValues = {
@@ -97,7 +107,16 @@ const defaultValues: FormValues = {
ntfyToken: "",
ntfyPriority: "default",
authUsername: "",
authPassword: ""
authPassword: "",
aiEnabled: false,
aiProvider: "",
aiBaseUrl: "",
aiModel: "",
aiApiKey: "",
aiSystemPrompt: "",
aiTimeoutSeconds: 60,
aiMaxTokens: 2000,
paperlessWebhookSecret: ""
};
export default function SettingsPage() {
@@ -146,6 +165,8 @@ export default function SettingsPage() {
const [removeMailPassword, setRemoveMailPassword] = useState(false);
const [removeNtfyToken, setRemoveNtfyToken] = useState(false);
const [removeAuthPassword, setRemoveAuthPassword] = useState(false);
const [removeAiApiKey, setRemoveAiApiKey] = useState(false);
const [removeWebhookSecret, setRemoveWebhookSecret] = useState(false);
const {
control,
@@ -182,7 +203,16 @@ export default function SettingsPage() {
ntfyToken: "",
ntfyPriority: settingsData.values.ntfyPriority ?? "default",
authUsername: settingsData.values.authUsername ?? "",
authPassword: ""
authPassword: "",
aiEnabled: settingsData.values.aiEnabled ?? false,
aiProvider: settingsData.values.aiProvider ?? "",
aiBaseUrl: settingsData.values.aiBaseUrl ?? "",
aiModel: settingsData.values.aiModel ?? "",
aiApiKey: "",
aiSystemPrompt: settingsData.values.aiSystemPrompt ?? "",
aiTimeoutSeconds: settingsData.values.aiTimeoutSeconds ?? 60,
aiMaxTokens: settingsData.values.aiMaxTokens ?? 2000,
paperlessWebhookSecret: ""
};
initialValuesRef.current = values;
@@ -190,6 +220,8 @@ export default function SettingsPage() {
setRemoveMailPassword(false);
setRemoveNtfyToken(false);
setRemoveAuthPassword(false);
setRemoveAiApiKey(false);
setRemoveWebhookSecret(false);
reset(values);
}, [settingsData, reset]);
@@ -225,6 +257,12 @@ export default function SettingsPage() {
onError: (error: Error) => showMessage(error.message ?? t("settings.ntfyTestError"), "error")
});
const aiTestMutation = useMutation({
mutationFn: () => triggerAiTest(),
onSuccess: () => showMessage(t("settings.aiTestSuccess"), "success"),
onError: (error: Error) => showMessage(error.message ?? t("settings.aiTestError"), "error")
});
const createCategoryMutation = useMutation({
mutationFn: (name: string) => apiCreateCategory(name),
onSuccess: async (category) => {
@@ -386,6 +424,38 @@ export default function SettingsPage() {
payload.authPassword = formValues.authPassword.trim();
}
if (formValues.aiEnabled !== initial.aiEnabled) {
payload.aiEnabled = formValues.aiEnabled;
}
if (formValues.aiProvider !== initial.aiProvider) {
payload.aiProvider = formValues.aiProvider ? formValues.aiProvider : null;
}
if (formValues.aiBaseUrl !== initial.aiBaseUrl) {
payload.aiBaseUrl = trimOrNull(formValues.aiBaseUrl);
}
if (formValues.aiModel !== initial.aiModel) {
payload.aiModel = trimOrNull(formValues.aiModel);
}
if (removeAiApiKey) {
payload.aiApiKey = null;
} else if (formValues.aiApiKey.trim().length > 0) {
payload.aiApiKey = formValues.aiApiKey.trim();
}
if (formValues.aiSystemPrompt !== initial.aiSystemPrompt) {
payload.aiSystemPrompt = trimOrNull(formValues.aiSystemPrompt);
}
if (formValues.aiTimeoutSeconds !== initial.aiTimeoutSeconds) {
payload.aiTimeoutSeconds = formValues.aiTimeoutSeconds;
}
if (formValues.aiMaxTokens !== initial.aiMaxTokens) {
payload.aiMaxTokens = formValues.aiMaxTokens;
}
if (removeWebhookSecret) {
payload.paperlessWebhookSecret = null;
} else if (formValues.paperlessWebhookSecret.trim().length > 0) {
payload.paperlessWebhookSecret = formValues.paperlessWebhookSecret.trim();
}
if (Object.keys(payload).length === 0) {
showMessage(t("settings.noChanges"), "info");
return;
@@ -425,6 +495,8 @@ export default function SettingsPage() {
const mailPasswordSet = settings?.secrets.mailPasswordSet ?? false;
const ntfyTokenSet = settings?.secrets.ntfyTokenSet ?? false;
const authPasswordSet = settings?.secrets.authPasswordSet ?? false;
const aiApiKeySet = settings?.secrets.aiApiKeySet ?? false;
const paperlessWebhookSecretSet = settings?.secrets.paperlessWebhookSecretSet ?? false;
return (
<>
@@ -675,6 +747,135 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{t("settings.ai")}
</Typography>
{loading ? (
<CircularProgress size={24} />
) : (
<Stack spacing={2}>
<FormControlLabel
control={
<Controller
name="aiEnabled"
control={control}
render={({ field }) => <Switch {...field} checked={field.value} />}
/>
}
label={t("settings.aiEnabled")}
/>
<TextField select label={t("settings.aiProvider")} {...register("aiProvider")} fullWidth>
<MenuItem value="">{t("settings.aiProviderNone")}</MenuItem>
<MenuItem value="openai">OpenAI</MenuItem>
<MenuItem value="openai-compatible">OpenAI compatible</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
</TextField>
<TextField
label={t("settings.aiBaseUrl")}
{...register("aiBaseUrl")}
placeholder={t("settings.aiBaseUrlPlaceholder")}
helperText={t("settings.aiBaseUrlHelp")}
fullWidth
/>
<TextField label={t("settings.aiModel")} {...register("aiModel")} fullWidth />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={aiApiKeySet && !removeAiApiKey ? t("settings.aiApiKeyNew") : t("settings.aiApiKey")}
type="password"
{...register("aiApiKey")}
fullWidth
/>
<Button
variant="outlined"
color={removeAiApiKey ? "inherit" : "warning"}
onClick={() => {
if (removeAiApiKey) {
setRemoveAiApiKey(false);
} else {
setRemoveAiApiKey(true);
setValue("aiApiKey", "", { shouldDirty: true });
}
}}
>
{removeAiApiKey ? t("settings.paperlessTokenKeep") : t("settings.aiApiKeyRemove")}
</Button>
</Stack>
{aiApiKeySet && !removeAiApiKey && (
<Typography variant="caption" color="text.secondary">
{t("settings.aiApiKeyInfo")}
</Typography>
)}
<TextField
label={t("settings.aiSystemPrompt")}
{...register("aiSystemPrompt")}
fullWidth
multiline
minRows={3}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label={t("settings.aiTimeout")}
type="number"
inputProps={{ min: 5, max: 300 }}
{...register("aiTimeoutSeconds", { valueAsNumber: true })}
fullWidth
/>
<TextField
label={t("settings.aiMaxTokens")}
type="number"
inputProps={{ min: 256, max: 16000 }}
{...register("aiMaxTokens", { valueAsNumber: true })}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<TextField
label={paperlessWebhookSecretSet && !removeWebhookSecret ? t("settings.webhookSecretNew") : t("settings.webhookSecret")}
type="password"
{...register("paperlessWebhookSecret")}
fullWidth
helperText={t("settings.webhookSecretHelp")}
/>
<Button
variant="outlined"
color={removeWebhookSecret ? "inherit" : "warning"}
onClick={() => {
if (removeWebhookSecret) {
setRemoveWebhookSecret(false);
} else {
setRemoveWebhookSecret(true);
setValue("paperlessWebhookSecret", "", { shouldDirty: true });
}
}}
>
{removeWebhookSecret ? t("settings.paperlessTokenKeep") : t("settings.webhookSecretRemove")}
</Button>
</Stack>
{paperlessWebhookSecretSet && !removeWebhookSecret && (
<Typography variant="caption" color="text.secondary">
{t("settings.webhookSecretInfo")}
</Typography>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button
variant="outlined"
onClick={() => aiTestMutation.mutate()}
disabled={aiTestMutation.isPending || !serverConfig?.aiConfigured}
>
{t("settings.aiTest")}
</Button>
{aiTestMutation.isPending && <CircularProgress size={24} />}
</Stack>
{aiTestMutation.isError && (
<Alert severity="error">{(aiTestMutation.error as Error).message ?? t("settings.aiTestError")}</Alert>
)}
</Stack>
)}
</CardContent>
</Card>
<Card variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>