fixes
This commit is contained in:
+17
-4
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user