diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 1341950..2466cca 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34d8c05..9736a16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -114,6 +114,7 @@ export function App() { const [filePreview, setFilePreview] = useState(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)) } diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 5748fb3..4b163fd 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -24,7 +24,7 @@ export function AdminCenter() { const { t } = useT(); const [allowed, setAllowed] = useState(null); const [tab, setTab] = useState("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() { toggle({ ocrEnabled: v })} /> +
+
+

{t("center.reportsTitle")}

+

{t("center.reportsDesc")}

+
+ toggle({ reportsEnabled: v })} /> +
{saving &&

{t("common.saving")}

} {!settings && !error &&

{t("common.loading")}

} diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 24696de..b5b626f 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -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 - onReport(index)}> + onReport(index)} + disabled={!reportsEnabled} + > @@ -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({ )} {messages.filter((m) => m.role !== "system").map((m, i) => ( - + ))} {streaming !== "" && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a0eda7f..8bd8f7d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 }> => { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 7a754c1..633ad42 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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", diff --git a/src/db.ts b/src/db.ts index 2165920..c276a71 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index 7d1b0cb..c81cd10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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") {