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
+35 -25
View File
@@ -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]);
+45 -16
View File
@@ -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}
</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>
))}
</div>
@@ -160,6 +183,12 @@ export function AdminCenter() {
</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" && (
<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">
+139 -5
View File
@@ -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 (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
{isUser
@@ -29,11 +42,117 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
<Markdown text={msg.content} />
</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>
);
}
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({
user,
conv,
@@ -80,6 +199,7 @@ export function Chat({
}) {
const [draft, setDraft] = useState("");
const [attachOpen, setAttachOpen] = useState(false);
const [reportIndex, setReportIndex] = useState<number | null>(null);
const { t } = useT();
const features = useFeatures();
const boxRef = useRef<HTMLDivElement>(null);
@@ -169,7 +289,7 @@ export function Chat({
</div>
)}
{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 !== "" && (
<div className="flex gap-3">
@@ -306,6 +426,20 @@ export function Chat({
</a>
</p>
</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>
);
}
@@ -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,
} from "lucide-react";
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import {
Button,
CopyButton,
@@ -82,32 +83,36 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
}
}
useEffect(() => {
let stopped = false;
load();
// Live updates: short-poll the ticket so admin replies and the
// decision show up without a manual refresh. No WS infra on the
// backend — traffic here is tiny, polling is the clean fit.
let busy = false;
async function poll() {
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;
}
/** Silent resync after a (re)connect — never flips to the error state. */
async function refreshSilent() {
try {
const res = await api.accessTicket(ticketId);
setRequest(res.request);
setMessages(res.messages);
} catch {
// Transient failure: keep last known state.
}
const id = setInterval(poll, 5000);
return () => {
stopped = true;
clearInterval(id);
};
}
useEffect(() => {
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
}, [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;
}
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";
export function getToken(): string | null {
@@ -184,6 +205,29 @@ export const api = {
method: "POST",
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 (
name: string,
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.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.loadingTicket": "Loading ticket…",
"review.notFound": "Ticket not found",
@@ -872,6 +918,50 @@ const es: Dict = {
"access.sendFailed": "No se pudo enviar",
"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.loadingTicket": "Cargando ticket…",
"review.notFound": "Ticket no encontrado",
@@ -1319,6 +1409,50 @@ const fr: Dict = {
"access.sendFailed": "Envoi 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.loadingTicket": "Chargement du ticket…",
"review.notFound": "Ticket introuvable",
@@ -1754,6 +1888,50 @@ const ru: Dict = {
"access.sendFailed": "Не отправилось",
"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.loadingTicket": "Загружаем тикет…",
"review.notFound": "Тикет не найден",
@@ -2194,6 +2372,50 @@ const it: Dict = {
"access.sendFailed": "Invio 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.loadingTicket": "Carico ticket…",
"review.notFound": "Ticket non trovato",
@@ -2625,6 +2847,50 @@ const pt: Dict = {
"access.sendFailed": "Falha ao enviar",
"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.loadingTicket": "A carregar ticket…",
"review.notFound": "Ticket não encontrado",