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
167 lines
9.1 KiB
TypeScript
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>;
|
|
}
|