This commit is contained in:
k
2026-09-24 10:33:43 +02:00
parent cf4480743e
commit 4005ce0631
17 changed files with 625 additions and 98 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) |
| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request |
| WS | `/api/access/ws/:id` | ticket bearer | live ticket messages + status (ping/pong heartbeat) |
| WS | `/api/admin/ws?token=…` | admin | firehose of all access events (badge + triage list) |
| WS | `/api/admin/ws?token=…` | admin | firehose of all access + report events (badges + triage lists, full payloads, no polling) |
| 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 |
+4 -1
View File
@@ -108,7 +108,10 @@ exists: key = account id (`/cdn/avatar/<id>.png`), ownership enforced.
tesseract binary, availability probed at boot). `POST /api/tools/ocr` takes
raw image bytes (5 MB, magic-verified, 20 jobs/hour/account) and returns
`{ text, truncated, chars }` — transcription happens server-side with
per-driver-style stdout logging; a missing binary yields a clean 501. Text
per-driver-style stdout logging; a missing binary yields a clean 501.
`sanitizeOcrText` strips engine noise (control/format/private-use code
points, U+FFFD markers, form-feeds) while keeping legitimate text
(accents, CJK, emoji). Text
attachments use the `text` CDN namespace (500 KB, UTF-8 validated). The model
only ever sees reviewed text blocks, never raw files.
+3 -2
View File
@@ -78,8 +78,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`,
(`{ title, topic, model, authorName, authorAvatarUrl, archived, sharedAt, messages }`);
user messages render the author's avatar via `Avatar` (URL or initials);
an "Archived" pill shows when the source conversation is archived.
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and
a copy button.
- `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a
copy button and dependency-free syntax highlighting (`lib/highlight.tsx` —
regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched).
## Theming
+7 -3
View File
@@ -171,9 +171,13 @@ UUID, same pattern as share links).
(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.
`access_created` for every ticket plus `report_created` + `report_status`,
driving the AdminCenter badges and the triage lists). Every event carries
its full payload (`access_created` bundles the ticket, its first message
and the message count), so clients apply it straight to state — HTTP
resync only happens on (re)connect to cover the offline gap. Clients ping
every 25 s (`{"type":"ping"}` → `{"type":"pong"}`) with a pong watchdog
that redials half-dead sockets.
## Content reports (flagged AI responses)
+77 -33
View File
@@ -3,6 +3,7 @@ import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } fr
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 { pushToast } from "../lib/toasts.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -16,6 +17,22 @@ const STATUS_STYLE: Record<AccessStatus, { cls: string }> = {
type Filter = "all" | AccessStatus;
const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"];
/**
* Message ids already applied (WS echo + HTTP response race). Shared by the
* list (count bumps) and the open thread (appends) so each message lands
* exactly once no matter which arrives first.
*/
const seenAccessMsgIds = new Set<number>();
function markAccessMsgSeen(msg: AccessMessage): boolean {
if (seenAccessMsgIds.has(msg.id)) return false;
seenAccessMsgIds.add(msg.id);
if (seenAccessMsgIds.size > 1000) {
const oldest = seenAccessMsgIds.values().next().value;
if (oldest !== undefined) seenAccessMsgIds.delete(oldest);
}
return true;
}
/** Admin triage for the access-request wishlist: accept / refuse / review. */
export function AccessRequestsPanel() {
const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]);
@@ -24,11 +41,23 @@ export function AccessRequestsPanel() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const { t } = useT();
// State mirrors for the WS handler (no side effects inside updaters).
const rowsRef = useRef(requests);
const selectedRef = useRef(selectedId);
rowsRef.current = requests;
selectedRef.current = selectedId;
function setRows(fn: (prev: (AccessRequest & { message_count: number })[]) => (AccessRequest & { message_count: number })[]): void {
setRequests((prev) => {
const next = fn(prev);
rowsRef.current = next;
return next;
});
}
async function refresh(select?: string) {
try {
const res = await api.adminAccessList();
setRequests(res.requests);
setRows(() => res.requests);
if (select) setSelectedId(select);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
@@ -39,14 +68,40 @@ export function AccessRequestsPanel() {
useEffect(() => {
refresh();
// Live list: the admin firehose pushes every access event (shares its
// socket with the AdminCenter badge via the pool). Resync on
// (re)connect covers events missed while offline.
// Live list: the admin firehose pushes full payloads, so rows update
// incrementally with zero HTTP (shares its socket with the AdminCenter
// badge via the pool). Full resync only on (re)connect (offline gap).
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") refresh();
if (evt.type === "pong") return;
if (evt.type === "access_created") {
markAccessMsgSeen(evt.message);
setRows((prev) =>
prev.some((r) => r.id === evt.request_id)
? prev
: [{ ...evt.request, message_count: evt.message_count }, ...prev],
);
} else if (evt.type === "access_message") {
if (!markAccessMsgSeen(evt.message)) return;
const row = rowsRef.current.find((r) => r.id === evt.request_id);
if (row && evt.message.author === "user" && selectedRef.current !== evt.request_id) {
pushToast(t("live.newMessage", { user: row.username }), { icon: "mail" });
}
setRows((prev) =>
prev.map((r) =>
r.id === evt.request_id ? { ...r, message_count: r.message_count + 1 } : r,
),
);
} else if (evt.type === "access_status") {
setRows((prev) =>
prev.map((r) =>
r.id === evt.request_id ? { ...evt.request, message_count: r.message_count } : r,
),
);
}
// Report events: the ReportsPanel owns them; badges own the counts.
},
onSync: () => {
refresh();
@@ -121,7 +176,7 @@ export function AccessRequestsPanel() {
{t(`access.status.${r.status}` as StringKey)}
</span>
</button>
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
{selectedId === r.id && <TicketDetail request={r} onUpdated={(updated) => setRows((prev) => prev.map((x) => x.id === updated.id ? { ...updated, message_count: x.message_count } : x))} />}
</li>
))}
</ul>
@@ -129,7 +184,7 @@ export function AccessRequestsPanel() {
);
}
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
function TicketDetail({ request, onUpdated }: { request: AccessRequest; onUpdated: (r: AccessRequest) => void }) {
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [reason, setReason] = useState(request.reason);
const [draft, setDraft] = useState("");
@@ -137,29 +192,19 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
const [error, setError] = useState("");
const { t } = useT();
const [freshKey, setFreshKey] = useState<string | null>(null);
// Latest callbacks / server state without restarting the poll loop.
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const known = useRef({ status: request.status, reason: request.reason });
useEffect(() => {
setReason(request.reason);
setFreshKey(null);
known.current = { status: request.status, reason: request.reason };
// Live thread: requester replies arrive instantly, and a status change
// made elsewhere syncs the list (badges, filters). Resync on
// (re)connect covers events missed while offline.
// Live thread: requester replies arrive instantly with their full
// payload (no refetch); status changes made elsewhere arrive on the
// parent's firehose and flow back down as props. Full reload only on
// (re)connect (offline gap).
async function fetchOnce() {
try {
const res = await api.adminAccessGet(request.id);
for (const m of res.messages) markAccessMsgSeen(m);
setMessages(res.messages);
if (
res.request.status !== known.current.status ||
res.request.reason !== known.current.reason
) {
known.current = { status: res.request.status, reason: res.request.reason };
onChangedRef.current();
}
} catch {
// Transient failure: keep last state.
}
@@ -167,15 +212,14 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
fetchOnce();
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
onEvent: (evt) => {
if (evt.type === "pong" || evt.request_id !== request.id) return;
// Report events only travel the admin firehose — never ticket sockets.
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
if (evt.request_id !== request.id) return;
if (evt.type === "access_message") {
const msg = evt.message;
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
onChangedRef.current();
} else if (evt.type === "access_status") {
known.current = { status: evt.request.status, reason: evt.request.reason };
onChangedRef.current();
if (!markAccessMsgSeen(evt.message)) return;
setMessages((prev) => [...prev, evt.message]);
}
// access_status flows via the parent firehose -> onUpdated -> props.
},
onSync: () => {
fetchOnce();
@@ -192,8 +236,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
try {
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
if (res.key) setFreshKey(res.key);
known.current = { status: res.request.status, reason: res.request.reason };
onChanged();
onUpdated(res.request);
} catch (err) {
setError(err instanceof Error ? err.message : t("access.updateFailed"));
} finally {
@@ -209,9 +252,10 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
setError("");
try {
const res = await api.adminAccessReply(request.id, body);
setMessages((prev) => [...prev, res.message]);
// The broadcast echo of our own message may already be applied —
// the shared seen-set keeps exactly one copy either way.
if (markAccessMsgSeen(res.message)) setMessages((prev) => [...prev, res.message]);
setDraft("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
} finally {
+58 -18
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api, getToken } from "../lib/api.ts";
import { api, getToken, type AccessStatus, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts";
@@ -29,6 +31,9 @@ export function AdminCenter() {
const [saving, setSaving] = useState(false);
const [openAccessCount, setOpenAccessCount] = useState(0);
const [openReportCount, setOpenReportCount] = useState(0);
// Full badge resync (HTTP) — only for mount, reconnect gaps and rare
// actions with no WS event (reporter shadow-ban). Hot path is incremental.
const resyncBadgesRef = useRef<() => void>(() => {});
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
@@ -64,40 +69,74 @@ export function AdminCenter() {
return () => window.clearTimeout(timer);
}, []);
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
// event, so the open counts stay fresh with zero polling (reports re-fetch
// alongside). Resync on (re)connect covers events missed while offline.
// Live badges on the Access/Reports tabs: the admin firehose pushes full
// payloads, so the open counts update incrementally with zero HTTP.
// Resync on (re)connect covers events missed while offline.
useEffect(() => {
if (!allowed) return;
async function fetchCount() {
const accessById = new Map<string, AccessStatus>();
const reportById = new Map<string, { status: ReportStatus; shadowbanned: number }>();
function recount() {
let access = 0;
for (const s of accessById.values()) if (s === "pending" || s === "reviewing") access++;
let reports = 0;
for (const r of reportById.values()) {
if ((r.status === "open" || r.status === "reviewing") && r.shadowbanned !== 1) reports++;
}
setOpenAccessCount(access);
setOpenReportCount(reports);
}
async function resync() {
try {
const res = await api.adminAccessList();
setOpenAccessCount(
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
);
accessById.clear();
for (const r of res.requests) accessById.set(r.id, r.status);
} catch {
// Transient failure: keep last count.
// Transient failure: keep last counts.
}
try {
const res = await api.adminReportList();
setOpenReportCount(
res.reports.filter((r) => (r.status === "open" || r.status === "reviewing") && r.reporter_shadowbanned !== 1).length,
);
reportById.clear();
for (const r of res.reports) {
reportById.set(r.id, { status: r.status, shadowbanned: r.reporter_shadowbanned });
}
} catch {
// Transient failure: keep last count.
// Transient failure: keep last counts.
}
recount();
}
fetchCount();
resyncBadgesRef.current = resync;
resync();
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type !== "pong") fetchCount();
if (evt.type === "pong" || evt.type === "access_message") return;
if (evt.type === "access_created") {
accessById.set(evt.request_id, evt.request.status);
recount();
pushToast(t("live.newRequest", { user: evt.request.username }), { icon: "inbox" });
} else if (evt.type === "access_status") {
accessById.set(evt.request_id, evt.request.status);
recount();
} else if (evt.type === "report_created") {
reportById.set(evt.report.id, { status: evt.report.status, shadowbanned: evt.report.reporter_shadowbanned });
recount();
pushToast(t("live.newReport", { reason: evt.report.reason }), { icon: "mail" });
} else if (evt.type === "report_status") {
const prev = reportById.get(evt.report.id);
reportById.set(evt.report.id, {
status: evt.report.status,
shadowbanned: prev?.shadowbanned ?? evt.report.reporter_shadowbanned,
});
recount();
}
},
onSync: () => {
fetchCount();
resync();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowed]);
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) {
@@ -199,7 +238,7 @@ export function AdminCenter() {
{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 />
<ReportsPanel onShadowbanChanged={() => resyncBadgesRef.current()} />
</section>
)}
@@ -265,6 +304,7 @@ export function AdminCenter() {
{tab === "mail" && <MailPanel />}
</main>
<Toasts />
</div>
);
}
+4 -1
View File
@@ -1,8 +1,11 @@
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CopyButton } from "./ui.tsx";
import { highlightCode } from "../lib/highlight.tsx";
function CodeBlock({ code, lang }: { code: string; lang: string }) {
const nodes = useMemo(() => highlightCode(code, lang), [code, lang]);
return (
<div className="relative">
{lang && (
@@ -12,7 +15,7 @@ function CodeBlock({ code, lang }: { code: string; lang: string }) {
<CopyButton text={code} />
</div>
<pre className={lang ? "pt-9" : ""}>
<code>{code}</code>
<code>{nodes}</code>
</pre>
</div>
);
+41 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { CheckCircle2, Clock, Eye, EyeOff, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
import { api, type Report, type ReportStatus } from "../lib/api.ts";
import { api, getToken, type Report, type ReportStatus } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { Button, ConfirmDialog, Field, Input, Spinner } from "./ui.tsx";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -16,7 +17,7 @@ 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() {
export function ReportsPanel({ onShadowbanChanged }: { onShadowbanChanged?: () => void }) {
const [reports, setReports] = useState<Report[]>([]);
const [filter, setFilter] = useState<Filter>("open");
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -42,11 +43,24 @@ export function ReportsPanel() {
useEffect(() => {
refresh();
// Live list: new reports pop without manual refresh.
const id = setInterval(() => {
if (!document.hidden) refresh();
}, 15000);
return () => clearInterval(id);
// Live list: the admin firehose pushes full report payloads, so rows
// update incrementally with zero HTTP (shares its socket with the
// AdminCenter badge via the pool). Full resync only on (re)connect.
const token = getToken();
if (!token) return;
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
onEvent: (evt) => {
if (evt.type === "report_created") {
setReports((prev) => (prev.some((r) => r.id === evt.report.id) ? prev : [evt.report, ...prev]));
} else if (evt.type === "report_status") {
setReports((prev) => prev.map((r) => (r.id === evt.report.id ? evt.report : r)));
}
// Access events: the AccessRequestsPanel owns them.
},
onSync: () => {
refresh();
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -124,7 +138,18 @@ export function ReportsPanel() {
</span>
)}
</button>
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
{selectedId === r.id && (
<ReportDetail
report={r}
onPatched={(updated) => setReports((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))}
onBanned={(reporterId, shadowbanned) => {
setReports((prev) =>
prev.map((x) => (x.reporter_id === reporterId ? { ...x, reporter_shadowbanned: shadowbanned ? 1 : 0 } : x)),
);
onShadowbanChanged?.();
}}
/>
)}
</li>
))}
</ul>
@@ -132,7 +157,7 @@ export function ReportsPanel() {
);
}
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
function ReportDetail({ report, onPatched, onBanned }: { report: Report; onPatched: (r: Report) => void; onBanned: (reporterId: string, shadowbanned: boolean) => void }) {
const [note, setNote] = useState(report.admin_note);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -149,8 +174,10 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
setBusy(true);
setError("");
try {
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onChanged();
// The report_status broadcast echoes the same payload to every admin —
// applying the response directly keeps this view instant and idempotent.
const res = await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
onPatched(res.report);
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
@@ -163,7 +190,9 @@ function ReportDetail({ report, onChanged }: { report: Report; onChanged: () =>
setError("");
try {
await api.adminShadowban(report.reporter_id, shadowbanned);
onChanged();
// Shadow-ban has no WS event (per-user, not per-report): patch every
// row of this reporter locally and resync the badges over HTTP.
onBanned(report.reporter_id, shadowbanned);
} catch (err) {
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
} finally {
+35 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
CheckCircle2,
Clock,
@@ -19,6 +19,8 @@ import {
Modal,
Spinner,
} from "./ui.tsx";
import { Toasts } from "./Toasts.tsx";
import { pushToast } from "../lib/toasts.ts";
import { useT, type StringKey } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
@@ -70,10 +72,28 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
const { t } = useT();
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const threadRef = useRef<HTMLDivElement>(null);
// Message ids already applied (WS echo + HTTP response race): each lands once.
const seenIds = useRef(new Set<number>());
function appendMessage(msg: AccessMessage): boolean {
if (seenIds.current.has(msg.id)) return false;
seenIds.current.add(msg.id);
setMessages((prev) => [...prev, msg]);
return true;
}
// Follow live traffic: stick to the bottom while the user already is.
useEffect(() => {
const el = threadRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
if (nearBottom) el.scrollTop = el.scrollHeight;
}, [messages]);
async function load() {
try {
const res = await api.accessTicket(ticketId);
for (const m of res.messages) seenIds.current.add(m.id);
setRequest(res.request);
setMessages(res.messages);
} catch {
@@ -87,6 +107,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
async function refreshSilent() {
try {
const res = await api.accessTicket(ticketId);
for (const m of res.messages) seenIds.current.add(m.id);
setRequest(res.request);
setMessages(res.messages);
} catch {
@@ -96,16 +117,21 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
useEffect(() => {
load();
// Live updates over WebSocket: admin replies and the decision arrive
// instantly; resync on (re)connect covers events missed while offline.
// Live updates over WebSocket with full payloads: admin replies and the
// decision apply straight to state (no refetch); 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;
// Report events only travel the admin firehose — never ticket sockets.
if (evt.type === "pong" || evt.type === "report_created" || evt.type === "report_status") return;
if (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]));
if (appendMessage(evt.message) && evt.message.author === "admin") {
pushToast(t("live.adminReply"), { icon: "mail" });
}
} else if (evt.type === "access_status") {
setRequest(evt.request);
pushToast(t("live.ticketStatus", { status: t(`review.status.${evt.request.status}` as StringKey) }), { icon: "inbox" });
}
},
onSync: () => {
@@ -124,7 +150,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
setError("");
try {
const res = await api.accessReply(ticketId, body);
setMessages((prev) => [...prev, res.message]);
appendMessage(res.message);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : t("access.sendFailed"));
@@ -190,7 +216,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
</>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
<div ref={threadRef} className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
{messages.map((m) => (
<div
key={m.id}
@@ -259,6 +285,7 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
</div>
)}
</div>
<Toasts />
</div>
);
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Archive, ArchiveRestore, Check, Cpu, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
import { Archive, ArchiveRestore, Check, Cpu, Inbox, Mail, Palette, Pencil, SunMoon, Trash2, Unplug, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
import { removeToast, TOAST_DISPLAY_MS, useToasts, type Toast, type ToastIcon } from "../lib/toasts.ts";
@@ -13,6 +13,8 @@ const ICONS: Record<ToastIcon, LucideIcon> = {
unshare: Unplug,
edit: Pencil,
check: Check,
mail: Mail,
inbox: Inbox,
};
/** Must match the .toast-exit animation duration in index.css. */
+67 -7
View File
@@ -1,4 +1,4 @@
import type { AccessMessage, AccessRequest } from "./api.ts";
import type { AccessMessage, AccessRequest, Report } from "./api.ts";
/**
* Shared WebSocket pool for live access-request updates.
@@ -7,13 +7,21 @@ import type { AccessMessage, AccessRequest } from "./api.ts";
* 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.
* A 25s client ping keeps idle connections alive through proxies, and a
* pong watchdog reconnects half-dead sockets (ping answered by nobody)
* that would otherwise silently stop notifying.
*
* Events carry their full payload — handlers apply them straight to state.
* HTTP resync (onSync) is only the offline-gap fallback, never the
* per-event path.
*/
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: "access_created"; request_id: string; request: AccessRequest; message: AccessMessage; message_count: number }
| { type: "report_created"; report: Report }
| { type: "report_status"; report: Report }
| { type: "pong" };
export interface AccessLiveListener {
@@ -28,15 +36,51 @@ interface Pool {
backoff: number;
retryTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
/** Last time the server proved liveness (open or pong). 0 = unknown. */
lastLive: number;
}
let globalHooksInstalled = false;
const pools = new Map<string, Pool>();
/** Ping period; the watchdog tolerates one missed pong plus slack. */
const PING_MS = 25_000;
const WATCHDOG_MS = PING_MS + 10_000;
export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
/** Reconnect every pooled socket now (tab visible again / back online). */
function reconnectAll(): void {
for (const [url, pool] of pools) {
if (pool.listeners.size === 0) continue;
if (pool.retryTimer) {
clearTimeout(pool.retryTimer);
pool.retryTimer = null;
}
try {
pool.ws?.close();
} catch {
// already gone — connect() below redials
}
pool.ws = null;
pool.backoff = 0;
connect(url, pool);
}
}
function installGlobalHooks(): void {
if (globalHooksInstalled || typeof document === "undefined") return;
globalHooksInstalled = true;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") reconnectAll();
});
window.addEventListener("online", () => reconnectAll());
}
function connect(url: string, pool: Pool): void {
let ws: WebSocket;
try {
@@ -49,15 +93,27 @@ function connect(url: string, pool: Pool): void {
ws.onopen = () => {
pool.backoff = 0;
pool.lastLive = Date.now();
pool.pingTimer = setInterval(() => {
// Half-dead socket: OPEN but the server hasn't proved liveness for
// over a ping period + grace (proxy killed it, server restarted…).
// Close it so onclose schedules a fresh dial.
if (ws.readyState === WebSocket.OPEN) {
if (Date.now() - pool.lastLive > WATCHDOG_MS) {
try {
ws.close();
} catch {
// close handler reconnects
}
return;
}
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
// send failed — the close handler will schedule a retry
}
}
}, 25_000);
}, PING_MS);
for (const l of [...pool.listeners]) {
try {
l.onSync?.();
@@ -75,6 +131,8 @@ function connect(url: string, pool: Pool): void {
return;
}
if (!parsed || typeof parsed.type !== "string") return;
// Any frame (event or pong) proves the socket is alive.
if (pool.ws === ws) pool.lastLive = Date.now();
for (const l of [...pool.listeners]) {
try {
l.onEvent(parsed);
@@ -110,10 +168,12 @@ function scheduleRetry(url: string, pool: Pool): void {
}
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
let pool = pools.get(url);
let pool: Pool | undefined = pools.get(url);
if (!pool) {
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
pools.set(url, pool);
const fresh: Pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null, lastLive: 0 };
pools.set(url, fresh);
pool = fresh;
installGlobalHooks();
connect(url, pool);
}
pool.listeners.add(listener);
+219
View File
@@ -0,0 +1,219 @@
import type { ReactNode } from "react";
/**
* Dependency-free syntax highlighting for chat code blocks.
*
* No highlight.js / prism (keeps the bundle small and the Nix offline build
* lockfile-stable): a small regex tokenizer per language family that emits
* <span> tokens colored for the always-dark <pre> bubble (see index.css).
* Unknown languages fall back to generic strings/comments/numbers so every
* block still gets some coloration.
*/
const CLS = {
comment: "text-stone-400 italic",
string: "text-amber-200",
keyword: "text-violet-300",
number: "text-sky-300",
function: "text-emerald-300",
type: "text-rose-300",
decorator: "text-orange-300",
diffAdd: "text-emerald-300",
diffDel: "text-red-300",
} as const;
type TokKind = keyof typeof CLS | "plain";
interface Token {
kind: TokKind;
text: string;
}
const ALIASES: Record<string, string> = {
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
mts: "typescript",
py: "python",
py3: "python",
sh: "bash",
shell: "bash",
zsh: "bash",
console: "bash",
terminal: "bash",
yml: "yaml",
json5: "json",
jsonc: "json",
cxx: "cpp",
"c++": "cpp",
hpp: "cpp",
cs: "csharp",
rs: "rust",
golang: "go",
dockerfile: "docker",
makefile: "make",
md: "markdown",
html: "xml",
vue: "xml",
svelte: "xml",
};
const KEYWORDS: Record<string, string[]> = {
javascript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set".split(" "),
typescript: "break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return super switch this throw try typeof var void while with yield async await of static get set interface type enum implements private public protected readonly abstract namespace declare".split(" "),
python: "and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case".split(" "),
bash: "if then else elif fi for while in do done case esac function select until echo exit return local export alias cd ls".split(" "),
rust: "as break const continue crate else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while async await dyn".split(" "),
go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var".split(" "),
java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while var".split(" "),
c: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while".split(" "),
cpp: "auto break case char const continue default do double else enum extern float for goto if inline int long namespace new private protected public return short signed sizeof static struct switch template this throw try typedef typename union unsigned using virtual void volatile while class constexpr decltype noexcept nullptr static_assert".split(" "),
csharp: "abstract as base bool break byte case catch char checked class const continue decimal default do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var".split(" "),
php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile extends final finally fn for foreach function global if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public require require_once return static switch throw trait try unset use var while xor yield".split(" "),
ruby: "alias and begin BEGIN break case class def defined do else elsif end END ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield".split(" "),
swift: "associatedtype class deinit enum extension fileprivate func if import in init inout internal let open operator private protocol public static struct subscript typealias var break case continue default defer do else fallthrough for guard repeat return switch where while as catch throw throws try await async".split(" "),
kotlin: "as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field finally get import init out set value where".split(" "),
sql: "select from where join left right inner outer on group by order having limit offset insert into values update set delete create table alter drop index view as and or not null primary key foreign references distinct union all exists in between like is asc desc".split(" "),
json: "true false null".split(" "),
yaml: "true false null yes no on off".split(" "),
css: "import media supports keyframes font-face".split(" "),
xml: [],
docker: "from run cmd label maintainer expose env add copy entrypoint volume user workdir arg onbuild stopsignal healthcheck shell".split(" "),
make: "ifeq ifneq else endif include override export unexport define endef".split(" "),
markdown: [],
};
function langKey(lang: string): string {
const l = lang.trim().toLowerCase();
return ALIASES[l] ?? l;
}
function escapeRe(words: string[]): string {
return words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
}
interface Grammar {
lineComment?: string;
blockComment?: boolean;
strings: ("sq" | "dq" | "bq" | "py3")[];
keywords: string[];
}
function grammarFor(lang: string): Grammar | null {
switch (lang) {
case "javascript":
case "typescript":
return { lineComment: "//", blockComment: true, strings: ["sq", "dq", "bq"], keywords: KEYWORDS[lang]! };
case "json":
return { strings: ["dq"], keywords: KEYWORDS.json! };
case "python":
return { lineComment: "#", strings: ["sq", "dq", "py3"], keywords: KEYWORDS.python! };
case "bash":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.bash! };
case "rust":
case "go":
case "java":
case "c":
case "cpp":
case "csharp":
case "php":
case "swift":
case "kotlin":
return { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS[lang]! };
case "ruby":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.ruby! };
case "sql":
return { lineComment: "--", blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.sql! };
case "yaml":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.yaml! };
case "css":
return { blockComment: true, strings: ["sq", "dq"], keywords: KEYWORDS.css! };
case "xml":
return { strings: ["sq", "dq"], keywords: [] };
case "docker":
return { lineComment: "#", strings: ["sq", "dq"], keywords: KEYWORDS.docker! };
case "make":
return { lineComment: "#", strings: [], keywords: KEYWORDS.make! };
default:
return null;
}
}
const STRING_RE: Record<string, string> = {
sq: "'(?:[^'\\\\\\n]|\\\\.)*'",
dq: '"(?:[^"\\\\\\n]|\\\\.)*"',
bq: "`(?:[^`\\\\]|\\\\.)*`",
py3: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'',
};
function tokenize(code: string, lang: string): Token[] {
const lk = langKey(lang);
// Diffs color whole lines — handled line by line, no grammar needed.
if (lk === "diff" || lk === "patch") {
return code.split("\n").flatMap((line, i, arr) => {
const kind: TokKind =
line.startsWith("+") && !line.startsWith("+++") ? "diffAdd" : line.startsWith("-") && !line.startsWith("---") ? "diffDel" : line.startsWith("@") ? "function" : "plain";
const toks: Token[] = [{ kind, text: line }];
if (i < arr.length - 1) toks.push({ kind: "plain", text: "\n" });
return toks;
});
}
const g = grammarFor(lk);
// Unknown language: generic strings + comments + numbers.
const grammar: Grammar = g ?? { lineComment: "//", blockComment: true, strings: ["sq", "dq"], keywords: [] };
const commentAlts: string[] = [];
if (grammar.blockComment) commentAlts.push("/\\*[\\s\\S]*?(?:\\*/|$)");
if (lk === "xml") commentAlts.push("<!--[\\s\\S]*?(?:-->|$)");
if (grammar.lineComment) {
const lc = grammar.lineComment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// `//` must not match the `://` of URLs (https://…).
commentAlts.push(grammar.lineComment === "//" ? `(?<!:)//[^\\n]*` : `${lc}[^\\n]*`);
}
const stringAlts = grammar.strings.map((s) => STRING_RE[s]!);
const named: [TokKind, string][] = [];
if (commentAlts.length > 0) named.push(["comment", commentAlts.join("|")]);
if (stringAlts.length > 0) named.push(["string", stringAlts.join("|")]);
if (grammar.keywords.length > 0) named.push(["keyword", `\\b(?:${escapeRe(grammar.keywords)})\\b`]);
named.push(["number", "\\b(?:0x[0-9a-fA-F]+|\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)\\b"]);
named.push(["decorator", "@[A-Za-z_][\\w$]*"]);
named.push(["function", "[A-Za-z_][\\w$]*(?=\\s*\\()"]);
named.push(["type", "\\b[A-Z][A-Za-z0-9_]*\\b"]);
const re = new RegExp(named.map(([kind, p]) => `(?<${kind}>${p})`).join("|"), "g");
const tokens: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
// Guard against pathological inputs: cap tokens, dump the rest as plain.
const MAX_TOKENS = 5000;
while ((m = re.exec(code)) !== null && tokens.length < MAX_TOKENS) {
if (m.index > last) tokens.push({ kind: "plain", text: code.slice(last, m.index) });
const text = m[0];
const groups = m.groups ?? {};
const kind: TokKind = (named.find(([k]) => groups[k] !== undefined)?.[0] ?? "plain") as TokKind;
tokens.push({ kind, text });
last = m.index + text.length;
if (text.length === 0) re.lastIndex++;
}
if (last < code.length) tokens.push({ kind: "plain", text: code.slice(last) });
return tokens;
}
export function highlightCode(code: string, lang: string): ReactNode[] {
const tokens = tokenize(code, lang);
return tokens.map((t, i) =>
t.kind === "plain" ? (
<span key={i}>{t.text}</span>
) : (
<span key={i} className={CLS[t.kind]}>
{t.text}
</span>
),
);
}
+30
View File
@@ -485,6 +485,11 @@ const en = {
"access.youAdmin": "You (admin)",
"access.sendFailed": "Send failed",
"access.updateFailed": "Update failed",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copy message",
"msg.search": "Search the web",
@@ -1056,6 +1061,11 @@ const es: Dict = {
"access.youAdmin": "Tú (admin)",
"access.sendFailed": "No se pudo enviar",
"access.updateFailed": "No se pudo actualizar",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
@@ -1622,6 +1632,11 @@ const fr: Dict = {
"access.youAdmin": "Vous (admin)",
"access.sendFailed": "Envoi impossible",
"access.updateFailed": "Mise à jour impossible",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
@@ -2176,6 +2191,11 @@ const ru: Dict = {
"access.youAdmin": "Вы (админ)",
"access.sendFailed": "Не отправилось",
"access.updateFailed": "Не обновилось",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
@@ -2735,6 +2755,11 @@ const it: Dict = {
"access.youAdmin": "Tu (admin)",
"access.sendFailed": "Invio fallito",
"access.updateFailed": "Aggiornamento fallito",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
@@ -3285,6 +3310,11 @@ const pt: Dict = {
"access.youAdmin": "Tu (admin)",
"access.sendFailed": "Falha ao enviar",
"access.updateFailed": "Falha ao atualizar",
"live.newRequest": "New access request from @{user}",
"live.newMessage": "New message from @{user}",
"live.newReport": "New content report ({reason})",
"live.adminReply": "Admin replied to your ticket",
"live.ticketStatus": "Ticket {status}",
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
+3 -1
View File
@@ -16,7 +16,9 @@ export type ToastIcon =
| "accent"
| "unshare"
| "edit"
| "check";
| "check"
| "mail"
| "inbox";
export interface Toast {
id: number;
+17 -4
View File
@@ -1,4 +1,5 @@
import type { AccessMessage, AccessRequest } from "./access.ts";
import type { Report } from "./reports.ts";
/**
* Live access-request updates over WebSocket (replaces HTTP polling).
@@ -8,14 +9,19 @@ import type { AccessMessage, AccessRequest } from "./access.ts";
* 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.
* because browsers can't set WS headers). Receives every access event
* plus content-report events, driving the AdminCenter badges + lists.
*
* Every event carries its full payload: clients apply it straight to state
* (no HTTP refetch on event — the socket IS the data, not a hint to poll).
*/
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: "access_created"; request_id: string; request: AccessRequest; message: AccessMessage; message_count: number }
| { type: "report_created"; report: Report }
| { type: "report_status"; report: Report };
export interface AccessLiveSubscriber {
/** Ticket to follow, or null for the admin firehose. */
@@ -37,8 +43,15 @@ export function subscribeAccessLive(s: AccessLiveSubscriber): () => void {
export function broadcastAccessLive(evt: AccessLiveEvent): void {
if (subscribers.size === 0) return;
const data = JSON.stringify(evt);
// Ticket-scoped events route to that ticket's socket; report events and
// everything else fan out to the admin firehose only.
const ticketId = "request_id" in evt ? evt.request_id : null;
for (const s of [...subscribers]) {
if (s.ticketId !== evt.request_id && !(s.isAdmin && s.ticketId === null)) continue;
if (s.isAdmin && s.ticketId === null) {
// Admin firehose: every event.
} else if (s.ticketId === null || s.ticketId !== ticketId) {
continue;
}
try {
if (!s.send(data)) subscribers.delete(s);
} catch {
+8 -3
View File
@@ -560,8 +560,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
const created = await createAccessRequest({ username, email, message });
const firstMsg = await addAccessMessage(created.id, "user", message);
clearAttempts(ip);
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created });
broadcastAccessLive({ type: "access_message", request_id: created.id, message: firstMsg });
// One event carries the whole creation (ticket + its first message):
// subscribers apply it directly, no HTTP refetch. No ticket socket
// can exist for this id yet, so nothing needs the message separately.
broadcastAccessLive({ type: "access_created", request_id: created.id, request: created, message: firstMsg, message_count: 1 });
// Ticket page: /review/<uuid> (unguessable, same pattern as share links).
const reviewUrl = `${config.appUrl}/review/${created.id}`;
sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {});
@@ -1093,6 +1095,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
clientPrompt,
clientModel,
});
broadcastAccessLive({ type: "report_created", report });
return json({ report }, 201);
} catch (e) {
if ((e as Error).message === "TOO_MANY_OPEN") return json({ error: "too many open reports" }, 429);
@@ -1122,7 +1125,9 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
const adminNote = cleanOptionalStr(body.adminNote, 1000);
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
return json({ report: await setReportStatus(target.id, status, adminNote) });
const updated = await setReportStatus(target.id, status, adminNote);
if (updated) broadcastAccessLive({ type: "report_status", report: updated });
return json({ report: updated });
}
}
+48 -3
View File
@@ -77,8 +77,7 @@ export class OcrTool implements PlatformTool {
async transcribe(image: Uint8Array): Promise<{ text: string; truncated: boolean }> {
if (!(await this.isAvailable())) throw new Error((await this.unavailableReason()) ?? "ocr unavailable");
const t0 = Date.now();
const maxChars = config.ocrMaxChars;
let proc;
const maxChars = config.ocrMaxChars; let proc;
try {
proc = Bun.spawn([tessBin(), "stdin", "stdout", "-l", config.ocrLang, "--psm", "3"], {
stdin: image,
@@ -104,7 +103,7 @@ export class OcrTool implements PlatformTool {
if (code !== 0) {
throw new Error(`ocr failed (exit ${code}): ${errText.trim().slice(0, 300)}`);
}
let text = out.replace(/\f/g, "").trim();
let text = sanitizeOcrText(out);
let truncated = false;
if (text.length > maxChars) {
text = text.slice(0, maxChars);
@@ -118,4 +117,50 @@ export class OcrTool implements PlatformTool {
}
}
/**
* Strip unrecognizable output from the OCR engine while preserving every
* legitimate character (accents, CJK, punctuation, emoji…).
*
* Tesseract emits raw control bytes, Unicode private-use / unassigned /
* surrogate code points and U+FFFD replacement chars when glyphs are
* unreadable, plus form-feeds between pages and \r line endings. Those
* poison the model prompt and the attachment preview, so they are removed
* here. Visible-but-dubious glyphs (|, ~, …) are kept: they may be real
* content and only the user can judge them in the review step.
*/
export function sanitizeOcrText(raw: string): string {
// Normalize line endings first; form-feeds are page breaks, not content.
let text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n");
// Strip BOMs / zero-width no-break spaces anywhere (keep ZWJ + VS16 so
// emoji sequences survive).
text = text.replace(/[\uFEFF\u200B\u200C\u2060\u180E]/g, "");
let out = "";
for (const ch of text) {
if (ch === "\n" || ch === "\t" || ch === "\u200D" || ch === "\uFE0F") {
out += ch;
continue;
}
const cp = ch.codePointAt(0)!;
// U+FFFD (replacement char) and U+FFFE/FFFF are the engine's markers
// for unreadable glyphs — never real content.
if (cp === 0xfffd || cp === 0xfffe || cp === 0xffff) continue;
// Keep letters, marks, numbers, punctuation, symbols and spaces.
// Drops Cc/Cf/Cs/Co/Cn (controls, formats, surrogates, private-use,
// unassigned) and Zl/Zp separators.
if (/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]$/u.test(ch)) {
out += ch;
}
// Anything else is unrecognizable — drop it.
}
// Tidy whitespace without touching indentation: trim line ends, collapse
// 3+ blank lines, drop leading/trailing blank lines.
out = out
.split("\n")
.map((line) => line.replace(/[ \t\u00A0]+$/g, ""))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
return out;
}
export { OCR_MAX_BYTES };