diff --git a/README.md b/README.md index 34cbccd..da405c4 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,11 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model | | POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) | | GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | +| WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) | +| WS | `/api/admin/ws?token=…` | admin | firehose of all access events (badge + triage list) | | GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply | +| POST | `/api/reports` | Bearer | flag an AI response (copyright/gore/falseinfo/bug) → `{report}` (201) | +| GET/PATCH | `/api/admin/reports`, `/api/admin/reports/:id` | admin | report triage: list, reviewing/resolved/dismissed + note | | GET | `/api/admin/mail` | admin | SMTP credentials viewer (password blurred by default in UI) | | POST | `/api/admin/mail/verify`, `/api/admin/mail/test` | admin | verify SMTP connectivity / send test mail `{to}` | | POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index d6cf695..8578e7b 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -95,6 +95,28 @@ UUID, same pattern as share links). `GET /api/access/ticket/:id` + `POST .../message` (public, ticket bearer), `GET /api/admin/access`, `GET/PATCH /api/admin/access/:id`, `POST /api/admin/access/:id/message` (admin). +- Live updates ride WebSockets (no polling): `GET /api/access/ws/:id` + (ticket bearer — pushes `access_message` + `access_status` for that ticket) + and `GET /api/admin/ws?token=…` (admin Bearer in query string, browsers + can't set WS headers — pushes `access_message` + `access_status` + + `access_created` for every ticket, driving the AdminCenter badge and the + triage list). Clients ping every 25 s (`{"type":"ping"}` → `{"type":"pong"}`) + and resync over HTTP on every (re)connect. + +## Content reports (flagged AI responses) + +Every assistant message carries three actions: **Copy**, **Search the web** +(DuckDuckGo, new tab) and **Report**. Reporting opens a modal with a reason +picker (`copyright`, `gore`, `falseinfo`, `bug`) plus optional details, and +creates a row in the `reports` table with a content snapshot (survives later +edits/deletion of the chat), the model, and the reporter. + +- Reporter: capped at 20 open (`open` + `reviewing`) reports (429 beyond). +- Admin: **Reports** tab in the Admin Center (badge counts open reports) — + filter by status, expand for the full snapshot + reporter details, and set + `open → reviewing → resolved / dismissed` with an internal note. +- Routes: `POST /api/reports` (Bearer), `GET /api/admin/reports`, + `PATCH /api/admin/reports/:id` (admin). ## Key recovery via email (optional) diff --git a/frontend/src/components/AccessRequests.tsx b/frontend/src/components/AccessRequests.tsx index 275ceb9..b137abe 100644 --- a/frontend/src/components/AccessRequests.tsx +++ b/frontend/src/components/AccessRequests.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react"; -import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts"; +import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts"; +import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx"; import { useT, type StringKey } from "../lib/i18n.ts"; import { cn } from "../lib/cn.ts"; @@ -23,11 +24,8 @@ export function AccessRequestsPanel() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const { t } = useT(); - const listBusy = useRef(false); async function refresh(select?: string) { - if (listBusy.current) return; - listBusy.current = true; try { const res = await api.adminAccessList(); setRequests(res.requests); @@ -35,18 +33,25 @@ export function AccessRequestsPanel() { } catch (err) { setError(err instanceof Error ? err.message : t("admin.loadFailed")); } finally { - listBusy.current = false; setLoading(false); } } useEffect(() => { refresh(); - // Live list: new requests / replies pop without manual refresh. - const id = setInterval(() => { - if (!document.hidden) refresh(); - }, 15000); - return () => clearInterval(id); + // Live list: the admin firehose pushes every access event (shares its + // socket with the AdminCenter badge via the pool). Resync on + // (re)connect covers events missed while offline. + const token = getToken(); + if (!token) return; + return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), { + onEvent: (evt) => { + if (evt.type !== "pong") refresh(); + }, + onSync: () => { + refresh(); + }, + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -141,16 +146,12 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange setReason(request.reason); setFreshKey(null); known.current = { status: request.status, reason: request.reason }; - let stopped = false; - let busy = false; - // Live thread: requester replies appear without manual refresh, and a - // status change made elsewhere syncs the list (badges, filters). + // Live thread: requester replies arrive instantly, and a status change + // made elsewhere syncs the list (badges, filters). Resync on + // (re)connect covers events missed while offline. async function fetchOnce() { - if (busy || document.hidden) return; - busy = true; try { const res = await api.adminAccessGet(request.id); - if (stopped) return; setMessages(res.messages); if ( res.request.status !== known.current.status || @@ -160,17 +161,26 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange onChangedRef.current(); } } catch { - // Transient failure: keep last state, retry next tick. - } finally { - busy = false; + // Transient failure: keep last state. } } fetchOnce(); - const id = setInterval(fetchOnce, 5000); - return () => { - stopped = true; - clearInterval(id); - }; + return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), { + onEvent: (evt) => { + if (evt.type === "pong" || evt.request_id !== request.id) return; + if (evt.type === "access_message") { + const msg = evt.message; + setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg])); + onChangedRef.current(); + } else if (evt.type === "access_status") { + known.current = { status: evt.request.status, reason: evt.request.reason }; + onChangedRef.current(); + } + }, + onSync: () => { + fetchOnce(); + }, + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [request.id]); diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 9fbd584..2e0c56b 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -1,13 +1,15 @@ import { useEffect, useState } from "react"; -import { ArrowLeft, Eye, EyeOff, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; -import { api } from "../lib/api.ts"; +import { ArrowLeft, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; +import { api, getToken } from "../lib/api.ts"; +import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; import { AdminPanel } from "./AdminPanel.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx"; +import { ReportsPanel } from "./ReportsPanel.tsx"; import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; import { useT } from "../lib/i18n.ts"; -type Tab = "accounts" | "access" | "features" | "mail"; +type Tab = "accounts" | "access" | "reports" | "features" | "mail"; function applyTheme(theme: string) { const root = document.documentElement; @@ -24,10 +26,12 @@ export function AdminCenter() { const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [openAccessCount, setOpenAccessCount] = useState(0); + const [openReportCount, setOpenReportCount] = useState(0); const TABS: { id: Tab; label: string; icon: typeof Users }[] = [ { id: "accounts", label: t("center.tabAccounts"), icon: Users }, { id: "access", label: t("center.tabAccess"), icon: Inbox }, + { id: "reports", label: t("center.tabReports"), icon: Flag }, { id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal }, { id: "mail", label: t("center.tabMail"), icon: Mail }, ]; @@ -46,30 +50,40 @@ export function AdminCenter() { .catch(() => setAllowed(false)); }, []); - // Live badge on the Access tab: poll the open (pending + reviewing) - // request count so new demands pop without opening the tab. + // Live badges on the Access/Reports tabs: the admin firehose pushes every access + // event, so the open counts stay fresh with zero polling (reports re-fetch + // alongside). Resync on (re)connect covers events missed while offline. useEffect(() => { if (!allowed) return; - let stopped = false; async function fetchCount() { try { const res = await api.adminAccessList(); - if (stopped) return; setOpenAccessCount( res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length, ); } catch { - // Transient failure: keep last count, retry next tick. + // Transient failure: keep last count. + } + try { + const res = await api.adminReportList(); + setOpenReportCount( + res.reports.filter((r) => r.status === "open" || r.status === "reviewing").length, + ); + } catch { + // Transient failure: keep last count. } } fetchCount(); - const id = setInterval(() => { - if (!document.hidden) fetchCount(); - }, 15000); - return () => { - stopped = true; - clearInterval(id); - }; + const token = getToken(); + if (!token) return; + return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), { + onEvent: (evt) => { + if (evt.type !== "pong") fetchCount(); + }, + onSync: () => { + fetchCount(); + }, + }); }, [allowed]); async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) { @@ -122,7 +136,7 @@ export function AdminCenter() { key={t.id} onClick={() => setTab(t.id)} className={cn( - "relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition", + "flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition", tab === t.id ? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100" : "opacity-60 hover:opacity-100", @@ -139,6 +153,15 @@ export function AdminCenter() { {openAccessCount > 99 ? "99+" : openAccessCount} )} + {t.id === "reports" && openReportCount > 0 && ( + + {openReportCount > 99 ? "99+" : openReportCount} + + )} ))} @@ -160,6 +183,12 @@ export function AdminCenter() { )} + {tab === "reports" && ( +
+ +
+ )} + {tab === "features" && (
diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 780a3b2..2d6ca6f 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -1,15 +1,28 @@ import { useEffect, useRef, useState } from "react"; -import { Archive, ArchiveRestore, BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; +import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; -import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx"; +import { api, type ReportReason } from "../lib/api.ts"; +import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { Markdown } from "./Markdown.tsx"; import { cn } from "../lib/cn.ts"; import { useFeatures } from "../lib/features.ts"; -import { useT } from "../lib/i18n.ts"; +import { useT, type StringKey } from "../lib/i18n.ts"; -function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) { +const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"]; + +function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void }) { const { t } = useT(); const isUser = msg.role === "user"; + const [copied, setCopied] = useState(false); + function copy() { + navigator.clipboard.writeText(msg.content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }).catch(() => {}); + } + function searchWeb() { + window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer"); + } return (
{isUser @@ -29,11 +42,117 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
)} + {!isUser && onReport && ( +
+ + {copied ? : } + + + + + onReport(index)}> + + +
+ )}
); } +function ReportDialog({ open, onClose, conversationId, messageIndex, content, model }: { + open: boolean; + onClose: () => void; + conversationId: string | null; + messageIndex: number; + content: string; + model: string; +}) { + const { t } = useT(); + const [reason, setReason] = useState(""); + const [details, setDetails] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [sent, setSent] = useState(false); + + useEffect(() => { + if (open) { + setReason(""); + setDetails(""); + setError(""); + setSent(false); + } + }, [open, messageIndex]); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!reason || busy) return; + setBusy(true); + setError(""); + try { + await api.createReport({ + conversationId: conversationId ?? undefined, + messageIndex, + reason: reason as ReportReason, + details: details.trim(), + content, + model, + }); + setSent(true); + } catch (err) { + setError(err instanceof Error ? err.message : t("report.failed")); + } finally { + setBusy(false); + } + } + + return ( + + {sent ? ( +
+

+ {t("report.sent")} +

+
+ +
+
+ ) : ( +
+ + ({ value: r, label: t(`report.reason.${r}` as StringKey) }))} + placeholder={t("report.reasonPh")} + ariaLabel={t("report.reason")} + align="left" + /> + + +