jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

167 lines
9.1 KiB
TypeScript

/** Accessible multimodal UI. It emits intents but never fetches, uploads, or opens devices. */
import { Camera, FileText, Image as ImageIcon, Mic2, MonitorUp, Pencil, Upload } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import {
buildAnnotation, buildCaptureIntent, buildImageEdit, buildTranscriptCorrection,
captureAuthorization, multimodalEnabled, normalizeMultimodalPage,
} from "./model.ts";
import { validateUpload } from "./security.ts";
import type { FileCandidate, MediaView, MultimodalProps } from "./types.ts";
function MediaPreview({item}: {item: MediaView}) {
if (item.kind === "image" && item.previewUrl) return <figure className="hux-mm-image">
<img src={item.previewUrl} alt={item.altText} />
<figcaption>{item.fileName} · version {item.artifactVersion}</figcaption>
</figure>;
if (item.kind === "document" && item.previewText) return <div className="hux-mm-document">
<strong>{item.fileName}</strong>
<pre tabIndex={0}><code>{item.previewText}</code></pre>
<small>Safe text preview; active document content is not executed.</small>
</div>;
const Icon = item.kind === "image" ? ImageIcon : item.kind === "audio" ? Mic2 : FileText;
return <div className="hux-mm-safe-preview" role="img" aria-label={`${item.kind} preview unavailable`}>
<Icon aria-hidden />
<strong>{item.fileName}</strong>
<span>{item.mime} · {Math.ceil(item.bytes / 1024)} KB</span>
<small>Content is not executed. Open an authorized artifact preview to inspect it.</small>
</div>;
}
function UploadPanel({props, authorization, setNotice}: {
props: MultimodalProps;
authorization: MultimodalProps["page"]["upload_authorization"];
setNotice: (message: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const authorized = authorization === "authorized";
function handle(files: FileList | readonly File[]) {
const accepted: FileCandidate[] = [];
const rejected: string[] = [];
for (const file of Array.from(files).slice(0, 20)) {
const result = validateUpload(file, String(authorization));
if (result.ok) accepted.push(result.file);
else rejected.push(`${file.name || "File"}: ${result.reason}`);
}
if (accepted.length) props.onUpload?.(accepted);
setNotice(rejected.length ? rejected.join(" ") : `${accepted.length} file${accepted.length === 1 ? "" : "s"} ready to upload.`);
}
return <section className="hux-mm-upload" aria-labelledby="hux-mm-upload-title"
onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault(); handle(event.dataTransfer.files);
}}>
<Upload aria-hidden />
<h3 id="hux-mm-upload-title">Add files or images</h3>
<p>Drop authorized PNG, JPEG, WebP, PDF, text, Markdown, or audio files here.</p>
<input ref={input} className="sr-only" type="file" multiple
accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/markdown,audio/webm,audio/wav,audio/mpeg,audio/ogg"
onChange={(event) => event.currentTarget.files && handle(event.currentTarget.files)} />
{authorized ? <button type="button" disabled={props.busy} onClick={() => input.current?.click()}>
Choose files
</button> : <button type="button" disabled={props.busy || authorization === "denied"}
onClick={() => props.onRequestAuthorization?.("upload")}>
{authorization === "denied" ? "Uploads denied" : "Ask before uploading"}
</button>}
</section>;
}
function MediaEditor({item, props}: {item: MediaView; props: MultimodalProps}) {
const [draft, setDraft] = useState("");
const [page, setPage] = useState("");
if (item.kind === "audio") return null;
const image = item.kind === "image";
return <form className="hux-mm-editor" onSubmit={(event) => {
event.preventDefault();
if (image) props.onImageEdit?.(buildImageEdit(item, props.scope, draft));
else props.onAnnotate?.(buildAnnotation(item, props.scope, draft, page ? Number(page) : null));
setDraft(""); setPage("");
}}>
<label htmlFor={`hux-mm-edit-${item.id}`}>{image ? "Describe an image edit" : "Add a document annotation"}</label>
<textarea id={`hux-mm-edit-${item.id}`} value={draft} maxLength={image ? 2000 : 1000}
onChange={(event) => setDraft(event.currentTarget.value)} required />
{!image && <label>Page (optional)<input type="number" min="1" max="100000" value={page}
onChange={(event) => setPage(event.currentTarget.value)} /></label>}
<button type="submit" disabled={props.busy || !draft.trim()}>
<Pencil aria-hidden /> {image ? "Create linked variant" : "Save annotation intent"}
</button>
{image && <small>The next version retains artifact and variant lineage.</small>}
</form>;
}
function TranscriptEditor({props, page}: {props: MultimodalProps; page: ReturnType<typeof normalizeMultimodalPage>}) {
const transcript = page?.transcript;
const [text, setText] = useState(transcript?.text || "");
if (!transcript) return <p>No voice transcript is attached to this turn.</p>;
return <form className="hux-mm-transcript" onSubmit={(event) => {
event.preventDefault();
props.onCorrectTranscript?.(buildTranscriptCorrection(transcript, text, props.identity, props.scope));
}}>
<label htmlFor="hux-mm-transcript-text">Voice transcript · {transcript.language}</label>
<textarea id="hux-mm-transcript-text" value={text} maxLength={20000}
readOnly={!transcript.finalized} onChange={(event) => setText(event.currentTarget.value)} />
<button type="submit" disabled={props.busy || !transcript.finalized || !text.trim() || text === transcript.text}>
Correct transcript
</button>
{!transcript.finalized && <small>Correction unlocks when transcription is finalized.</small>}
</form>;
}
function CaptureButton({kind, props}: {kind: "camera" | "screen"; props: MultimodalProps}) {
const decision = captureAuthorization(props.autonomyPolicy, null, props.identity);
const Icon = kind === "camera" ? Camera : MonitorUp;
return <button type="button" disabled={props.busy || decision === "deny"} onClick={() => {
props.onCapture?.(buildCaptureIntent(kind, props.autonomyPolicy, props.identity, props.scope));
}}>
<Icon aria-hidden /> {decision === "deny" ? `${kind} denied` : `Ask to use ${kind}`}
</button>;
}
/** Render nothing unless foundation, projects, artifacts, autonomy, and multimodal are active. */
export function MultimodalChat(props: MultimodalProps) {
const enabled = multimodalEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeMultimodalPage(props.page, props.identity, props.scope) : null,
[enabled, props.identity, props.page, props.scope]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [notice, setNotice] = useState("");
if (!enabled || !page) return null;
const selected = page.media.find((item) => item.id === selectedId) || page.media[0] || null;
return <section className="hux-multimodal" aria-label="Multimodal chat" aria-busy={props.busy}>
<header><div><span className="hux-mm-kicker">HUX-07</span><h2>Multimodal workspace</h2>
<p>Files, voice, and generated variants remain bound to this conversation.</p></div>
<div className="hux-mm-capture" aria-label="Live media permissions">
<CaptureButton kind="camera" props={props} /><CaptureButton kind="screen" props={props} />
</div>
</header>
{props.error && <p className="hux-mm-error" role="alert">{props.error}</p>}
{page.rejected > 0 && <p className="hux-mm-warning" role="status">
Some media was withheld because its owner, binding, type, or lineage was invalid.
</p>}
<UploadPanel props={props} authorization={page.uploadAuthorization} setNotice={setNotice} />
<div className="hux-mm-workspace">
<nav aria-label="Conversation media"><ul>{page.media.map((item) => <li key={item.id}>
<button type="button" aria-current={selected?.id === item.id ? "page" : undefined}
onClick={() => setSelectedId(item.id)}>{item.kind === "image" ? <ImageIcon aria-hidden /> :
<FileText aria-hidden />}<span>{item.fileName}</span></button>
</li>)}</ul>{!page.media.length && <p>No media is attached.</p>}</nav>
<main>{selected ? <><MediaPreview item={selected} /><MediaEditor key={selected.id} item={selected} props={props} /></> :
<p>Select media to preview or annotate it.</p>}</main>
<aside aria-label="Generation history"><h3>Variant history</h3>
{page.threads.map((thread) => <section key={thread.id}><h4>{thread.prompt}</h4><ol>
{thread.variants.map((variant, index) => <li key={variant.id}>
Variant {index + 1} · artifact v{variant.artifactVersion}
{variant.parentVariantId && <small>Linked to an earlier variant</small>}
</li>)}</ol></section>)}
{!page.threads.length && <p>No generated variants yet.</p>}
</aside>
</div>
<TranscriptEditor key={`${page.transcript?.id || "none"}:${page.transcript?.revision || 0}`}
props={props} page={page} />
<p className="sr-only" role="status" aria-live="polite">{notice ||
`${page.media.length} media items and ${page.threads.length} generation threads available`}</p>
</section>;
}