Preserve conversation excerpts and deduplicate case evidence

This commit is contained in:
2026-10-17 19:35:52 -04:00
parent 83f1c91877
commit ef96a2c308
13 changed files with 1222 additions and 50 deletions
+137 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import { canModerate, canReadModeration } from '$lib/auth.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
@@ -10,6 +10,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
let {
server,
@@ -40,6 +41,41 @@
let follow = $state(true);
let paused = $state(false);
let controller: AbortController | null = null;
const selectableMessages = $derived(
history !== undefined &&
canModerate($page.data?.user?.role) &&
(moderationGame === 't5' || moderationGame === 't6')
? entries.filter(
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
)
: []
);
let selectedMessageIds = $state<string[]>([]);
let evidenceOpen = $state(false);
let attachedCaseId = $state('');
const selectedMatchId = $derived(
selectableMessages.find(
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
)?.matchId
);
const selectionCandidates = $derived(
selectableMessages.filter(
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
)
);
$effect(() => {
void history;
selectedMessageIds = [];
evidenceOpen = false;
attachedCaseId = '';
});
function toggleEvidence(messageId: string) {
selectedMessageIds = selectedMessageIds.includes(messageId)
? selectedMessageIds.filter((id) => id !== messageId)
: [...selectedMessageIds, messageId];
}
async function refresh(manual = false) {
if (!server || hostOffline) return;
@@ -175,6 +211,36 @@
</div>
{/if}
</div>
{#if selectableMessages.length}
<div class="evidence-toolbar" aria-label="Chat evidence selection">
<span>{selectedMessageIds.length} selected</span>
<Button
size="sm"
onclick={() =>
(selectedMessageIds = selectionCandidates
.slice(0, 100)
.map((entry) => entry.messageId!))}
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
>
<Button
size="sm"
disabled={!selectedMessageIds.length}
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
>
<Button
size="sm"
variant="primary"
disabled={!selectedMessageIds.length}
onclick={() => (evidenceOpen = true)}>Attach to case</Button
>
{#if selectedMessageIds.length >= 100}<span
>Attach up to 100 selected messages at a time.</span
>{/if}
</div>
{/if}
{#if attachedCaseId}<p class="evidence-notice" role="status">
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
</p>{/if}
{#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.'
@@ -236,7 +302,11 @@
{@render matchBoundary(item.entries[item.entries.length - 1])}
</li>
{:else}
<li class="message">
<li
class="message"
class:selected={!!item.entry.messageId &&
selectedMessageIds.includes(item.entry.messageId)}
>
<span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime
@@ -246,6 +316,27 @@
</span>
<div class="message-content">
<div class="message-head">
{#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
<label class="evidence-select">
<input
type="checkbox"
checked={selectedMessageIds.includes(
item.entry.messageId
)}
disabled={!selectedMessageIds.includes(
item.entry.messageId
) &&
(selectedMessageIds.length >= 100 ||
(!!selectedMatchId &&
selectedMatchId !==
item.entry.matchId))}
onchange={() =>
toggleEvidence(item.entry.messageId!)}
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
/>
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
@@ -271,6 +362,20 @@
{/if}
</section>
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
<MatchEvidenceDialog
bind:open={evidenceOpen}
game={moderationGame}
matchId={selectedMatchId}
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
{selectedMessageIds}
onsubmitted={(id) => {
attachedCaseId = id;
selectedMessageIds = [];
}}
/>
{/if}
<style>
section {
container-type: inline-size;
@@ -283,6 +388,31 @@
gap: var(--space-3);
flex-wrap: wrap;
}
.evidence-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-notice {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-select {
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
}
.evidence-select input {
accent-color: var(--accent-primary);
}
.message.selected {
background: var(--surface-raised);
}
.chat-head {
justify-content: space-between;
}
@@ -488,5 +618,9 @@
grid-column: 1 / -1;
grid-row: 4;
}
.evidence-select {
grid-column: 1 / -1;
grid-row: 5;
}
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
} = $props();
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each}
</ol>
<style>
.excerpt {
display: grid;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
background: var(--surface-sunken);
min-width: 0;
}
li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.metadata {
margin-top: var(--space-1);
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
}
details {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ContextualChatEntry } from '$lib/game-chat.js';
import {
moderationGuid,
moderationCaseStatusLabels,
type ModerationCase,
type ModerationGame,
type ModerationOverview
} from '$lib/moderation.js';
import { conversationExcerpt } from '$ui/moderation-evidence.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import EvidenceMessages from './EvidenceMessages.svelte';
let {
open = $bindable(false),
game,
matchId,
entries,
selectedMessageIds,
onsubmitted
}: {
open?: boolean;
game: ModerationGame;
matchId: number;
entries: ContextualChatEntry[];
selectedMessageIds: string[];
onsubmitted: (id: string) => void;
} = $props();
const selected = $derived(
entries.filter(
(entry) =>
entry.type === 'message' &&
!!entry.messageId &&
selectedMessageIds.includes(entry.messageId)
)
);
const participants = $derived.by(() => {
const players = new Map<string, string>();
for (const entry of selected) {
if (entry.type === 'message')
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
}
return [...players].map(([guid, name]) => ({
value: guid,
label: `${name} · GUID ${guid}`
}));
});
let guid = $state(untrack(() => participants[0]?.value ?? ''));
let destination = $state('');
let title = $state('');
let note = $state('');
let contextBefore = $state<number | undefined>(3);
let contextAfter = $state<number | undefined>(3);
let cases = $state<ModerationCase[]>([]);
let loading = $state(false);
let loadError = $state('');
let reload = $state(0);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
const excerpt = $derived(
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
);
const highlightedMessageIds = $derived(
selected.flatMap((entry) =>
entry.type === 'message' &&
entry.messageId &&
moderationGuid(game, entry.message.guid) === guid
? [entry.messageId]
: []
)
);
$effect(() => {
const selectedGuid = guid;
const selectedGame = game;
void reload;
const controller = new AbortController();
cases = [];
destination = '';
loadError = '';
loading = true;
async function loadCases() {
try {
const loaded: ModerationCase[] = [];
let offset = 0;
while (true) {
const params = new URLSearchParams({
view: 'cases',
game: selectedGame,
guid: selectedGuid,
offset: String(offset)
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok)
throw new Error(
'Could not load existing cases. Retry before attaching evidence.'
);
const report: ModerationOverview = await response.json();
if (controller.signal.aborted) return;
loaded.push(...report.cases);
offset += report.cases.length;
if (!report.cases.length || offset >= report.total) break;
}
cases = loaded;
} catch (cause) {
if (!controller.signal.aborted)
loadError =
cause instanceof Error ? cause.message : 'Could not load existing cases.';
} finally {
if (!controller.signal.aborted) loading = false;
}
}
void loadCases();
return () => controller.abort();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || (!pending && (loading || loadError))) return;
if (!pending) {
const target = cases.find((item) => item.id === destination);
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
error = 'Choose a case subject with at least one selected message.';
return;
}
if (
![contextBefore, contextAfter].every(
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
)
) {
error = 'Choose between 0 and 20 surrounding messages on each side.';
return;
}
pending = {
...(target
? { op: 'attach-evidence', id: target.id, revision: target.revision }
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
if (response.status === 409) reload++;
throw new Error(result?.error || 'Could not attach the selected evidence.');
}
open = false;
onsubmitted(result.id);
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Attach to case"
{busy}
wide
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field
label="Case subject"
required
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
>
<Select bind:value={guid} options={participants} required />
</Field>
{#if loading}<p role="status">Loading this player's cases…</p>
{:else if loadError}<p class="ui-error" role="alert">
{loadError}
<Button size="sm" onclick={() => reload++}>Retry</Button>
</p>
{:else}
<Field
label="Destination"
hint={cases.length
? 'Review existing cases before opening another.'
: 'No existing cases for this player.'}
>
<Select
bind:value={destination}
options={[
{ value: '', label: 'Open a new case' },
...cases.map((item) => ({
value: item.id,
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
}))
]}
/>
</Field>
{#if !destination}
<Field label="Case title" required
><Input bind:value={title} maxlength={160} required /></Field
>
<Field label="Private staff note"
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
>
{/if}
{/if}
<div class="paired">
<Field label="Messages before" hint="Per selected message"
><Input
type="number"
bind:value={contextBefore}
min={0}
max={20}
step={1}
required
/></Field
>
<Field label="Messages after" hint="Per selected message"
><Input
type="number"
bind:value={contextAfter}
min={0}
max={20}
step={1}
required
/></Field
>
</div>
<p class="hint">
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry the same request to avoid duplicate evidence.
</p>{/if}
<div class="actions">
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={!uncertain && (loading || !!loadError)}
>{uncertain
? 'Retry same request'
: destination
? 'Attach evidence'
: 'Open case with evidence'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-body);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
</style>