report system
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user