Add public agent profiles and community moderation

This commit is contained in:
JB
2026-07-27 15:38:27 -04:00
parent efb0ccdc7e
commit 6b76110b76
28 changed files with 2101 additions and 64 deletions
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
type AgentSummary = { id: string; displayName: string; avatarUrl: string };
type CommunityComment = {
id: string;
bodyHtml: string;
status: 'visible' | 'removed';
createdAt: string;
updatedAt: string;
moderationReason: string | null;
targetType: string;
targetTitle: string;
targetHref: string;
author: AgentSummary;
};
type CommunityBio = {
id: string;
bodyHtml: string;
status: 'visible' | 'hidden';
updatedAt: string;
moderationReason: string | null;
agent: AgentSummary;
};
type CommunityItem = CommunityComment | CommunityBio;
type ModerationAction = 'removeComment' | 'restoreComment' | 'hideBio' | 'restoreBio';
type PageData = {
tab: 'comments' | 'bios';
status: string;
search: string;
summary: {
comments: { visible: number; removed: number };
bios: { visible: number; hidden: number };
};
items: CommunityItem[];
pagination: any;
permissions: { comments: boolean; profiles: boolean };
};
let { data, form }: { data: PageData; form?: Record<string, any> | null } = $props();
let reviewing = $state<CommunityItem | null>(null);
let moderationAction = $state<ModerationAction | null>(null);
let moderationReason = $state('');
let confirming = $state(false);
let handledResultToken = $state('');
const canModerateComments = $derived(data.permissions.comments);
const canModerateProfiles = $derived(data.permissions.profiles);
const isComment = (item: CommunityItem): item is CommunityComment => 'author' in item;
const itemAgent = (item: CommunityItem) => isComment(item) ? item.author : item.agent;
const itemTimestamp = (item: CommunityItem) => isComment(item) ? item.createdAt : item.updatedAt;
const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf())
? 'DATE UNKNOWN'
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed);
};
const actionRoute = $derived(
moderationAction === 'removeComment' ? '?/removeComment'
: moderationAction === 'restoreComment' ? '?/restoreComment'
: moderationAction === 'hideBio' ? '?/hideBio'
: '?/restoreBio'
);
const actionLabel = $derived(
moderationAction === 'removeComment' ? 'REMOVE COMMENT'
: moderationAction === 'restoreComment' ? 'RESTORE COMMENT'
: moderationAction === 'hideBio' ? 'HIDE PROFILE BIO'
: 'RESTORE PROFILE BIO'
);
const actionConsequence = $derived(
moderationAction === 'removeComment'
? 'The comment will no longer appear on its public target or the author’s public activity.'
: moderationAction === 'restoreComment'
? 'The comment will return to its public target and the author’s public activity.'
: moderationAction === 'hideBio'
? 'The current bio will be hidden from the Agent’s public profile.'
: 'The bio will return to the Agent’s public profile.'
);
const statusItems = $derived(
data.tab === 'comments'
? [
{ value: 'all', label: 'ALL', count: data.summary.comments.visible + data.summary.comments.removed, description: 'All public and moderated comments' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.comments.visible, description: 'Comments currently visible to the community' },
{ value: 'removed', label: 'REMOVED', count: data.summary.comments.removed, description: 'Comments removed by Oversight' }
]
: [
{ value: 'all', label: 'ALL', count: data.summary.bios.visible + data.summary.bios.hidden, description: 'All public and moderated bios' },
{ value: 'visible', label: 'VISIBLE', count: data.summary.bios.visible, description: 'Bios currently visible on Agent profiles' },
{ value: 'hidden', label: 'HIDDEN', count: data.summary.bios.hidden, description: 'Bios hidden by Oversight' }
]
);
function openModeration(item: CommunityItem) {
reviewing = item;
moderationAction = isComment(item)
? item.status === 'removed' ? 'restoreComment' : 'removeComment'
: item.status === 'hidden' ? 'restoreBio' : 'hideBio';
moderationReason = '';
confirming = false;
}
function closeModeration() {
reviewing = null;
moderationAction = null;
moderationReason = '';
confirming = false;
}
$effect(() => {
const token = String(form?.communityResultToken ?? '');
if (!token || token === handledResultToken) return;
handledResultToken = token;
if (form?.communitySaved) closeModeration();
});
</script>
<svelte:head><title>Community Oversight — ISAC</title></svelte:head>
<svelte:window onkeydown={(event) => {
if (event.key === 'Escape' && reviewing) closeModeration();
}}/>
<div class="module-toolbar moderation-toolbar">
<div>
<p class="eyebrow">COMMUNITY OVERSIGHT / PUBLIC CONTENT CONTROL</p>
<h2>COMMUNITY OVERSIGHT</h2>
<p>Review public discussion and Agent bios. Moderation preserves evidence, requires a reason, and remains reversible.</p>
</div>
<div class="queue-readout">
<span>{data.tab === 'comments' ? 'VISIBLE COMMENTS' : 'VISIBLE BIOS'}</span>
<strong>{data.tab === 'comments' ? data.summary.comments.visible : data.summary.bios.visible}</strong>
<small>PUBLIC RECORDS</small>
</div>
</div>
<nav class="community-tabs" aria-label="Community oversight channels">
{#if canModerateComments}
<a class:active={data.tab === 'comments'} aria-current={data.tab === 'comments' ? 'page' : undefined} href="/isac/community?tab=comments&status=all">
<span>COMMENTS</span><b>{data.summary.comments.visible + data.summary.comments.removed}</b>
</a>
{/if}
{#if canModerateProfiles}
<a class:active={data.tab === 'bios'} aria-current={data.tab === 'bios' ? 'page' : undefined} href="/isac/community?tab=bios&status=all">
<span>PROFILE BIOS</span><b>{data.summary.bios.visible + data.summary.bios.hidden}</b>
</a>
{/if}
</nav>
<KpiFilters parameter="status" active={data.status} items={statusItems}/>
<form class="audit-filter community-filter">
<input type="hidden" name="tab" value={data.tab}/>
<input type="hidden" name="status" value={data.status}/>
<input name="search" type="search" value={data.search} placeholder={data.tab === 'comments' ? 'AUTHOR, TARGET, OR COMMENT TEXT' : 'AGENT OR BIO TEXT'}/>
<button class="secondary">SEARCH COMMUNITY →</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="community-registry">
{#each data.items as item}
<article class:moderated={item.status === 'removed' || item.status === 'hidden'}>
<header>
<AgentIdentityLink agent={itemAgent(item)} meta={isComment(item) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<div class="community-state">
<span>{date(itemTimestamp(item))}</span>
<b class:visible={item.status === 'visible'}>{item.status.toUpperCase()}</b>
</div>
</header>
{#if isComment(item)}
<div class="community-target">
<span>{item.targetType.toUpperCase()}</span>
<a href={item.targetHref}>{item.targetTitle} →</a>
</div>
{:else}
<div class="community-target"><span>PUBLIC PROFILE BIO</span><a href={`/agents/${encodeURIComponent(item.agent.id)}`}>VIEW AGENT PROFILE →</a></div>
{/if}
<div class="community-markdown-preview">
<MarkdownContent html={item.bodyHtml} compact emptyMessage={isComment(item) ? 'Comment body unavailable.' : 'No bio content.'}/>
</div>
{#if item.moderationReason}
<div class="moderation-evidence"><span>LAST OVERSIGHT REASON</span><p>{item.moderationReason}</p></div>
{/if}
<footer>
<span>{isComment(item) ? `COMMENT ${item.id.slice(0,8).toUpperCase()}` : `AGENT ${item.id.slice(0,8).toUpperCase()}`}</span>
{#if (isComment(item) && canModerateComments) || (!isComment(item) && canModerateProfiles)}
<button class={item.status === 'visible' ? 'danger' : 'secondary'} onclick={() => openModeration(item)}>
{item.status === 'visible' ? isComment(item) ? 'REMOVE COMMENT →' : 'HIDE BIO →' : isComment(item) ? 'RESTORE COMMENT →' : 'RESTORE BIO →'}
</button>
{/if}
</footer>
</article>
{:else}
<div class="empty-dashboard">
<strong>NO MATCHING {data.tab === 'comments' ? 'COMMENTS' : 'PROFILE BIOS'}</strong>
<span>Adjust the status or search filters to inspect another part of the community record.</span>
</div>
{/each}
</div>
{#if reviewing && moderationAction}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal community-review-modal" role="dialog" aria-modal="true" aria-labelledby="community-review-title">
<div class="editor-title">
<div><p class="eyebrow">COMMUNITY CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="community-review-title">{actionLabel}</h3></div>
<button aria-label="Close community moderation review" onclick={closeModeration}>×</button>
</div>
<div class="community-review-identity">
<AgentIdentityLink agent={itemAgent(reviewing)} meta={isComment(reviewing) ? 'COMMENT AUTHOR' : 'PROFILE OWNER'} />
<span>{date(itemTimestamp(reviewing))}</span>
</div>
{#if isComment(reviewing)}
<div class="community-target review-target"><span>{reviewing.targetType.toUpperCase()}</span><a href={reviewing.targetHref}>{reviewing.targetTitle} →</a></div>
{/if}
<div class="community-review-content"><MarkdownContent html={reviewing.bodyHtml} emptyMessage="No content is available for review."/></div>
{#if reviewing.moderationReason}<div class="moderation-evidence"><span>PREVIOUS OVERSIGHT REASON</span><p>{reviewing.moderationReason}</p></div>{/if}
{#if !confirming}
<label class="field moderation-reason">
OVERSIGHT REASON — REQUIRED
<textarea bind:value={moderationReason} maxlength="500" required placeholder="Record a specific policy, safety, or restoration reason…"></textarea>
</label>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="moderation-warning" class:rejecting={moderationAction === 'removeComment' || moderationAction === 'hideBio'}>
<b>!</b><span>{actionConsequence} The original content and this reason remain available to authorized Oversight.</span>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick={closeModeration}>CANCEL</button>
<button type="button" class="primary" disabled={!moderationReason.trim()} onclick={() => confirming = true}>REVIEW ACTION →</button>
</div>
{:else}
{@const reviewingId = reviewing.id}
<div class="community-final-confirm" role="alert">
<div><span>FINAL CONFIRMATION</span><strong>{actionLabel}?</strong><p>{actionConsequence}</p></div>
<dl><dt>OVERSIGHT REASON</dt><dd>{moderationReason}</dd></dl>
</div>
<PendingForm action={actionRoute}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewingId}/>
<input type="hidden" name="reason" value={moderationReason}/>
{#if form?.communityError}<p class="error">{form.communityError}</p>{/if}
<div class="form-actions">
<button type="button" class="secondary" disabled={pending} onclick={() => confirming = false}>BACK</button>
<button class={moderationAction === 'removeComment' || moderationAction === 'hideBio' ? 'danger' : 'primary'} disabled={pending} aria-busy={pending}>
{pending ? 'PROCESSING…' : `CONFIRM ${actionLabel} →`}
</button>
</div>
{/snippet}
</PendingForm>
{/if}
</div>
</div>
{/if}
<style>
.community-tabs{height:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0 0 8px;border:1px solid var(--shd-border);background:#0d1011}
.community-tabs a{min-height:46px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;padding:8px 11px;border-left:1px solid var(--shd-border);border-bottom:2px solid transparent;color:var(--shd-text-muted)}.community-tabs a:first-child{border-left:0}.community-tabs a span,.community-tabs a b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-tabs a b{color:var(--shd-text-dim)}.community-tabs a:hover,.community-tabs a:focus-visible{background:var(--shd-surface-active);color:var(--shd-text)}.community-tabs a.active{border-bottom-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}.community-tabs a.active b{color:var(--shd-orange)}
.community-filter{grid-template-columns:minmax(0,1fr) auto}.community-registry{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:7px}.community-registry>article{min-width:0;display:grid;grid-template-rows:auto auto minmax(100px,1fr) auto;border:1px solid var(--shd-border);border-left:2px solid var(--shd-info);background:var(--shd-surface)}.community-registry>article.moderated{border-left-color:var(--shd-danger)}
.community-registry article>header{min-height:55px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>header :global(.agent-identity){flex:1}.community-state{display:grid;justify-items:end;gap:4px}.community-state span,.community-state b{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-state span{color:var(--shd-text-muted)}.community-state b{padding:3px 5px;border:1px solid var(--shd-danger);color:var(--shd-danger)}.community-state b.visible{border-color:var(--shd-success);color:var(--shd-success)}
.community-target{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 10px;border-bottom:1px solid var(--shd-border-soft);background:#0d1011}.community-target span,.community-target a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-target span{color:var(--shd-text-muted)}.community-target a{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.community-markdown-preview{max-height:150px;overflow:hidden;padding:11px;position:relative}.community-markdown-preview:after{position:absolute;right:0;bottom:0;left:0;height:25px;background:linear-gradient(transparent,var(--shd-surface));content:"";pointer-events:none}
.moderation-evidence{display:grid;gap:4px;margin:0 10px 9px;padding:8px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-danger);background:#1b1111}.moderation-evidence span{color:var(--shd-danger);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.moderation-evidence p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}
.community-registry article>footer{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;border-top:1px solid var(--shd-border);background:var(--shd-surface-raised)}.community-registry article>footer>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.community-registry article>footer button{min-height:36px;padding:0 10px}
.community-review-modal{width:min(900px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.community-review-identity{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px;border-bottom:1px solid var(--shd-border)}.community-review-identity :global(.agent-identity){flex:1}.community-review-identity>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.review-target{border-bottom:1px solid var(--shd-border)}.community-review-content{max-height:330px;overflow:auto;padding:14px;border-bottom:1px solid var(--shd-border);background:#0d1011}.community-review-content :global(.markdown-content){max-width:none}.moderation-reason{margin:10px}.moderation-reason textarea{min-height:90px}.community-final-confirm{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.65fr);gap:9px;margin:10px;padding:10px;border:1px solid var(--shd-orange);background:#1b160d}.community-final-confirm>div{display:grid;gap:4px}.community-final-confirm span,.community-final-confirm dt{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.community-final-confirm p,.community-final-confirm dd{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.4}.community-final-confirm dl{display:grid;align-content:start;gap:5px;margin:0;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.community-review-modal>.moderation-warning,.community-review-modal>.form-actions,.community-review-modal :global(form > .form-actions),.community-review-modal :global(form > .error){margin-right:10px;margin-left:10px}
@media(max-width:900px){.community-registry{grid-template-columns:1fr}}
@media(max-width:650px){.community-tabs{grid-template-columns:1fr}.community-tabs a{min-height:52px;border-top:1px solid var(--shd-border);border-left:0}.community-tabs a:first-child{border-top:0}.community-filter{grid-template-columns:1fr}.community-filter button{min-height:44px}.community-registry article>header,.community-registry article>footer,.community-review-identity,.community-target{align-items:stretch;flex-direction:column}.community-state{justify-items:start}.community-target a{white-space:normal}.community-registry article>footer button{width:100%;min-height:44px}.community-final-confirm{grid-template-columns:1fr}.community-review-modal>.form-actions,.community-review-modal :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.community-review-modal button{min-height:44px}}
@media(prefers-reduced-motion:reduce){.community-tabs a,.community-registry button{transition:none}}
</style>