report system

This commit is contained in:
k
2026-09-21 21:51:38 +02:00
parent 4cefd3e17f
commit aa33828215
15 changed files with 1379 additions and 75 deletions
+4
View File
@@ -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 | | 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`) | | 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 | | 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 | | 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) | | 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 | `/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 | | POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account |
+22
View File
@@ -95,6 +95,28 @@ UUID, same pattern as share links).
`GET /api/access/ticket/:id` + `POST .../message` (public, ticket bearer), `GET /api/access/ticket/:id` + `POST .../message` (public, ticket bearer),
`GET /api/admin/access`, `GET/PATCH /api/admin/access/:id`, `GET /api/admin/access`, `GET/PATCH /api/admin/access/:id`,
`POST /api/admin/access/:id/message` (admin). `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) ## Key recovery via email (optional)
+35 -25
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-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 { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts"; import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
@@ -23,11 +24,8 @@ export function AccessRequestsPanel() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const { t } = useT(); const { t } = useT();
const listBusy = useRef(false);
async function refresh(select?: string) { async function refresh(select?: string) {
if (listBusy.current) return;
listBusy.current = true;
try { try {
const res = await api.adminAccessList(); const res = await api.adminAccessList();
setRequests(res.requests); setRequests(res.requests);
@@ -35,18 +33,25 @@ export function AccessRequestsPanel() {
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed")); setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally { } finally {
listBusy.current = false;
setLoading(false); setLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
refresh(); refresh();
// Live list: new requests / replies pop without manual refresh. // Live list: the admin firehose pushes every access event (shares its
const id = setInterval(() => { // socket with the AdminCenter badge via the pool). Resync on
if (!document.hidden) refresh(); // (re)connect covers events missed while offline.
}, 15000); const token = getToken();
return () => clearInterval(id); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -141,16 +146,12 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
setReason(request.reason); setReason(request.reason);
setFreshKey(null); setFreshKey(null);
known.current = { status: request.status, reason: request.reason }; known.current = { status: request.status, reason: request.reason };
let stopped = false; // Live thread: requester replies arrive instantly, and a status change
let busy = false; // made elsewhere syncs the list (badges, filters). Resync on
// Live thread: requester replies appear without manual refresh, and a // (re)connect covers events missed while offline.
// status change made elsewhere syncs the list (badges, filters).
async function fetchOnce() { async function fetchOnce() {
if (busy || document.hidden) return;
busy = true;
try { try {
const res = await api.adminAccessGet(request.id); const res = await api.adminAccessGet(request.id);
if (stopped) return;
setMessages(res.messages); setMessages(res.messages);
if ( if (
res.request.status !== known.current.status || res.request.status !== known.current.status ||
@@ -160,17 +161,26 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
onChangedRef.current(); onChangedRef.current();
} }
} catch { } catch {
// Transient failure: keep last state, retry next tick. // Transient failure: keep last state.
} finally {
busy = false;
} }
} }
fetchOnce(); fetchOnce();
const id = setInterval(fetchOnce, 5000); return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
return () => { onEvent: (evt) => {
stopped = true; if (evt.type === "pong" || evt.request_id !== request.id) return;
clearInterval(id); 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [request.id]); }, [request.id]);
+45 -16
View File
@@ -1,13 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowLeft, Eye, EyeOff, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; import { ArrowLeft, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api } from "../lib/api.ts"; import { api, getToken } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { AdminPanel } from "./AdminPanel.tsx"; import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts"; import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "features" | "mail"; type Tab = "accounts" | "access" | "reports" | "features" | "mail";
function applyTheme(theme: string) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
@@ -24,10 +26,12 @@ export function AdminCenter() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0); const [openAccessCount, setOpenAccessCount] = useState(0);
const [openReportCount, setOpenReportCount] = useState(0);
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [ const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users }, { id: "accounts", label: t("center.tabAccounts"), icon: Users },
{ id: "access", label: t("center.tabAccess"), icon: Inbox }, { 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: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail }, { id: "mail", label: t("center.tabMail"), icon: Mail },
]; ];
@@ -46,30 +50,40 @@ export function AdminCenter() {
.catch(() => setAllowed(false)); .catch(() => setAllowed(false));
}, []); }, []);
// Live badge on the Access tab: poll the open (pending + reviewing) // Live badges on the Access/Reports tabs: the admin firehose pushes every access
// request count so new demands pop without opening the tab. // event, so the open counts stay fresh with zero polling (reports re-fetch
// alongside). Resync on (re)connect covers events missed while offline.
useEffect(() => { useEffect(() => {
if (!allowed) return; if (!allowed) return;
let stopped = false;
async function fetchCount() { async function fetchCount() {
try { try {
const res = await api.adminAccessList(); const res = await api.adminAccessList();
if (stopped) return;
setOpenAccessCount( setOpenAccessCount(
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length, res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
); );
} catch { } 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(); fetchCount();
const id = setInterval(() => { const token = getToken();
if (!document.hidden) fetchCount(); if (!token) return;
}, 15000); return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
return () => { onEvent: (evt) => {
stopped = true; if (evt.type !== "pong") fetchCount();
clearInterval(id); },
}; onSync: () => {
fetchCount();
},
});
}, [allowed]); }, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) { async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
@@ -122,7 +136,7 @@ export function AdminCenter() {
key={t.id} key={t.id}
onClick={() => setTab(t.id)} onClick={() => setTab(t.id)}
className={cn( 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 tab === t.id
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100" ? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
: "opacity-60 hover:opacity-100", : "opacity-60 hover:opacity-100",
@@ -139,6 +153,15 @@ export function AdminCenter() {
{openAccessCount > 99 ? "99+" : openAccessCount} {openAccessCount > 99 ? "99+" : openAccessCount}
</span> </span>
)} )}
{t.id === "reports" && openReportCount > 0 && (
<span
role="status"
aria-label={`${t.label}: ${openReportCount}`}
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
>
{openReportCount > 99 ? "99+" : openReportCount}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -160,6 +183,12 @@ export function AdminCenter() {
</section> </section>
)} )}
{tab === "reports" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<ReportsPanel />
</section>
)}
{tab === "features" && ( {tab === "features" && (
<section className="space-y-3"> <section className="space-y-3">
<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="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">
+139 -5
View File
@@ -1,15 +1,28 @@
import { useEffect, useRef, useState } from "react"; 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 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 { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.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 { t } = useT();
const isUser = msg.role === "user"; 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 ( return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}> <div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
{isUser {isUser
@@ -29,11 +42,117 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
<Markdown text={msg.content} /> <Markdown text={msg.content} />
</div> </div>
)} )}
{!isUser && onReport && (
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
</IconButton>
<IconButton title={t("msg.search")} onClick={searchWeb}>
<Search size={14} />
</IconButton>
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
<Flag size={14} />
</IconButton>
</div>
)}
</div> </div>
</div> </div>
); );
} }
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 (
<Modal open={open} onClose={onClose} title={t("report.title")} icon={Flag}>
{sent ? (
<div className="space-y-4">
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
<Check size={16} /> {t("report.sent")}
</p>
<div className="flex justify-end">
<Button variant="secondary" onClick={onClose}>{t("common.close")}</Button>
</div>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<Field label={t("report.reason")}>
<Picker
value={reason}
onChange={setReason}
options={REPORT_REASONS.map((r) => ({ value: r, label: t(`report.reason.${r}` as StringKey) }))}
placeholder={t("report.reasonPh")}
ariaLabel={t("report.reason")}
align="left"
/>
</Field>
<Field label={t("report.details")}>
<textarea
rows={3}
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder={t("report.detailsPh")}
aria-label={t("report.details")}
maxLength={2000}
className="max-h-40 w-full resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" disabled={!reason || busy}>
{busy ? <Spinner size={14} /> : <Flag size={14} />} {t("report.submit")}
</Button>
</div>
</form>
)}
</Modal>
);
}
export function Chat({ export function Chat({
user, user,
conv, conv,
@@ -80,6 +199,7 @@ export function Chat({
}) { }) {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false);
const [reportIndex, setReportIndex] = useState<number | null>(null);
const { t } = useT(); const { t } = useT();
const features = useFeatures(); const features = useFeatures();
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
@@ -169,7 +289,7 @@ export function Chat({
</div> </div>
)} )}
{messages.filter((m) => m.role !== "system").map((m, i) => ( {messages.filter((m) => m.role !== "system").map((m, i) => (
<MessageBubble key={i} msg={m} authorAvatar={user.avatarUrl} authorName={user.displayName} /> <MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} />
))} ))}
{streaming !== "" && ( {streaming !== "" && (
<div className="flex gap-3"> <div className="flex gap-3">
@@ -306,6 +426,20 @@ export function Chat({
</a> </a>
</p> </p>
</div> </div>
{(() => {
const shown = messages.filter((m) => m.role !== "system");
const target = reportIndex !== null ? shown[reportIndex] : undefined;
return (
<ReportDialog
open={reportIndex !== null}
onClose={() => setReportIndex(null)}
conversationId={conv?.id ?? null}
messageIndex={reportIndex ?? 0}
content={target?.content ?? ""}
model={conv?.model || model}
/>
);
})()}
</div> </div>
); );
} }
@@ -0,0 +1,60 @@
import { useState } from "react";
import { ChevronDown, FileText } from "lucide-react";
import { cn } from "../lib/cn.ts";
/**
* User message content with attached files folded away.
*
* Text/OCR attachments travel inlined in the message as:
* [attached text file: foo.txt]
* ```text
* <whole file content, up to 500KB>
* ```
* Rendering that raw blows the bubble off-screen, so each block renders as
* a collapsed attachment row (header + expand chevron, no i18n needed) with
* the body capped to a scrollable pane when expanded.
*/
const ATTACH_RE = /^(\[.*?\])\n```text\n([\s\S]*?)\n```/gm;
function AttachmentBlock({ header, body }: { header: string; body: string }) {
const [open, setOpen] = useState(false);
const label = header.startsWith("[") && header.endsWith("]") ? header.slice(1, -1) : header;
return (
<span className="mt-2 block overflow-hidden rounded-2xl bg-black/25 text-left dark:bg-black/30">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] font-medium"
>
<FileText size={14} className="shrink-0 opacity-80" />
<span className="min-w-0 flex-1 truncate">{label}</span>
<ChevronDown
size={14}
className={cn("shrink-0 opacity-80 transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-t border-white/10 px-3 py-2 font-mono text-[12.5px] leading-relaxed">
{body}
</pre>
)}
</span>
);
}
export function UserMessageContent({ content }: { content: string }) {
const parts: React.ReactNode[] = [];
let last = 0;
let key = 0;
ATTACH_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ATTACH_RE.exec(content)) !== null) {
if (m.index > last) parts.push(<span key={key++}>{content.slice(last, m.index)}</span>);
parts.push(<AttachmentBlock key={key++} header={m[1]!} body={m[2] ?? ""} />);
last = m.index + m[0].length;
}
if (last < content.length) parts.push(<span key={key++}>{content.slice(last)}</span>);
if (parts.length === 0) return <>{content}</>;
return <>{parts}</>;
}
+204
View File
@@ -0,0 +1,204 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
import { api, type Report, type ReportStatus } from "../lib/api.ts";
import { Button, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<ReportStatus, { cls: string }> = {
open: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
resolved: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
dismissed: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
};
type Filter = "all" | ReportStatus;
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
export function ReportsPanel() {
const [reports, setReports] = useState<Report[]>([]);
const [filter, setFilter] = useState<Filter>("open");
const [selectedId, setSelectedId] = useState<string | null>(null);
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.adminReportList();
setReports(res.reports);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
listBusy.current = false;
setLoading(false);
}
}
useEffect(() => {
refresh();
// Live list: new reports pop without manual refresh.
const id = setInterval(() => {
if (!document.hidden) refresh();
}, 15000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visible = reports.filter((r) => filter === "all" || r.status === filter);
const openCount = reports.filter((r) => r.status === "open").length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{FILTERS.map((f) => {
const n = f === "all" ? reports.length : reports.filter((r) => r.status === f).length;
return (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"rounded-full px-3.5 py-1.5 text-xs font-medium capitalize transition",
filter === f
? "bg-stone-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
)}
>
{t(`reports.filter.${f}` as StringKey)} · {n}
</button>
);
})}
<button
onClick={() => refresh()}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("reports.refresh")}
aria-label={t("reports.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!loading && visible.length === 0 && (
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
<Inbox size={16} />
{openCount > 0 ? t("reports.emptyFiltered") : t("reports.empty")}
</p>
)}
<ul className="space-y-2">
{visible.map((r) => (
<li key={r.id}>
<button
onClick={() => setSelectedId((cur) => (cur === r.id ? null : r.id))}
className={cn(
"flex w-full items-center gap-3 rounded-3xl border p-4 text-left transition",
selectedId === r.id
? "border-accent-500/50 bg-accent-500/5"
: "border-stone-200/70 bg-white hover:border-stone-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
)}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-stone-200/70 dark:bg-zinc-800">
<Flag size={15} className="opacity-70" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{t(`report.reason.${r.reason}` as StringKey)}
<span className="font-normal opacity-50"> · @{r.reporter_name} · {new Date(r.created_at).toLocaleString()}</span>
</span>
<span className="block truncate text-xs opacity-60">{r.content || t(`report.reason.${r.reason}` as StringKey)}</span>
</span>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
{t(`reports.status.${r.status}` as StringKey)}
</span>
</button>
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
</li>
))}
</ul>
</div>
);
}
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
const [note, setNote] = useState(report.admin_note);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { t } = useT();
const closed = report.status === "resolved" || report.status === "dismissed";
useEffect(() => {
setNote(report.admin_note);
}, [report.id, report.admin_note]);
async function setStatus(status: ReportStatus) {
setBusy(true);
setError("");
try {
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wider opacity-50">{t("reports.response")}</p>
<div className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 text-sm dark:border-zinc-800 dark:bg-zinc-900">
{report.content || <span className="opacity-50">{t("reports.noContent")}</span>}
</div>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs opacity-70">
<span>{t("reports.model")}: <span className="font-medium opacity-100">{report.model || "—"}</span></span>
<span>{t("reports.reporter")}: <span className="font-medium opacity-100">@{report.reporter_name}</span></span>
{report.conversation_id && (
<span>{t("reports.conversation")}: <code className="font-mono">{report.conversation_id.slice(0, 8)}…</code></span>
)}
{report.details && (
<span className="w-full">{t("reports.detailsGiven")}: <span className="opacity-100">{report.details}</span></span>
)}
</div>
{!closed ? (
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="min-w-48 flex-1">
<Field label={t("reports.noteLabel")}>
<Input value={note} onChange={(e) => setNote(e.target.value)} placeholder={t("reports.notePh")} maxLength={1000} />
</Field>
</div>
<div className="flex gap-2">
{report.status === "open" && (
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
<Search size={14} /> {t("reports.btnReviewing")}
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => setStatus("resolved")}>
<CheckCircle2 size={14} /> {t("reports.btnResolve")}
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("dismissed")} className="!text-red-600 dark:!text-red-400">
<XCircle size={14} /> {t("reports.btnDismiss")}
</Button>
</div>
</div>
) : (
<p className="flex items-center gap-2 text-xs opacity-60">
<Clock size={13} /> {t("reports.closedNote", { status: t(`reports.status.${report.status}` as StringKey).toLowerCase() })}
{report.admin_note && <> {t("reports.noteWord")}: <em>{report.admin_note}</em></>}
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+30 -25
View File
@@ -9,6 +9,7 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts"; import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { import {
Button, Button,
CopyButton, CopyButton,
@@ -82,32 +83,36 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
} }
} }
useEffect(() => { /** Silent resync after a (re)connect — never flips to the error state. */
let stopped = false; async function refreshSilent() {
load(); try {
// Live updates: short-poll the ticket so admin replies and the const res = await api.accessTicket(ticketId);
// decision show up without a manual refresh. No WS infra on the setRequest(res.request);
// backend — traffic here is tiny, polling is the clean fit. setMessages(res.messages);
let busy = false; } catch {
async function poll() { // Transient failure: keep last known state.
if (stopped || busy || document.hidden) return;
busy = true;
try {
const res = await api.accessTicket(ticketId);
if (stopped) return;
setRequest(res.request);
setMessages(res.messages);
} catch {
// Transient failure: keep last known state, retry next tick.
} finally {
busy = false;
}
} }
const id = setInterval(poll, 5000); }
return () => {
stopped = true; useEffect(() => {
clearInterval(id); load();
}; // Live updates over WebSocket: admin replies and the decision arrive
// instantly; resync on (re)connect covers events missed while offline.
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== ticketId) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
} else if (evt.type === "access_status") {
setRequest(evt.request);
}
},
onSync: () => {
refreshSilent();
},
});
return close;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]); }, [ticketId]);
+142
View File
@@ -0,0 +1,142 @@
import type { AccessMessage, AccessRequest } from "./api.ts";
/**
* Shared WebSocket pool for live access-request updates.
*
* One underlying socket per URL (refcounted across components, e.g. the
* AdminCenter badge and the triage list share the admin firehose).
* Auto-reconnects with backoff; every (re)connect triggers onSync so
* components resync over HTTP and never miss an event sent while offline.
* A 25s client ping keeps idle connections alive through proxies.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest }
| { type: "pong" };
export interface AccessLiveListener {
onEvent: (e: AccessLiveEvent) => void;
/** Fired on every (re)connect — resync state over HTTP here. */
onSync?: () => void;
}
interface Pool {
ws: WebSocket | null;
listeners: Set<AccessLiveListener>;
backoff: number;
retryTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
}
const pools = new Map<string, Pool>();
export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
function connect(url: string, pool: Pool): void {
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch {
scheduleRetry(url, pool);
return;
}
pool.ws = ws;
ws.onopen = () => {
pool.backoff = 0;
pool.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
// send failed — the close handler will schedule a retry
}
}
}, 25_000);
for (const l of [...pool.listeners]) {
try {
l.onSync?.();
} catch {
// listener bug must not break the pool
}
}
};
ws.onmessage = (ev) => {
let parsed: AccessLiveEvent | null = null;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as AccessLiveEvent;
} catch {
return;
}
if (!parsed || typeof parsed.type !== "string") return;
for (const l of [...pool.listeners]) {
try {
l.onEvent(parsed);
} catch {
// listener bug must not break the pool
}
}
};
const onDead = () => {
if (pool.ws === ws) pool.ws = null;
if (pool.pingTimer) {
clearInterval(pool.pingTimer);
pool.pingTimer = null;
}
scheduleRetry(url, pool);
};
ws.onclose = onDead;
ws.onerror = () => {
// onerror is followed by onclose — retry is scheduled there
};
}
function scheduleRetry(url: string, pool: Pool): void {
if (!pools.has(url) || pool.listeners.size === 0 || pool.retryTimer) return;
const delay = Math.min(1000 * 2 ** pool.backoff, 15_000);
pool.backoff += 1;
pool.retryTimer = setTimeout(() => {
pool.retryTimer = null;
if (!pools.has(url) || pool.listeners.size === 0) return;
connect(url, pool);
}, delay);
}
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
let pool = pools.get(url);
if (!pool) {
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
pools.set(url, pool);
connect(url, pool);
}
pool.listeners.add(listener);
if (pool.ws && pool.ws.readyState === WebSocket.OPEN) {
try {
listener.onSync?.();
} catch {
// ignore
}
}
return () => {
const p = pools.get(url);
if (!p) return;
p.listeners.delete(listener);
if (p.listeners.size > 0) return;
pools.delete(url);
if (p.retryTimer) clearTimeout(p.retryTimer);
if (p.pingTimer) clearInterval(p.pingTimer);
try {
p.ws?.close();
} catch {
// already gone — fine
}
p.ws = null;
};
}
+44
View File
@@ -21,6 +21,27 @@ export interface AccessMessage {
created_at: number; created_at: number;
} }
export type ReportReason = "copyright" | "gore" | "falseinfo" | "bug";
export type ReportStatus = "open" | "reviewing" | "resolved" | "dismissed";
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
content: string;
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
const TOKEN_KEY = "kisassistant_token"; const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null { export function getToken(): string | null {
@@ -184,6 +205,29 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ body }), body: JSON.stringify({ body }),
}), }),
/** Content reports (flagged AI responses). */
createReport: (r: {
conversationId?: string;
messageIndex?: number;
reason: ReportReason;
details?: string;
content?: string;
prompt?: string;
model?: string;
}) =>
req<{ report: Report }>("/api/reports", {
method: "POST",
body: JSON.stringify(r),
}),
/** Admin: report triage. */
adminReportList: () => req<{ reports: Report[] }>("/api/admin/reports"),
adminReportPatch: (id: string, patch: { status: ReportStatus; adminNote?: string }) =>
req<{ report: Report }>(`/api/admin/reports/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
ollamaPull: async ( ollamaPull: async (
name: string, name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void, onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
+266
View File
@@ -422,6 +422,52 @@ const en = {
"access.sendFailed": "Send failed", "access.sendFailed": "Send failed",
"access.updateFailed": "Update failed", "access.updateFailed": "Update failed",
"msg.copy": "Copy message",
"msg.search": "Search the web",
"msg.report": "Report response",
"report.title": "Report this response",
"report.reason": "Reason",
"report.reasonPh": "Select a reason…",
"report.reason.copyright": "Copyright",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "False information",
"report.reason.bug": "Bug",
"report.details": "Details (optional)",
"report.detailsPh": "What is wrong with this response? (optional)",
"report.submit": "Send report",
"report.sent": "Report sent — an admin will review it.",
"report.failed": "Could not send the report",
"center.tabReports": "Reports",
"reports.filter.all": "all",
"reports.filter.open": "open",
"reports.filter.reviewing": "reviewing",
"reports.filter.resolved": "resolved",
"reports.filter.dismissed": "dismissed",
"reports.status.open": "Open",
"reports.status.reviewing": "Reviewing",
"reports.status.resolved": "Resolved",
"reports.status.dismissed": "Dismissed",
"reports.refresh": "Refresh",
"reports.refreshAria": "Refresh reports",
"reports.empty": "No content reports yet.",
"reports.emptyFiltered": "Nothing with this status.",
"reports.response": "Reported response",
"reports.noContent": "No content captured.",
"reports.model": "Model",
"reports.reporter": "Reporter",
"reports.conversation": "Conversation",
"reports.detailsGiven": "Reporter details",
"reports.noteLabel": "Admin note",
"reports.notePh": "Internal note…",
"reports.noteWord": "Note",
"reports.btnReviewing": "Reviewing",
"reports.btnResolve": "Resolve",
"reports.btnDismiss": "Dismiss",
"reports.updateFailed": "Update failed",
"reports.closedNote": "Report {status}.",
"review.title": "Access request", "review.title": "Access request",
"review.loadingTicket": "Loading ticket…", "review.loadingTicket": "Loading ticket…",
"review.notFound": "Ticket not found", "review.notFound": "Ticket not found",
@@ -872,6 +918,50 @@ const es: Dict = {
"access.sendFailed": "No se pudo enviar", "access.sendFailed": "No se pudo enviar",
"access.updateFailed": "No se pudo actualizar", "access.updateFailed": "No se pudo actualizar",
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
"msg.report": "Denunciar respuesta",
"report.title": "Denunciar esta respuesta",
"report.reason": "Motivo",
"report.reasonPh": "Selecciona un motivo…",
"report.reason.copyright": "Derechos de autor",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Información falsa",
"report.reason.bug": "Error",
"report.details": "Detalles (opcional)",
"report.detailsPh": "¿Qué tiene de malo esta respuesta? (opcional)",
"report.submit": "Enviar denuncia",
"report.sent": "Denuncia enviada — un administrador la revisará.",
"report.failed": "No se pudo enviar la denuncia",
"center.tabReports": "Denuncias",
"reports.filter.all": "todas",
"reports.filter.open": "abiertas",
"reports.filter.reviewing": "en revisión",
"reports.filter.resolved": "resueltas",
"reports.filter.dismissed": "descartadas",
"reports.status.open": "Abierta",
"reports.status.reviewing": "En revisión",
"reports.status.resolved": "Resuelta",
"reports.status.dismissed": "Descartada",
"reports.refresh": "Actualizar",
"reports.refreshAria": "Actualizar denuncias",
"reports.empty": "Aún no hay denuncias de contenido.",
"reports.emptyFiltered": "Nada con este estado.",
"reports.response": "Respuesta denunciada",
"reports.noContent": "Sin contenido capturado.",
"reports.model": "Modelo",
"reports.reporter": "Denunciante",
"reports.conversation": "Conversación",
"reports.detailsGiven": "Detalles del denunciante",
"reports.noteLabel": "Nota del administrador",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "En revisión",
"reports.btnResolve": "Resolver",
"reports.btnDismiss": "Descartar",
"reports.updateFailed": "No se pudo actualizar",
"reports.closedNote": "Denuncia {status}.",
"review.title": "Solicitud de acceso", "review.title": "Solicitud de acceso",
"review.loadingTicket": "Cargando ticket…", "review.loadingTicket": "Cargando ticket…",
"review.notFound": "Ticket no encontrado", "review.notFound": "Ticket no encontrado",
@@ -1319,6 +1409,50 @@ const fr: Dict = {
"access.sendFailed": "Envoi impossible", "access.sendFailed": "Envoi impossible",
"access.updateFailed": "Mise à jour impossible", "access.updateFailed": "Mise à jour impossible",
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
"msg.report": "Signaler la réponse",
"report.title": "Signaler cette réponse",
"report.reason": "Motif",
"report.reasonPh": "Choisir un motif…",
"report.reason.copyright": "Droit d'auteur",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Fausse information",
"report.reason.bug": "Bug",
"report.details": "Détails (facultatif)",
"report.detailsPh": "Qu'est-ce qui ne va pas avec cette réponse ? (facultatif)",
"report.submit": "Envoyer le signalement",
"report.sent": "Signalement envoyé — un admin va l'examiner.",
"report.failed": "Envoi du signalement impossible",
"center.tabReports": "Signalements",
"reports.filter.all": "tous",
"reports.filter.open": "ouverts",
"reports.filter.reviewing": "en cours",
"reports.filter.resolved": "résolus",
"reports.filter.dismissed": "rejetés",
"reports.status.open": "Ouvert",
"reports.status.reviewing": "En cours",
"reports.status.resolved": "Résolu",
"reports.status.dismissed": "Rejeté",
"reports.refresh": "Actualiser",
"reports.refreshAria": "Actualiser les signalements",
"reports.empty": "Aucun signalement pour l'instant.",
"reports.emptyFiltered": "Rien avec ce statut.",
"reports.response": "Réponse signalée",
"reports.noContent": "Aucun contenu capturé.",
"reports.model": "Modèle",
"reports.reporter": "Auteur",
"reports.conversation": "Conversation",
"reports.detailsGiven": "Détails de l'auteur",
"reports.noteLabel": "Note admin",
"reports.notePh": "Note interne…",
"reports.noteWord": "Note",
"reports.btnReviewing": "En cours",
"reports.btnResolve": "Résoudre",
"reports.btnDismiss": "Rejeter",
"reports.updateFailed": "Mise à jour impossible",
"reports.closedNote": "Signalement {status}.",
"review.title": "Demande d'accès", "review.title": "Demande d'accès",
"review.loadingTicket": "Chargement du ticket…", "review.loadingTicket": "Chargement du ticket…",
"review.notFound": "Ticket introuvable", "review.notFound": "Ticket introuvable",
@@ -1754,6 +1888,50 @@ const ru: Dict = {
"access.sendFailed": "Не отправилось", "access.sendFailed": "Не отправилось",
"access.updateFailed": "Не обновилось", "access.updateFailed": "Не обновилось",
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
"msg.report": "Пожаловаться на ответ",
"report.title": "Пожаловаться на этот ответ",
"report.reason": "Причина",
"report.reasonPh": "Выберите причину…",
"report.reason.copyright": "Авторские права",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Ложная информация",
"report.reason.bug": "Ошибка",
"report.details": "Подробности (необязательно)",
"report.detailsPh": "Что не так с этим ответом? (необязательно)",
"report.submit": "Отправить жалобу",
"report.sent": "Жалоба отправлена — администратор её рассмотрит.",
"report.failed": "Не удалось отправить жалобу",
"center.tabReports": "Жалобы",
"reports.filter.all": "все",
"reports.filter.open": "открытые",
"reports.filter.reviewing": "на рассмотрении",
"reports.filter.resolved": "решённые",
"reports.filter.dismissed": "отклонённые",
"reports.status.open": "Открыта",
"reports.status.reviewing": "На рассмотрении",
"reports.status.resolved": "Решена",
"reports.status.dismissed": "Отклонена",
"reports.refresh": "Обновить",
"reports.refreshAria": "Обновить жалобы",
"reports.empty": "Жалоб на контент пока нет.",
"reports.emptyFiltered": "Ничего с этим статусом.",
"reports.response": "Ответ",
"reports.noContent": "Содержимое отсутствует.",
"reports.model": "Модель",
"reports.reporter": "Автор жалобы",
"reports.conversation": "Беседа",
"reports.detailsGiven": "Комментарий автора",
"reports.noteLabel": "Заметка админа",
"reports.notePh": "Внутренняя заметка…",
"reports.noteWord": "Заметка",
"reports.btnReviewing": "На рассмотрение",
"reports.btnResolve": "Решить",
"reports.btnDismiss": "Отклонить",
"reports.updateFailed": "Не обновилось",
"reports.closedNote": "Жалоба {status}.",
"review.title": "Заявка на доступ", "review.title": "Заявка на доступ",
"review.loadingTicket": "Загружаем тикет…", "review.loadingTicket": "Загружаем тикет…",
"review.notFound": "Тикет не найден", "review.notFound": "Тикет не найден",
@@ -2194,6 +2372,50 @@ const it: Dict = {
"access.sendFailed": "Invio fallito", "access.sendFailed": "Invio fallito",
"access.updateFailed": "Aggiornamento fallito", "access.updateFailed": "Aggiornamento fallito",
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
"msg.report": "Segnala risposta",
"report.title": "Segnala questa risposta",
"report.reason": "Motivo",
"report.reasonPh": "Seleziona un motivo…",
"report.reason.copyright": "Diritto d'autore",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Informazione falsa",
"report.reason.bug": "Bug",
"report.details": "Dettagli (facoltativo)",
"report.detailsPh": "Cosa c'è di sbagliato in questa risposta? (facoltativo)",
"report.submit": "Invia segnalazione",
"report.sent": "Segnalazione inviata — un amministratore la esaminerà.",
"report.failed": "Impossibile inviare la segnalazione",
"center.tabReports": "Segnalazioni",
"reports.filter.all": "tutte",
"reports.filter.open": "aperte",
"reports.filter.reviewing": "in revisione",
"reports.filter.resolved": "risolte",
"reports.filter.dismissed": "respinte",
"reports.status.open": "Aperta",
"reports.status.reviewing": "In revisione",
"reports.status.resolved": "Risolta",
"reports.status.dismissed": "Respinta",
"reports.refresh": "Aggiorna",
"reports.refreshAria": "Aggiorna segnalazioni",
"reports.empty": "Nessuna segnalazione ancora.",
"reports.emptyFiltered": "Niente con questo stato.",
"reports.response": "Risposta segnalata",
"reports.noContent": "Nessun contenuto acquisito.",
"reports.model": "Modello",
"reports.reporter": "Autore",
"reports.conversation": "Conversazione",
"reports.detailsGiven": "Dettagli dell'autore",
"reports.noteLabel": "Nota admin",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "In revisione",
"reports.btnResolve": "Risolvi",
"reports.btnDismiss": "Respingi",
"reports.updateFailed": "Aggiornamento fallito",
"reports.closedNote": "Segnalazione {status}.",
"review.title": "Richiesta di accesso", "review.title": "Richiesta di accesso",
"review.loadingTicket": "Carico ticket…", "review.loadingTicket": "Carico ticket…",
"review.notFound": "Ticket non trovato", "review.notFound": "Ticket non trovato",
@@ -2625,6 +2847,50 @@ const pt: Dict = {
"access.sendFailed": "Falha ao enviar", "access.sendFailed": "Falha ao enviar",
"access.updateFailed": "Falha ao atualizar", "access.updateFailed": "Falha ao atualizar",
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
"msg.report": "Denunciar resposta",
"report.title": "Denunciar esta resposta",
"report.reason": "Motivo",
"report.reasonPh": "Selecione um motivo…",
"report.reason.copyright": "Direitos autorais",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Informação falsa",
"report.reason.bug": "Bug",
"report.details": "Detalhes (opcional)",
"report.detailsPh": "O que há de errado nesta resposta? (opcional)",
"report.submit": "Enviar denúncia",
"report.sent": "Denúncia enviada — um administrador irá analisá-la.",
"report.failed": "Não foi possível enviar a denúncia",
"center.tabReports": "Denúncias",
"reports.filter.all": "todas",
"reports.filter.open": "abertas",
"reports.filter.reviewing": "em análise",
"reports.filter.resolved": "resolvidas",
"reports.filter.dismissed": "rejeitadas",
"reports.status.open": "Aberta",
"reports.status.reviewing": "Em análise",
"reports.status.resolved": "Resolvida",
"reports.status.dismissed": "Rejeitada",
"reports.refresh": "Atualizar",
"reports.refreshAria": "Atualizar denúncias",
"reports.empty": "Nenhuma denúncia de conteúdo ainda.",
"reports.emptyFiltered": "Nada com este estado.",
"reports.response": "Resposta denunciada",
"reports.noContent": "Nenhum conteúdo capturado.",
"reports.model": "Modelo",
"reports.reporter": "Autor",
"reports.conversation": "Conversa",
"reports.detailsGiven": "Detalhes do autor",
"reports.noteLabel": "Nota do admin",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "Em análise",
"reports.btnResolve": "Resolver",
"reports.btnDismiss": "Rejeitar",
"reports.updateFailed": "Falha ao atualizar",
"reports.closedNote": "Denúncia {status}.",
"review.title": "Pedido de acesso", "review.title": "Pedido de acesso",
"review.loadingTicket": "A carregar ticket…", "review.loadingTicket": "A carregar ticket…",
"review.notFound": "Ticket não encontrado", "review.notFound": "Ticket não encontrado",
+3 -1
View File
@@ -11,7 +11,9 @@ export default defineConfig({
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
"/api": "http://localhost:3000", // Object form needed for ws:true — access-request live updates ride
// a WebSocket under /api (proxied in dev, same-origin in prod).
"/api": { target: "http://localhost:3000", ws: true },
}, },
}, },
}); });
+48
View File
@@ -0,0 +1,48 @@
import type { AccessMessage, AccessRequest } from "./access.ts";
/**
* Live access-request updates over WebSocket (replaces HTTP polling).
*
* Two channels, mirroring the HTTP auth model (no weakening):
* - ticket socket `/api/access/ws/:id` — capability auth by unguessable
* UUID, exactly like GET /api/access/ticket/:id. Receives this ticket's
* messages + status changes.
* - admin socket `/api/admin/ws?token=…` — admin Bearer (query param
* because browsers can't set WS headers). Receives every access event,
* driving the AdminCenter badge + triage list.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest };
export interface AccessLiveSubscriber {
/** Ticket to follow, or null for the admin firehose. */
ticketId: string | null;
isAdmin: boolean;
/** Send a pre-serialized frame. Return false when the socket is dead. */
send: (data: string) => boolean;
}
const subscribers = new Set<AccessLiveSubscriber>();
export function subscribeAccessLive(s: AccessLiveSubscriber): () => void {
subscribers.add(s);
return () => {
subscribers.delete(s);
};
}
export function broadcastAccessLive(evt: AccessLiveEvent): void {
if (subscribers.size === 0) return;
const data = JSON.stringify(evt);
for (const s of [...subscribers]) {
if (s.ticketId !== evt.request_id && !(s.isAdmin && s.ticketId === null)) continue;
try {
if (!s.send(data)) subscribers.delete(s);
} catch {
subscribers.delete(s);
}
}
}
+157 -3
View File
@@ -66,6 +66,19 @@ import {
setAccessStatus, setAccessStatus,
type AccessStatus, type AccessStatus,
} from "./access.ts"; } from "./access.ts";
import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
import {
REPORT_REASONS,
REPORT_STATUSES,
createReport,
getReport,
initReportsTables,
listReports,
setReportShadowbanned,
setReportStatus,
type ReportReason,
type ReportStatus,
} from "./reports.ts";
import type { ConversationRow } from "./db.ts"; import type { ConversationRow } from "./db.ts";
import type { OllamaDriver } from "./drivers/ollama.ts"; import type { OllamaDriver } from "./drivers/ollama.ts";
import { DriverRegistry } from "./drivers/registry.ts"; import { DriverRegistry } from "./drivers/registry.ts";
@@ -95,6 +108,7 @@ const registry = new DriverRegistry();
const ollamaDriver = registry.get("ollama") as OllamaDriver; const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init(); await tools.init();
initAccessTables(); initAccessTables();
initReportsTables();
initTotpTables(); initTotpTables();
function json(data: unknown, status = 200): Response { function json(data: unknown, status = 200): Response {
@@ -179,13 +193,48 @@ async function readJson(req: Request): Promise<{ ok: boolean; body: Record<strin
} }
} }
const server = Bun.serve({ /** WebSocket -> hub unsubscribe (cleaned up on close). */
const wsUnsub = new WeakMap<object, () => void>();
const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
port: config.port, port: config.port,
hostname: config.host, hostname: config.host,
// SSE chat streams can legitimately go silent for a while (cold model load, // SSE chat streams can legitimately go silent for a while (cold model load,
// slow/thinking models). Bun's default 10s idle timeout would kill them // slow/thinking models). Bun's default 10s idle timeout would kill them
// mid-generation — disable it (0). Nginx in front has its own timeouts. // mid-generation — disable it (0). Nginx in front has its own timeouts.
idleTimeout: 0, idleTimeout: 0,
websocket: {
open(ws) {
wsUnsub.set(
ws,
subscribeAccessLive({
ticketId: ws.data.ticketId,
isAdmin: ws.data.isAdmin,
send: (frame) => {
try {
ws.send(frame);
return true;
} catch {
return false;
}
},
}),
);
},
message(ws, msg) {
// Heartbeat only — clients ping, we pong. Anything else is ignored.
try {
const m = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg as Uint8Array).toString());
if (m?.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
} catch {
// malformed frame — ignore
}
},
close(ws) {
wsUnsub.get(ws)?.();
wsUnsub.delete(ws);
},
},
async fetch(req, server) { async fetch(req, server) {
const url = new URL(req.url); const url = new URL(req.url);
const path = url.pathname; const path = url.pathname;
@@ -475,8 +524,10 @@ const server = Bun.serve({
return json({ error: "a request for this username or email is already open" }, 409); return json({ error: "a request for this username or email is already open" }, 409);
} }
const created = createAccessRequest({ username, email, message }); const created = createAccessRequest({ username, email, message });
addAccessMessage(created.id, "user", message); const firstMsg = addAccessMessage(created.id, "user", message);
clearAttempts(ip); clearAttempts(ip);
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
// Ticket page: /review/<uuid> (unguessable, same pattern as share links). // Ticket page: /review/<uuid> (unguessable, same pattern as share links).
const reviewUrl = `${config.appUrl}/review/${created.id}`; const reviewUrl = `${config.appUrl}/review/${created.id}`;
sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {}); sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {});
@@ -504,11 +555,31 @@ const server = Bun.serve({
} }
const msg = addAccessMessage(r.id, "user", text); const msg = addAccessMessage(r.id, "user", text);
clearAttempts(ip); clearAttempts(ip);
broadcastAccessLive({ type: "access_message", request_id: r.id, message: msg });
return json({ message: msg }, 201); return json({ message: msg }, 201);
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
} }
// --- live access-request updates (WebSocket, replaces HTTP polling) ---
// Ticket socket: capability auth by unguessable UUID, exactly like
// GET /api/access/ticket/:id above. Admin socket: admin Bearer via
// ?token= (browsers can't set WS headers) or Authorization header.
const ticketWsMatch = path.match(/^\/api\/access\/ws\/([0-9a-f-]{36})$/);
if (ticketWsMatch && req.method === "GET") {
const t = getAccessRequest(ticketWsMatch[1]!);
if (!t) return json({ error: "invalid ticket link" }, 404);
if (server.upgrade(req, { data: { ticketId: t.id, isAdmin: false } })) return;
return json({ error: "websocket upgrade failed" }, 500);
}
if (path === "/api/admin/ws" && req.method === "GET") {
const token = url.searchParams.get("token") ?? extractBearer(req);
const u = token ? verifyToken(token) : null;
if (!u?.isAdmin) return json({ error: "unauthorized" }, 401);
if (server.upgrade(req, { data: { ticketId: null, isAdmin: true } })) return;
return json({ error: "websocket upgrade failed" }, 500);
}
// --- everything below requires auth --- // --- everything below requires auth ---
if (path.startsWith("/api/")) { if (path.startsWith("/api/")) {
const user = requireAuth(req); const user = requireAuth(req);
@@ -643,7 +714,7 @@ const server = Bun.serve({
return json({ user: toPublicUser(created), key }, 201); return json({ user: toPublicUser(created), key }, 201);
} }
const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate)?$/); const adminUserMatch = path.match(/^\/api\/admin\/users\/([0-9a-f-]{10,50})(\/regenerate|\/shadowban)?$/);
if (adminUserMatch) { if (adminUserMatch) {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
const target = getUserById(adminUserMatch[1]!); const target = getUserById(adminUserMatch[1]!);
@@ -658,6 +729,13 @@ const server = Bun.serve({
deleteUserSessions(target.id); deleteUserSessions(target.id);
return json({ key }); return json({ key });
} }
if (req.method === "POST" && adminUserMatch[2] === "/shadowban") {
const { ok, body } = await readJson(req);
if (!ok || typeof body.shadowbanned !== "boolean") {
return json({ error: "expected { shadowbanned: boolean }" }, 400);
}
return json({ shadowbanned: setReportShadowbanned(target.id, body.shadowbanned) === 1 });
}
if (req.method === "PATCH" && !adminUserMatch[2]) { if (req.method === "PATCH" && !adminUserMatch[2]) {
const { ok, body } = await readJson(req); const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400); if (!ok) return json({ error: "invalid JSON" }, 400);
@@ -828,6 +906,80 @@ const server = Bun.serve({
} }
} }
// --- content reports (flagged AI responses) ---
if (path === "/api/reports" && req.method === "POST") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const reason = body.reason as ReportReason | undefined;
if (!reason || !(REPORT_REASONS as readonly string[]).includes(reason)) {
return json({ error: "reason must be copyright|gore|falseinfo|bug" }, 400);
}
const details = body.details === undefined ? "" : cleanStr(body.details, 2000);
if (details === null) return json({ error: "details: max 2000 chars" }, 400);
const clientContent = typeof body.content === "string" ? body.content.slice(0, 20000) : "";
const clientPrompt = typeof body.prompt === "string" ? body.prompt.slice(0, 20000) : "";
const clientModel = typeof body.model === "string" ? body.model.slice(0, 160) : "";
let conversationId = "";
let messageIndex = -1;
if (body.conversationId !== undefined) {
if (typeof body.conversationId !== "string" || !body.conversationId) {
return json({ error: "bad conversationId" }, 400);
}
const conv = getConversation(body.conversationId);
if (!conv || conv.user_id !== user.id) return json({ error: "conversation not found" }, 404);
conversationId = conv.id;
if (body.messageIndex !== undefined) {
if (!Number.isInteger(body.messageIndex) || (body.messageIndex as number) < 0) {
return json({ error: "bad messageIndex" }, 400);
}
messageIndex = body.messageIndex as number;
}
}
try {
const report = createReport({
reporterId: user.id,
reporterName: user.displayName,
conversationId,
messageIndex,
reason,
details,
clientContent,
clientPrompt,
clientModel,
});
return json({ report }, 201);
} catch (e) {
if ((e as Error).message === "TOO_MANY_OPEN") return json({ error: "too many open reports" }, 429);
if ((e as Error).message === "ALREADY_REPORTED") {
return json({ error: "already reported", report: (e as Error & { report: unknown }).report }, 409);
}
throw e;
}
}
if (path === "/api/admin/reports" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ reports: listReports() });
}
const reportMatch = path.match(/^\/api\/admin\/reports\/([0-9a-f-]{36})$/);
if (reportMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getReport(reportMatch[1]!);
if (!target) return json({ error: "report not found" }, 404);
if (req.method === "PATCH") {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const status = body.status as ReportStatus | undefined;
if (!status || !(REPORT_STATUSES as readonly string[]).includes(status)) {
return json({ error: "status must be open|reviewing|resolved|dismissed" }, 400);
}
const adminNote = body.adminNote === undefined ? "" : cleanStr(body.adminNote, 1000);
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
return json({ report: setReportStatus(target.id, status, adminNote) });
}
}
// --- admin: access-request wishlist triage --- // --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") { if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
@@ -850,6 +1002,7 @@ const server = Bun.serve({
const msg = addAccessMessage(target.id, "admin", text); const msg = addAccessMessage(target.id, "admin", text);
// Every admin reply is forwarded to the requester (notification-only). // Every admin reply is forwarded to the requester (notification-only).
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {}); sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
broadcastAccessLive({ type: "access_message", request_id: target.id, message: msg });
return json({ message: msg }); return json({ message: msg });
} }
if (req.method === "PATCH" && !accessMatch[2]) { if (req.method === "PATCH" && !accessMatch[2]) {
@@ -874,6 +1027,7 @@ const server = Bun.serve({
if (status !== target.status && status !== "pending") { if (status !== target.status && status !== "pending") {
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {}); sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
} }
if (updated) broadcastAccessLive({ type: "access_status", request_id: updated.id, request: updated });
return json({ request: updated, ...(key ? { key } : {}) }); return json({ request: updated, ...(key ? { key } : {}) });
} }
return json({ error: "not found" }, 404); return json({ error: "not found" }, 404);
+180
View File
@@ -0,0 +1,180 @@
import { randomUUID } from "node:crypto";
import { getConversation, getDb, getMessages } from "./db.ts";
/**
* Content reports (user-flagged AI responses). Tables are created
* idempotently here so db.ts stays untouched — same pattern as access.ts.
*
* Flow: user hits "Report" under an assistant message, picks a reason
* (copyright / gore / falseinfo / bug) -> row in `reports` with a content
* snapshot (conversation may later be edited or deleted) -> admins triage
* in the AdminCenter (open / reviewing / resolved / dismissed + note).
*/
export const REPORT_REASONS = ["copyright", "gore", "falseinfo", "bug"] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const REPORT_STATUSES = ["open", "reviewing", "resolved", "dismissed"] as const;
export type ReportStatus = (typeof REPORT_STATUSES)[number];
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
/** Snapshot of the reported assistant message. */
content: string;
/** Snapshot of the user prompt that triggered it (previous user message). */
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
/** Live flag from users.report_shadowbanned (JOIN, not stored). */
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
export function initReportsTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
reporter_id TEXT NOT NULL,
reporter_name TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
message_index INTEGER NOT NULL DEFAULT -1,
content TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
admin_note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_reporter ON reports(reporter_id, created_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_reports_message ON reports(conversation_id, message_index)`).run();
// Prompt snapshot added after the initial schema — keep idempotent.
const rcols = d.query("PRAGMA table_info(reports)").all() as { name: string }[];
if (!rcols.some((c) => c.name === "prompt")) {
d.query("ALTER TABLE reports ADD COLUMN prompt TEXT NOT NULL DEFAULT ''").run();
}
// Per-user report shadow-ban (unreliable reporters) — keep idempotent.
const ucols = d.query("PRAGMA table_info(users)").all() as { name: string }[];
if (!ucols.some((c) => c.name === "report_shadowbanned")) {
d.query("ALTER TABLE users ADD COLUMN report_shadowbanned INTEGER NOT NULL DEFAULT 0").run();
}
}
/** Max non-terminal reports per reporter (spam guard). */
export const MAX_OPEN_REPORTS = 20;
/** Still-triageable report already covering this message, if any. */
export function findOpenReport(conversationId: string, messageIndex: number): Report | null {
if (!conversationId || messageIndex < 0) return null;
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.conversation_id = ? AND r.message_index = ? AND r.status IN ('open','reviewing') ORDER BY r.created_at DESC LIMIT 1",
).get(conversationId, messageIndex) as Report | null;
}
export function createReport(opts: {
reporterId: string;
reporterName: string;
conversationId: string;
messageIndex: number;
reason: ReportReason;
details: string;
clientContent: string;
clientPrompt: string;
clientModel: string;
}): Report {
const d = getDb();
const dup = opts.conversationId && opts.messageIndex >= 0
? findOpenReport(opts.conversationId, opts.messageIndex)
: null;
if (dup) {
const err = new Error("ALREADY_REPORTED") as Error & { report: Report };
err.report = dup;
throw err;
}
const open = d.query(
"SELECT COUNT(*) AS n FROM reports WHERE reporter_id = ? AND status IN ('open','reviewing')",
).get(opts.reporterId) as { n: number };
if (open.n >= MAX_OPEN_REPORTS) throw new Error("TOO_MANY_OPEN");
let content = opts.clientContent;
let prompt = opts.clientPrompt;
let model = opts.clientModel;
if (opts.conversationId) {
const conv = getConversation(opts.conversationId);
if (conv && conv.user_id === opts.reporterId) {
if (conv.model) model = conv.model;
const msgs = getMessages(conv.id).filter((m) => m.role !== "system");
if (opts.messageIndex >= 0) {
const at = msgs[opts.messageIndex];
if (at) content = at.content;
const prev = msgs[opts.messageIndex - 1];
if (prev && prev.role === "user") prompt = prev.content;
}
}
}
const now = Date.now();
const row: Report = {
id: randomUUID(),
reporter_id: opts.reporterId,
reporter_name: opts.reporterName,
conversation_id: opts.conversationId,
message_index: opts.messageIndex,
content,
prompt,
model,
reason: opts.reason,
details: opts.details,
status: "open",
admin_note: "",
reporter_shadowbanned: 0,
created_at: now,
updated_at: now,
};
d.query(
"INSERT INTO reports (id, reporter_id, reporter_name, conversation_id, message_index, content, prompt, model, reason, details, status, admin_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run(
row.id, row.reporter_id, row.reporter_name, row.conversation_id, row.message_index,
row.content, row.prompt, row.model, row.reason, row.details, row.status, row.admin_note,
row.created_at, row.updated_at,
);
return row;
}
export function listReports(): Report[] {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id ORDER BY r.updated_at DESC LIMIT 500",
).all() as Report[];
}
export function getReport(id: string): Report | null {
return getDb().query(
"SELECT r.*, COALESCE(u.report_shadowbanned, 0) AS reporter_shadowbanned FROM reports r LEFT JOIN users u ON u.id = r.reporter_id WHERE r.id = ?",
).get(id) as Report | null;
}
export function setReportStatus(id: string, status: ReportStatus, adminNote: string): Report | null {
getDb().query("UPDATE reports SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?").run(
status, adminNote, Date.now(), id,
);
return getReport(id);
}
/**
* Shadow-ban a reporter: their reports are still accepted but flagged
* unreliable (excluded from the admin badge). Returns the new flag.
*/
export function setReportShadowbanned(userId: string, shadowbanned: boolean): number {
getDb().query("UPDATE users SET report_shadowbanned = ? WHERE id = ?").run(shadowbanned ? 1 : 0, userId);
const row = getDb().query("SELECT report_shadowbanned AS v FROM users WHERE id = ?").get(userId) as { v: number } | null;
return row?.v ?? 0;
}