added a feature to enable/disable report agentic message repspnse

This commit is contained in:
k
2026-09-22 08:21:03 +02:00
parent eff63e0024
commit b5d17d8944
8 changed files with 71 additions and 13 deletions
+3
View File
@@ -71,6 +71,9 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
login page (wishlist). Only switchable on while public registration is off —
the two are mutually exclusive (API answers 409 otherwise).
- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool.
- `reports_enabled` (default on): admin kill-switch for message reports —
the chat flag button turns grey with a "Report disabled" tooltip and
`POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`.
- Flags live in the `settings` SQLite table, edited via
`GET/PATCH /api/admin/settings` (admin Bearer only).
+8 -3
View File
@@ -114,6 +114,7 @@ export function App() {
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
const [previewBusy, setPreviewBusy] = useState(false);
const [ocrAvailable, setOcrAvailable] = useState(false);
const [reportsEnabled, setReportsEnabled] = useState(true);
// Model-error retry: which trailing message is the error bubble (if any),
// its conversation, and how many times this prompt was already retried.
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
@@ -183,9 +184,11 @@ export function App() {
.tools()
.catch(() => ({
tools: [] as { name: string; available: boolean }[],
reportsEnabled: true,
})),
]);
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
setReportsEnabled(t.reportsEnabled ?? true);
setModels(m.models);
if (m.models.length > 0) setModel(m.models[0]!.id);
setConvs(c.conversations);
@@ -306,9 +309,10 @@ export function App() {
.catch(() => {});
api
.tools()
.then((t) =>
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)),
)
.then((t) => {
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
setReportsEnabled(t.reportsEnabled ?? true);
})
.catch(() => {});
// Land directly on the latest conversation's own URL.
refreshConvs().then((id) => {
@@ -681,6 +685,7 @@ export function App() {
attachments={attachments}
attachError={attachError}
ocrAvailable={ocrAvailable}
reportsEnabled={reportsEnabled}
onRemoveAttachment={(id) =>
setAttachments((prev) => prev.filter((a) => a.id !== id))
}
+9 -2
View File
@@ -24,7 +24,7 @@ export function AdminCenter() {
const { t } = useT();
const [allowed, setAllowed] = useState<boolean | null>(null);
const [tab, setTab] = useState<Tab>("accounts");
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } | null>(null);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
@@ -100,7 +100,7 @@ export function AdminCenter() {
});
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) {
setSaving(true);
setError("");
try {
@@ -244,6 +244,13 @@ export function AdminCenter() {
</div>
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
</div>
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("center.reportsTitle")}</p>
<p className="text-sm opacity-60">{t("center.reportsDesc")}</p>
</div>
<Switch label={t("center.reportsTitle")} checked={settings?.reportsEnabled ?? false} onChange={(v) => toggle({ reportsEnabled: v })} />
</div>
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
</section>
+9 -3
View File
@@ -11,7 +11,7 @@ import { useT, type StringKey } from "../lib/i18n.ts";
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reportsEnabled = true, isFailed, retryCount, maxRetries, retryDisabled, onRetry }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void; reportsEnabled?: boolean; isFailed?: boolean; retryCount?: number; maxRetries?: number; retryDisabled?: boolean; onRetry?: () => void }) {
const { t } = useT();
const isUser = msg.role === "user";
const [copied, setCopied] = useState(false);
@@ -53,7 +53,11 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, isFaile
<IconButton title={t("msg.search")} onClick={searchWeb}>
<Search size={14} />
</IconButton>
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
<IconButton
title={reportsEnabled ? t("msg.report") : t("msg.reportDisabled")}
onClick={() => onReport(index)}
disabled={!reportsEnabled}
>
<Flag size={14} />
</IconButton>
</>
@@ -194,6 +198,7 @@ export function Chat({
attachments,
attachError,
ocrAvailable,
reportsEnabled = true,
onRemoveAttachment,
onPickFile,
failedIndex,
@@ -220,6 +225,7 @@ export function Chat({
attachments: Attachment[];
attachError: string | null;
ocrAvailable: boolean;
reportsEnabled?: boolean;
onRemoveAttachment: (id: string) => void;
onPickFile: (kind: "ocr" | "text", file: File) => void;
/** Trailing model-error bubble index (retry arrow), if any. */
@@ -323,7 +329,7 @@ export function Chat({
</div>
)}
{messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} />
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} reportsEnabled={reportsEnabled} isFailed={failedIndex === i} retryCount={retryCount} maxRetries={maxRetries} retryDisabled={sending} onRetry={failedIndex === i && retryCount < maxRetries ? onRetry : undefined} />
))}
{streaming !== "" && (
<div className="flex gap-2 sm:gap-3">
+4 -4
View File
@@ -188,9 +188,9 @@ export const api = {
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings"),
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) =>
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings"),
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) =>
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
adminMailStatus: () =>
@@ -328,7 +328,7 @@ export const api = {
deleteProvider: (provider: string) =>
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"),
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean }>("/api/tools"),
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
+24
View File
@@ -339,6 +339,9 @@ const en = {
"center.ocrTitle": "OCR tool",
"center.ocrDesc":
"Server-side image transcription for attachments. Requires the tesseract binary.",
"center.reportsTitle": "Message reports",
"center.reportsDesc":
"Flag button under assistant responses. Off = the flag turns grey with a “Report disabled” tooltip and new reports are rejected.",
"center.mailTitle": "Key recovery emails",
"center.mailChecking": "Checking…",
"center.mailFrom": "Enabled — sent from {from}",
@@ -479,6 +482,7 @@ const en = {
"msg.copy": "Copy message",
"msg.search": "Search the web",
"msg.report": "Report response",
"msg.reportDisabled": "Report disabled",
"msg.retry": "Retry",
"report.title": "Report this response",
@@ -900,6 +904,9 @@ const es: Dict = {
"center.ocrTitle": "Herramienta OCR",
"center.ocrDesc":
"Transcripción de imágenes en el servidor. Requiere tesseract.",
"center.reportsTitle": "Denuncias de mensajes",
"center.reportsDesc":
"Botón de bandera bajo las respuestas. Off = bandera gris con aviso y se rechazan nuevas denuncias.",
"center.mailTitle": "Emails de recuperación",
"center.mailChecking": "Verificando…",
"center.mailFrom": "Activado — enviados desde {from}",
@@ -1039,6 +1046,7 @@ const es: Dict = {
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
"msg.report": "Denunciar respuesta",
"msg.reportDisabled": "Denuncias desactivadas",
"msg.retry": "Reintentar",
"report.title": "Denunciar esta respuesta",
"report.reason": "Motivo",
@@ -1455,6 +1463,9 @@ const fr: Dict = {
"Affiche Demander l'accès au login. Réservez un pseudo, plaidez votre cas ; tri dans l'onglet Accès.",
"center.ocrTitle": "Outil OCR",
"center.ocrDesc": "Transcription d'images côté serveur. Requiert tesseract.",
"center.reportsTitle": "Signalements de messages",
"center.reportsDesc":
"Bouton drapeau sous les réponses. Off = drapeau grisé avec infobulle et nouveaux signalements refusés.",
"center.mailTitle": "Emails de récupération",
"center.mailChecking": "Vérification…",
"center.mailFrom": "Activé — envoyés depuis {from}",
@@ -1594,6 +1605,7 @@ const fr: Dict = {
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
"msg.report": "Signaler la réponse",
"msg.reportDisabled": "Signalements désactivés",
"msg.retry": "Réessayer",
"report.title": "Signaler cette réponse",
"report.reason": "Motif",
@@ -2001,6 +2013,9 @@ const ru: Dict = {
"Кнопка доступа на входе. Гости просят имя; разбор во вкладке.",
"center.ocrTitle": "Инструмент OCR",
"center.ocrDesc": "Распознавание картинок на сервере. Нужен tesseract.",
"center.reportsTitle": "Жалобы на сообщения",
"center.reportsDesc":
"Кнопка-флажок под ответами. Выкл = серый флажок с подсказкой, новые жалобы отклоняются.",
"center.mailTitle": "Письма восстановления",
"center.mailChecking": "Проверяем…",
"center.mailFrom": "Включено — шлём от {from}",
@@ -2137,6 +2152,7 @@ const ru: Dict = {
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
"msg.report": "Пожаловаться на ответ",
"msg.reportDisabled": "Жалобы отключены",
"msg.retry": "Повторить",
"report.title": "Пожаловаться на этот ответ",
"report.reason": "Причина",
@@ -2547,6 +2563,9 @@ const it: Dict = {
"Mostra Richiedi accesso al login. Valuta nella scheda Accesso.",
"center.ocrTitle": "Strumento OCR",
"center.ocrDesc": "Trascrizione immagini sul server. Serve tesseract.",
"center.reportsTitle": "Segnalazioni messaggi",
"center.reportsDesc":
"Bandierina sotto le risposte. Off = bandierina grigia con tooltip e nuove segnalazioni rifiutate.",
"center.mailTitle": "Email di recupero",
"center.mailChecking": "Verifica…",
"center.mailFrom": "Attivo — inviate da {from}",
@@ -2685,6 +2704,7 @@ const it: Dict = {
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
"msg.report": "Segnala risposta",
"msg.reportDisabled": "Segnalazioni disabilitate",
"msg.retry": "Riprova",
"report.title": "Segnala questa risposta",
"report.reason": "Motivo",
@@ -3088,6 +3108,9 @@ const pt: Dict = {
"center.accDescOff": "Mostra Pedir acesso no login. Triagem na aba Acesso.",
"center.ocrTitle": "Ferramenta OCR",
"center.ocrDesc": "Transcrição no servidor. Precisa tesseract.",
"center.reportsTitle": "Denúncias de mensagens",
"center.reportsDesc":
"Bandeira sob as respostas. Off = bandeira cinzenta com aviso e novas denúncias recusadas.",
"center.mailTitle": "Emails de recuperação",
"center.mailChecking": "A verificar…",
"center.mailFrom": "Ativo — enviados de {from}",
@@ -3224,6 +3247,7 @@ const pt: Dict = {
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
"msg.report": "Denunciar resposta",
"msg.reportDisabled": "Denúncias desativadas",
"msg.retry": "Tentar novamente",
"report.title": "Denunciar esta resposta",
"report.reason": "Motivo",
+5
View File
@@ -389,6 +389,11 @@ export function isOcrToolEnabled(): boolean {
return getSetting("tools_ocr_enabled", "1") === "1";
}
/** Admin kill-switch for message reports / flag button (default on). */
export function isReportsEnabled(): boolean {
return getSetting("reports_enabled", "1") === "1";
}
// --- public shares ---
export function ensureShare(convId: string): string {
+9 -1
View File
@@ -33,6 +33,7 @@ import {
getUserById,
getUserByUsername,
isRegistrationEnabled,
isReportsEnabled,
listConversations,
listArchivedConversations,
listUsers,
@@ -831,6 +832,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
registrationEnabled: isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
reportsEnabled: isReportsEnabled(),
},
});
}
@@ -858,11 +860,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
if (typeof body.ocrEnabled !== "boolean") return json({ error: "ocrEnabled must be boolean" }, 400);
setSetting("tools_ocr_enabled", body.ocrEnabled ? "1" : "0");
}
if (body.reportsEnabled !== undefined) {
if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400);
setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
}
return json({
settings: {
registrationEnabled: isRegistrationEnabled(),
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
reportsEnabled: isReportsEnabled(),
},
});
}
@@ -1010,6 +1017,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- content reports (flagged AI responses) ---
if (path === "/api/reports" && req.method === "POST") {
if (!isReportsEnabled()) return json({ error: "reports are disabled" }, 403);
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const reason = body.reason as ReportReason | undefined;
@@ -1352,7 +1360,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
// --- platform tools (uploads always go through tools) ---
if (path === "/api/tools" && req.method === "GET") {
return json({ tools: tools.list() });
return json({ tools: tools.list(), reportsEnabled: isReportsEnabled() });
}
if (path === "/api/tools/ocr" && req.method === "POST") {