report system

This commit is contained in:
k
2026-09-21 21:51:38 +02:00
parent 4cefd3e17f
commit aa33828215
15 changed files with 1379 additions and 75 deletions
+142
View File
@@ -0,0 +1,142 @@
import type { AccessMessage, AccessRequest } from "./api.ts";
/**
* Shared WebSocket pool for live access-request updates.
*
* One underlying socket per URL (refcounted across components, e.g. the
* AdminCenter badge and the triage list share the admin firehose).
* Auto-reconnects with backoff; every (re)connect triggers onSync so
* components resync over HTTP and never miss an event sent while offline.
* A 25s client ping keeps idle connections alive through proxies.
*/
export type AccessLiveEvent =
| { type: "access_message"; request_id: string; message: AccessMessage }
| { type: "access_status"; request_id: string; request: AccessRequest }
| { type: "access_created"; request_id: string; request: AccessRequest }
| { type: "pong" };
export interface AccessLiveListener {
onEvent: (e: AccessLiveEvent) => void;
/** Fired on every (re)connect — resync state over HTTP here. */
onSync?: () => void;
}
interface Pool {
ws: WebSocket | null;
listeners: Set<AccessLiveListener>;
backoff: number;
retryTimer: ReturnType<typeof setTimeout> | null;
pingTimer: ReturnType<typeof setInterval> | null;
}
const pools = new Map<string, Pool>();
export function wsUrl(path: string): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}${path}`;
}
function connect(url: string, pool: Pool): void {
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch {
scheduleRetry(url, pool);
return;
}
pool.ws = ws;
ws.onopen = () => {
pool.backoff = 0;
pool.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: "ping" }));
} catch {
// send failed — the close handler will schedule a retry
}
}
}, 25_000);
for (const l of [...pool.listeners]) {
try {
l.onSync?.();
} catch {
// listener bug must not break the pool
}
}
};
ws.onmessage = (ev) => {
let parsed: AccessLiveEvent | null = null;
try {
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as AccessLiveEvent;
} catch {
return;
}
if (!parsed || typeof parsed.type !== "string") return;
for (const l of [...pool.listeners]) {
try {
l.onEvent(parsed);
} catch {
// listener bug must not break the pool
}
}
};
const onDead = () => {
if (pool.ws === ws) pool.ws = null;
if (pool.pingTimer) {
clearInterval(pool.pingTimer);
pool.pingTimer = null;
}
scheduleRetry(url, pool);
};
ws.onclose = onDead;
ws.onerror = () => {
// onerror is followed by onclose — retry is scheduled there
};
}
function scheduleRetry(url: string, pool: Pool): void {
if (!pools.has(url) || pool.listeners.size === 0 || pool.retryTimer) return;
const delay = Math.min(1000 * 2 ** pool.backoff, 15_000);
pool.backoff += 1;
pool.retryTimer = setTimeout(() => {
pool.retryTimer = null;
if (!pools.has(url) || pool.listeners.size === 0) return;
connect(url, pool);
}, delay);
}
export function subscribeAccessLive(url: string, listener: AccessLiveListener): () => void {
let pool = pools.get(url);
if (!pool) {
pool = { ws: null, listeners: new Set(), backoff: 0, retryTimer: null, pingTimer: null };
pools.set(url, pool);
connect(url, pool);
}
pool.listeners.add(listener);
if (pool.ws && pool.ws.readyState === WebSocket.OPEN) {
try {
listener.onSync?.();
} catch {
// ignore
}
}
return () => {
const p = pools.get(url);
if (!p) return;
p.listeners.delete(listener);
if (p.listeners.size > 0) return;
pools.delete(url);
if (p.retryTimer) clearTimeout(p.retryTimer);
if (p.pingTimer) clearInterval(p.pingTimer);
try {
p.ws?.close();
} catch {
// already gone — fine
}
p.ws = null;
};
}
+44
View File
@@ -21,6 +21,27 @@ export interface AccessMessage {
created_at: number;
}
export type ReportReason = "copyright" | "gore" | "falseinfo" | "bug";
export type ReportStatus = "open" | "reviewing" | "resolved" | "dismissed";
export interface Report {
id: string;
reporter_id: string;
reporter_name: string;
conversation_id: string;
message_index: number;
content: string;
prompt: string;
model: string;
reason: ReportReason;
details: string;
status: ReportStatus;
admin_note: string;
reporter_shadowbanned: number;
created_at: number;
updated_at: number;
}
const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null {
@@ -184,6 +205,29 @@ export const api = {
method: "POST",
body: JSON.stringify({ body }),
}),
/** Content reports (flagged AI responses). */
createReport: (r: {
conversationId?: string;
messageIndex?: number;
reason: ReportReason;
details?: string;
content?: string;
prompt?: string;
model?: string;
}) =>
req<{ report: Report }>("/api/reports", {
method: "POST",
body: JSON.stringify(r),
}),
/** Admin: report triage. */
adminReportList: () => req<{ reports: Report[] }>("/api/admin/reports"),
adminReportPatch: (id: string, patch: { status: ReportStatus; adminNote?: string }) =>
req<{ report: Report }>(`/api/admin/reports/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
ollamaPull: async (
name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
+266
View File
@@ -422,6 +422,52 @@ const en = {
"access.sendFailed": "Send failed",
"access.updateFailed": "Update failed",
"msg.copy": "Copy message",
"msg.search": "Search the web",
"msg.report": "Report response",
"report.title": "Report this response",
"report.reason": "Reason",
"report.reasonPh": "Select a reason…",
"report.reason.copyright": "Copyright",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "False information",
"report.reason.bug": "Bug",
"report.details": "Details (optional)",
"report.detailsPh": "What is wrong with this response? (optional)",
"report.submit": "Send report",
"report.sent": "Report sent — an admin will review it.",
"report.failed": "Could not send the report",
"center.tabReports": "Reports",
"reports.filter.all": "all",
"reports.filter.open": "open",
"reports.filter.reviewing": "reviewing",
"reports.filter.resolved": "resolved",
"reports.filter.dismissed": "dismissed",
"reports.status.open": "Open",
"reports.status.reviewing": "Reviewing",
"reports.status.resolved": "Resolved",
"reports.status.dismissed": "Dismissed",
"reports.refresh": "Refresh",
"reports.refreshAria": "Refresh reports",
"reports.empty": "No content reports yet.",
"reports.emptyFiltered": "Nothing with this status.",
"reports.response": "Reported response",
"reports.noContent": "No content captured.",
"reports.model": "Model",
"reports.reporter": "Reporter",
"reports.conversation": "Conversation",
"reports.detailsGiven": "Reporter details",
"reports.noteLabel": "Admin note",
"reports.notePh": "Internal note…",
"reports.noteWord": "Note",
"reports.btnReviewing": "Reviewing",
"reports.btnResolve": "Resolve",
"reports.btnDismiss": "Dismiss",
"reports.updateFailed": "Update failed",
"reports.closedNote": "Report {status}.",
"review.title": "Access request",
"review.loadingTicket": "Loading ticket…",
"review.notFound": "Ticket not found",
@@ -872,6 +918,50 @@ const es: Dict = {
"access.sendFailed": "No se pudo enviar",
"access.updateFailed": "No se pudo actualizar",
"msg.copy": "Copiar mensaje",
"msg.search": "Buscar en la web",
"msg.report": "Denunciar respuesta",
"report.title": "Denunciar esta respuesta",
"report.reason": "Motivo",
"report.reasonPh": "Selecciona un motivo…",
"report.reason.copyright": "Derechos de autor",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Información falsa",
"report.reason.bug": "Error",
"report.details": "Detalles (opcional)",
"report.detailsPh": "¿Qué tiene de malo esta respuesta? (opcional)",
"report.submit": "Enviar denuncia",
"report.sent": "Denuncia enviada — un administrador la revisará.",
"report.failed": "No se pudo enviar la denuncia",
"center.tabReports": "Denuncias",
"reports.filter.all": "todas",
"reports.filter.open": "abiertas",
"reports.filter.reviewing": "en revisión",
"reports.filter.resolved": "resueltas",
"reports.filter.dismissed": "descartadas",
"reports.status.open": "Abierta",
"reports.status.reviewing": "En revisión",
"reports.status.resolved": "Resuelta",
"reports.status.dismissed": "Descartada",
"reports.refresh": "Actualizar",
"reports.refreshAria": "Actualizar denuncias",
"reports.empty": "Aún no hay denuncias de contenido.",
"reports.emptyFiltered": "Nada con este estado.",
"reports.response": "Respuesta denunciada",
"reports.noContent": "Sin contenido capturado.",
"reports.model": "Modelo",
"reports.reporter": "Denunciante",
"reports.conversation": "Conversación",
"reports.detailsGiven": "Detalles del denunciante",
"reports.noteLabel": "Nota del administrador",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "En revisión",
"reports.btnResolve": "Resolver",
"reports.btnDismiss": "Descartar",
"reports.updateFailed": "No se pudo actualizar",
"reports.closedNote": "Denuncia {status}.",
"review.title": "Solicitud de acceso",
"review.loadingTicket": "Cargando ticket…",
"review.notFound": "Ticket no encontrado",
@@ -1319,6 +1409,50 @@ const fr: Dict = {
"access.sendFailed": "Envoi impossible",
"access.updateFailed": "Mise à jour impossible",
"msg.copy": "Copier le message",
"msg.search": "Rechercher sur le web",
"msg.report": "Signaler la réponse",
"report.title": "Signaler cette réponse",
"report.reason": "Motif",
"report.reasonPh": "Choisir un motif…",
"report.reason.copyright": "Droit d'auteur",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Fausse information",
"report.reason.bug": "Bug",
"report.details": "Détails (facultatif)",
"report.detailsPh": "Qu'est-ce qui ne va pas avec cette réponse ? (facultatif)",
"report.submit": "Envoyer le signalement",
"report.sent": "Signalement envoyé — un admin va l'examiner.",
"report.failed": "Envoi du signalement impossible",
"center.tabReports": "Signalements",
"reports.filter.all": "tous",
"reports.filter.open": "ouverts",
"reports.filter.reviewing": "en cours",
"reports.filter.resolved": "résolus",
"reports.filter.dismissed": "rejetés",
"reports.status.open": "Ouvert",
"reports.status.reviewing": "En cours",
"reports.status.resolved": "Résolu",
"reports.status.dismissed": "Rejeté",
"reports.refresh": "Actualiser",
"reports.refreshAria": "Actualiser les signalements",
"reports.empty": "Aucun signalement pour l'instant.",
"reports.emptyFiltered": "Rien avec ce statut.",
"reports.response": "Réponse signalée",
"reports.noContent": "Aucun contenu capturé.",
"reports.model": "Modèle",
"reports.reporter": "Auteur",
"reports.conversation": "Conversation",
"reports.detailsGiven": "Détails de l'auteur",
"reports.noteLabel": "Note admin",
"reports.notePh": "Note interne…",
"reports.noteWord": "Note",
"reports.btnReviewing": "En cours",
"reports.btnResolve": "Résoudre",
"reports.btnDismiss": "Rejeter",
"reports.updateFailed": "Mise à jour impossible",
"reports.closedNote": "Signalement {status}.",
"review.title": "Demande d'accès",
"review.loadingTicket": "Chargement du ticket…",
"review.notFound": "Ticket introuvable",
@@ -1754,6 +1888,50 @@ const ru: Dict = {
"access.sendFailed": "Не отправилось",
"access.updateFailed": "Не обновилось",
"msg.copy": "Скопировать сообщение",
"msg.search": "Искать в интернете",
"msg.report": "Пожаловаться на ответ",
"report.title": "Пожаловаться на этот ответ",
"report.reason": "Причина",
"report.reasonPh": "Выберите причину…",
"report.reason.copyright": "Авторские права",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Ложная информация",
"report.reason.bug": "Ошибка",
"report.details": "Подробности (необязательно)",
"report.detailsPh": "Что не так с этим ответом? (необязательно)",
"report.submit": "Отправить жалобу",
"report.sent": "Жалоба отправлена — администратор её рассмотрит.",
"report.failed": "Не удалось отправить жалобу",
"center.tabReports": "Жалобы",
"reports.filter.all": "все",
"reports.filter.open": "открытые",
"reports.filter.reviewing": "на рассмотрении",
"reports.filter.resolved": "решённые",
"reports.filter.dismissed": "отклонённые",
"reports.status.open": "Открыта",
"reports.status.reviewing": "На рассмотрении",
"reports.status.resolved": "Решена",
"reports.status.dismissed": "Отклонена",
"reports.refresh": "Обновить",
"reports.refreshAria": "Обновить жалобы",
"reports.empty": "Жалоб на контент пока нет.",
"reports.emptyFiltered": "Ничего с этим статусом.",
"reports.response": "Ответ",
"reports.noContent": "Содержимое отсутствует.",
"reports.model": "Модель",
"reports.reporter": "Автор жалобы",
"reports.conversation": "Беседа",
"reports.detailsGiven": "Комментарий автора",
"reports.noteLabel": "Заметка админа",
"reports.notePh": "Внутренняя заметка…",
"reports.noteWord": "Заметка",
"reports.btnReviewing": "На рассмотрение",
"reports.btnResolve": "Решить",
"reports.btnDismiss": "Отклонить",
"reports.updateFailed": "Не обновилось",
"reports.closedNote": "Жалоба {status}.",
"review.title": "Заявка на доступ",
"review.loadingTicket": "Загружаем тикет…",
"review.notFound": "Тикет не найден",
@@ -2194,6 +2372,50 @@ const it: Dict = {
"access.sendFailed": "Invio fallito",
"access.updateFailed": "Aggiornamento fallito",
"msg.copy": "Copia messaggio",
"msg.search": "Cerca nel web",
"msg.report": "Segnala risposta",
"report.title": "Segnala questa risposta",
"report.reason": "Motivo",
"report.reasonPh": "Seleziona un motivo…",
"report.reason.copyright": "Diritto d'autore",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Informazione falsa",
"report.reason.bug": "Bug",
"report.details": "Dettagli (facoltativo)",
"report.detailsPh": "Cosa c'è di sbagliato in questa risposta? (facoltativo)",
"report.submit": "Invia segnalazione",
"report.sent": "Segnalazione inviata — un amministratore la esaminerà.",
"report.failed": "Impossibile inviare la segnalazione",
"center.tabReports": "Segnalazioni",
"reports.filter.all": "tutte",
"reports.filter.open": "aperte",
"reports.filter.reviewing": "in revisione",
"reports.filter.resolved": "risolte",
"reports.filter.dismissed": "respinte",
"reports.status.open": "Aperta",
"reports.status.reviewing": "In revisione",
"reports.status.resolved": "Risolta",
"reports.status.dismissed": "Respinta",
"reports.refresh": "Aggiorna",
"reports.refreshAria": "Aggiorna segnalazioni",
"reports.empty": "Nessuna segnalazione ancora.",
"reports.emptyFiltered": "Niente con questo stato.",
"reports.response": "Risposta segnalata",
"reports.noContent": "Nessun contenuto acquisito.",
"reports.model": "Modello",
"reports.reporter": "Autore",
"reports.conversation": "Conversazione",
"reports.detailsGiven": "Dettagli dell'autore",
"reports.noteLabel": "Nota admin",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "In revisione",
"reports.btnResolve": "Risolvi",
"reports.btnDismiss": "Respingi",
"reports.updateFailed": "Aggiornamento fallito",
"reports.closedNote": "Segnalazione {status}.",
"review.title": "Richiesta di accesso",
"review.loadingTicket": "Carico ticket…",
"review.notFound": "Ticket non trovato",
@@ -2625,6 +2847,50 @@ const pt: Dict = {
"access.sendFailed": "Falha ao enviar",
"access.updateFailed": "Falha ao atualizar",
"msg.copy": "Copiar mensagem",
"msg.search": "Pesquisar na web",
"msg.report": "Denunciar resposta",
"report.title": "Denunciar esta resposta",
"report.reason": "Motivo",
"report.reasonPh": "Selecione um motivo…",
"report.reason.copyright": "Direitos autorais",
"report.reason.gore": "Gore / CSAM / NSFW",
"report.reason.falseinfo": "Informação falsa",
"report.reason.bug": "Bug",
"report.details": "Detalhes (opcional)",
"report.detailsPh": "O que há de errado nesta resposta? (opcional)",
"report.submit": "Enviar denúncia",
"report.sent": "Denúncia enviada — um administrador irá analisá-la.",
"report.failed": "Não foi possível enviar a denúncia",
"center.tabReports": "Denúncias",
"reports.filter.all": "todas",
"reports.filter.open": "abertas",
"reports.filter.reviewing": "em análise",
"reports.filter.resolved": "resolvidas",
"reports.filter.dismissed": "rejeitadas",
"reports.status.open": "Aberta",
"reports.status.reviewing": "Em análise",
"reports.status.resolved": "Resolvida",
"reports.status.dismissed": "Rejeitada",
"reports.refresh": "Atualizar",
"reports.refreshAria": "Atualizar denúncias",
"reports.empty": "Nenhuma denúncia de conteúdo ainda.",
"reports.emptyFiltered": "Nada com este estado.",
"reports.response": "Resposta denunciada",
"reports.noContent": "Nenhum conteúdo capturado.",
"reports.model": "Modelo",
"reports.reporter": "Autor",
"reports.conversation": "Conversa",
"reports.detailsGiven": "Detalhes do autor",
"reports.noteLabel": "Nota do admin",
"reports.notePh": "Nota interna…",
"reports.noteWord": "Nota",
"reports.btnReviewing": "Em análise",
"reports.btnResolve": "Resolver",
"reports.btnDismiss": "Rejeitar",
"reports.updateFailed": "Falha ao atualizar",
"reports.closedNote": "Denúncia {status}.",
"review.title": "Pedido de acesso",
"review.loadingTicket": "A carregar ticket…",
"review.notFound": "Ticket não encontrado",