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
+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 };