hermes(webui): track the ignored HUX-04 artifacts card models
The artifacts/ directory matched a repo-wide ignore rule and was left out of the UI card commit; its suites only passed locally because the files existed on disk. Force-track the complete card so the source push carries every module the tests import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
13359769dc
commit
b2cfbc94e8
221
dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx
Normal file
221
dockerfiles/hermes-webui-hux/artifacts/ArtifactWorkspace.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
/** Accessible HUX-04 artifact UI. This module never fetches or executes content. */
|
||||
|
||||
import {
|
||||
Code2,
|
||||
Download,
|
||||
FileText,
|
||||
GitCompare,
|
||||
History,
|
||||
Image as ImageIcon,
|
||||
Pencil,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { artifactsEnabled, normalizeArtifactWorkspace, normalizePreview } from "./model.ts";
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactAuthorization,
|
||||
ArtifactMutationIntent,
|
||||
ArtifactPreview,
|
||||
ArtifactScope,
|
||||
DiffIntent,
|
||||
HuxIdentity,
|
||||
PromoteIntent,
|
||||
RawArtifactEnvelope,
|
||||
RawPreviewPayload,
|
||||
} from "./types.ts";
|
||||
|
||||
interface ArtifactWorkspaceProps {
|
||||
flags?: Iterable<string>;
|
||||
identity: HuxIdentity;
|
||||
scope: ArtifactScope;
|
||||
page: RawArtifactEnvelope;
|
||||
previews?: readonly RawPreviewPayload[];
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onLoadPreview?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onContinueEdit?: (intent: ArtifactMutationIntent) => void;
|
||||
onDiff?: (intent: DiffIntent) => void;
|
||||
onAuthorize?: (action: "download" | "share", intent: ArtifactMutationIntent) => void;
|
||||
onDownload?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onShare?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
||||
onPromote?: (intent: PromoteIntent) => void;
|
||||
}
|
||||
|
||||
function intent(artifact: Artifact, scope: ArtifactScope): ArtifactMutationIntent {
|
||||
return {artifactId: artifact.id, expectedVersion: artifact.currentVersion, scope};
|
||||
}
|
||||
|
||||
function RendererIcon({artifact}: {artifact: Artifact}) {
|
||||
if (artifact.renderer === "image") return <ImageIcon aria-hidden />;
|
||||
if (artifact.renderer === "code" || artifact.renderer === "data") return <Code2 aria-hidden />;
|
||||
return <FileText aria-hidden />;
|
||||
}
|
||||
|
||||
function Preview({artifact, preview}: {artifact: Artifact; preview: ArtifactPreview | null}) {
|
||||
if (!preview) return <div className="hux-artifact-preview-empty">
|
||||
<RendererIcon artifact={artifact} />
|
||||
<p>Preview not loaded. Content stays behind its verified content reference.</p>
|
||||
</div>;
|
||||
if (preview.renderer === "image") {
|
||||
return <figure className="hux-artifact-image">
|
||||
<img src={preview.blobUrl!} alt={`Preview of ${artifact.title}`} />
|
||||
<figcaption>{preview.mime}; local authorized preview</figcaption>
|
||||
</figure>;
|
||||
}
|
||||
if (preview.renderer === "audio") {
|
||||
return <div className="hux-artifact-audio">
|
||||
<audio src={preview.blobUrl!} controls preload="metadata">
|
||||
Audio preview is unavailable in this browser.
|
||||
</audio>
|
||||
<span>{preview.mime}; local authorized preview</span>
|
||||
</div>;
|
||||
}
|
||||
const className = `hux-artifact-${preview.renderer}`;
|
||||
return <div className={className} aria-label={`${preview.renderer} preview`}>
|
||||
{preview.renderer === "report" && <h3>Report preview</h3>}
|
||||
<pre tabIndex={0}><code>{preview.text}</code></pre>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AuthorizationAction({action, state, busy, onAuthorize, onAction}: {
|
||||
action: "download" | "share";
|
||||
state: ArtifactAuthorization["download"];
|
||||
busy: boolean;
|
||||
onAuthorize: () => void;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
const label = action === "download" ? "Download" : "Share";
|
||||
const Icon = action === "download" ? Download : Share2;
|
||||
if (state === "authorized") return <button type="button" disabled={busy} onClick={onAction}>
|
||||
<Icon aria-hidden /> {label}
|
||||
</button>;
|
||||
if (state === "requires_approval") return <button type="button" disabled={busy} onClick={onAuthorize}>
|
||||
<Icon aria-hidden /> Request {label.toLowerCase()} approval
|
||||
</button>;
|
||||
return <button type="button" disabled aria-label={`${label} unavailable`}>
|
||||
<Icon aria-hidden /> {label} unavailable
|
||||
</button>;
|
||||
}
|
||||
|
||||
function VersionHistory({artifact, selectedVersion, setSelectedVersion, busy, scope, onDiff}: {
|
||||
artifact: Artifact;
|
||||
selectedVersion: number;
|
||||
setSelectedVersion: (version: number) => void;
|
||||
busy: boolean;
|
||||
scope: ArtifactScope;
|
||||
onDiff?: ArtifactWorkspaceProps["onDiff"];
|
||||
}) {
|
||||
return <section className="hux-artifact-history" aria-labelledby="hux-artifact-history-title">
|
||||
<h3 id="hux-artifact-history-title"><History aria-hidden /> Immutable version history</h3>
|
||||
<ol>{[...artifact.versions].reverse().map((version) => <li key={version.version}
|
||||
data-current={version.version === artifact.currentVersion}>
|
||||
<button type="button" aria-pressed={selectedVersion === version.version}
|
||||
onClick={() => setSelectedVersion(version.version)}>
|
||||
<strong>Version {version.version}</strong>
|
||||
<span>{version.note || `Created by ${version.createdBy}`}</span>
|
||||
<time dateTime={version.createdAt}>{new Date(version.createdAt).toLocaleString()}</time>
|
||||
</button>
|
||||
{version.version > 1 && onDiff && <button className="hux-artifact-diff" type="button"
|
||||
disabled={busy} onClick={() => onDiff({
|
||||
...intent(artifact, scope), fromVersion: version.diffFrom || version.version - 1,
|
||||
toVersion: version.version,
|
||||
})} aria-label={`Compare version ${version.version}`}><GitCompare aria-hidden /> Compare</button>}
|
||||
</li>)}</ol>
|
||||
</section>;
|
||||
}
|
||||
|
||||
/** Render nothing unless all negotiated HUX-04 dependencies are active. */
|
||||
export function ArtifactWorkspace(props: ArtifactWorkspaceProps) {
|
||||
const enabled = artifactsEnabled(props.flags);
|
||||
const page = useMemo(() => enabled ?
|
||||
normalizeArtifactWorkspace(props.page, props.identity, props.scope) : null,
|
||||
[enabled, props.identity, props.page, props.scope]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
if (!enabled || !page) return null;
|
||||
const artifact = page.artifacts.find((item) => item.id === selectedId) || page.artifacts[0] || null;
|
||||
const version = artifact ? Math.min(selectedVersion || artifact.currentVersion, artifact.currentVersion) : null;
|
||||
const rawPreview = artifact && version ? props.previews?.find((item) =>
|
||||
item.artifact_id === artifact.id && item.version === version) : undefined;
|
||||
const preview = artifact && rawPreview ?
|
||||
normalizePreview(rawPreview, artifact, props.identity, props.scope) : null;
|
||||
const attachments = artifact ? page.attachments.find((item) => item.artifactId === artifact.id) : null;
|
||||
const authorization = artifact ? page.authorizations.find((item) => item.artifactId === artifact.id) : null;
|
||||
|
||||
function selectArtifact(item: Artifact) {
|
||||
setSelectedId(item.id);
|
||||
setSelectedVersion(item.currentVersion);
|
||||
props.onLoadPreview?.({...intent(item, props.scope), version: item.currentVersion});
|
||||
}
|
||||
|
||||
function selectVersion(next: number) {
|
||||
if (!artifact) return;
|
||||
setSelectedVersion(next);
|
||||
props.onLoadPreview?.({...intent(artifact, props.scope), version: next});
|
||||
}
|
||||
|
||||
return <section className="hux-artifacts" aria-label="Artifact workspace" aria-busy={props.busy}>
|
||||
<nav className="hux-artifact-list" aria-label="Artifacts">
|
||||
<header><h2><Sparkles aria-hidden /> Artifacts</h2>
|
||||
<p>Durable outputs from this conversation.</p></header>
|
||||
<ul>{page.artifacts.map((item) => <li key={item.id}>
|
||||
<button type="button" aria-current={artifact?.id === item.id ? "page" : undefined}
|
||||
onClick={() => selectArtifact(item)}>
|
||||
<RendererIcon artifact={item} />
|
||||
<span><strong>{item.title}</strong><small>{item.renderer} · v{item.currentVersion}</small></span>
|
||||
</button>
|
||||
</li>)}</ul>
|
||||
{!page.artifacts.length && <p className="hux-artifact-empty">No artifacts are attached.</p>}
|
||||
</nav>
|
||||
|
||||
<main className="hux-artifact-main">
|
||||
{props.error && <p className="hux-artifact-error" role="alert">{props.error}</p>}
|
||||
{page.rejected > 0 && <p className="hux-artifact-warning" role="status">
|
||||
Some artifacts were withheld because their owner or workspace binding was invalid.
|
||||
</p>}
|
||||
{artifact && version ? <>
|
||||
<header className="hux-artifact-heading">
|
||||
<div><span>{artifact.renderer}</span><h2>{artifact.title}</h2>
|
||||
<p>Version {version} · {artifact.sensitivity} · {artifact.language || artifact.type}</p></div>
|
||||
<div className="hux-artifact-actions">
|
||||
<button type="button" disabled={props.busy}
|
||||
onClick={() => props.onContinueEdit?.(intent(artifact, props.scope))}>
|
||||
<Pencil aria-hidden /> Continue editing
|
||||
</button>
|
||||
<AuthorizationAction action="download" state={authorization?.download || "unavailable"}
|
||||
busy={Boolean(props.busy)}
|
||||
onAuthorize={() => props.onAuthorize?.("download", intent(artifact, props.scope))}
|
||||
onAction={() => props.onDownload?.({...intent(artifact, props.scope), version})} />
|
||||
<AuthorizationAction action="share" state={authorization?.share || "unavailable"}
|
||||
busy={Boolean(props.busy)}
|
||||
onAuthorize={() => props.onAuthorize?.("share", intent(artifact, props.scope))}
|
||||
onAction={() => props.onShare?.({...intent(artifact, props.scope), version})} />
|
||||
</div>
|
||||
</header>
|
||||
<Preview artifact={artifact} preview={preview} />
|
||||
<section className="hux-artifact-evidence" aria-label="Attached research evidence">
|
||||
<div><strong>{attachments?.sourceCount || 0}</strong><span>Sources attached</span></div>
|
||||
<div><strong>{attachments?.citationCount || 0}</strong><span>Citations attached</span></div>
|
||||
</section>
|
||||
<button className="hux-artifact-promote" type="button" disabled={props.busy ||
|
||||
artifact.promotedVersion === version} onClick={() => props.onPromote?.({
|
||||
...intent(artifact, props.scope), targetProjectId: props.scope.projectId, version,
|
||||
})}>
|
||||
{artifact.promotedVersion === version ? "Promoted to this project" : "Promote version to project"}
|
||||
</button>
|
||||
</> : <p className="hux-artifact-empty">Select an artifact to inspect it.</p>}
|
||||
</main>
|
||||
|
||||
<aside className="hux-artifact-side" aria-label="Artifact versions">
|
||||
{artifact && version ? <VersionHistory artifact={artifact} selectedVersion={version}
|
||||
setSelectedVersion={selectVersion} busy={Boolean(props.busy)} scope={props.scope}
|
||||
onDiff={props.onDiff} /> : <p>Version history will appear here.</p>}
|
||||
</aside>
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{props.busy ? "Artifact workspace is updating" : `${page.artifacts.length} artifacts available`}
|
||||
</p>
|
||||
</section>;
|
||||
}
|
||||
94
dockerfiles/hermes-webui-hux/artifacts/endpoints.ts
Normal file
94
dockerfiles/hermes-webui-hux/artifacts/endpoints.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/** Same-origin endpoint descriptions for a future HUX-04 backend adapter. */
|
||||
|
||||
import { artifactsEnabled } from "./model.ts";
|
||||
import { isOpaqueId, normalizeIdentity, scopedPath } from "./security.ts";
|
||||
import type {
|
||||
ArtifactEndpoint,
|
||||
ArtifactEndpointContract,
|
||||
ArtifactScope,
|
||||
HuxIdentity,
|
||||
} from "./types.ts";
|
||||
|
||||
interface FoundationClient {
|
||||
apiVersion: string;
|
||||
identity: HuxIdentity;
|
||||
endpoint(path: string): string;
|
||||
}
|
||||
|
||||
function endpoint(method: ArtifactEndpoint["method"], path: string, responseSchema: string,
|
||||
requiredBody?: readonly string[]): ArtifactEndpoint {
|
||||
return Object.freeze({method, path, responseSchema, ...(requiredBody ? {requiredBody} : {})});
|
||||
}
|
||||
|
||||
function versionNumber(value: number): string {
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("Artifact version is invalid");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The server derives identity from the authenticated session and verifies the
|
||||
* project/conversation path binding. Mutation bodies MUST carry
|
||||
* expected_current_version and return 409 on stale state. Existing versions
|
||||
* are never PATCHed or deleted; edits append a new content_ref-backed version.
|
||||
*/
|
||||
export function createArtifactEndpointContract(client: FoundationClient, scope: ArtifactScope,
|
||||
flags?: Iterable<string>): ArtifactEndpointContract | null {
|
||||
if (!artifactsEnabled(flags)) return null;
|
||||
if (client.apiVersion !== "hux.v1") throw new TypeError("HUX v1 client required");
|
||||
normalizeIdentity({tenant_ref: client.identity.tenantRef, user_ref: client.identity.userRef,
|
||||
surface: client.identity.surface});
|
||||
const collection = scopedPath("/projects", scope);
|
||||
|
||||
function itemPath(artifactId: string): string {
|
||||
if (!isOpaqueId(artifactId, "art")) throw new TypeError("Opaque artifact id required");
|
||||
return scopedPath("/projects", scope, artifactId);
|
||||
}
|
||||
|
||||
function itemEndpoint(artifactId: string, suffix = ""): string {
|
||||
return client.endpoint(`${itemPath(artifactId)}${suffix}`);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
list: endpoint("GET", client.endpoint(collection), "hux.artifact_workspace.v1"),
|
||||
item(artifactId) {
|
||||
return endpoint("GET", itemEndpoint(artifactId), "hux.artifact_response.v1");
|
||||
},
|
||||
preview(artifactId, version) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/preview`),
|
||||
"hux.artifact_preview.v1");
|
||||
},
|
||||
diff(artifactId, fromVersion, toVersion) {
|
||||
if (fromVersion >= toVersion) throw new TypeError("Artifact diff range is invalid");
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/diffs"), "hux.artifact_diff.v1",
|
||||
Object.freeze(["from_version", "to_version", "expected_current_version"]));
|
||||
},
|
||||
appendVersion(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/versions"), "hux.artifact_response.v1",
|
||||
Object.freeze(["content_ref", "expected_current_version"]));
|
||||
},
|
||||
continueEdit(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/continue"), "hux.artifact_edit_intent.v1",
|
||||
Object.freeze(["version", "expected_current_version"]));
|
||||
},
|
||||
authorize(artifactId, action) {
|
||||
if (action !== "download" && action !== "share") throw new TypeError("Artifact action is invalid");
|
||||
return endpoint("POST", itemEndpoint(artifactId, `/authorizations/${action}`),
|
||||
"hux.artifact_authorization.v1", Object.freeze(["expected_current_version"]));
|
||||
},
|
||||
download(artifactId, version) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/download`),
|
||||
"hux.artifact_content.v1");
|
||||
},
|
||||
share(artifactId, version) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, `/versions/${versionNumber(version)}/shares`),
|
||||
"hux.artifact_share.v1", Object.freeze(["expected_current_version"]));
|
||||
},
|
||||
promote(artifactId) {
|
||||
return endpoint("POST", itemEndpoint(artifactId, "/promotions"), "hux.artifact_response.v1",
|
||||
Object.freeze(["target_project_id", "version", "expected_current_version"]));
|
||||
},
|
||||
sources(artifactId) {
|
||||
return endpoint("GET", itemEndpoint(artifactId, "/sources"), "hux.artifact_sources.v1");
|
||||
},
|
||||
});
|
||||
}
|
||||
14
dockerfiles/hermes-webui-hux/artifacts/index.ts
Normal file
14
dockerfiles/hermes-webui-hux/artifacts/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export { ArtifactWorkspace } from "./ArtifactWorkspace.tsx";
|
||||
export { createArtifactEndpointContract } from "./endpoints.ts";
|
||||
export {
|
||||
ARTIFACTS_FLAG,
|
||||
FOUNDATION_FLAG,
|
||||
PROJECTS_FLAG,
|
||||
artifactsEnabled,
|
||||
normalizeArtifact,
|
||||
normalizeArtifactWorkspace,
|
||||
normalizePreview,
|
||||
preservesImmutableHistory,
|
||||
} from "./model.ts";
|
||||
export { ArtifactContractError } from "./security.ts";
|
||||
export type * from "./types.ts";
|
||||
239
dockerfiles/hermes-webui-hux/artifacts/model.ts
Normal file
239
dockerfiles/hermes-webui-hux/artifacts/model.ts
Normal file
@ -0,0 +1,239 @@
|
||||
/** Fail-closed normalization and immutable-history invariants for HUX-04. */
|
||||
|
||||
import {
|
||||
ArtifactContractError,
|
||||
isMime,
|
||||
isOpaqueId,
|
||||
isSha256,
|
||||
isUtc,
|
||||
normalizeIdentity,
|
||||
normalizeScope,
|
||||
safeBlobUrl,
|
||||
safePreviewText,
|
||||
safeText,
|
||||
sameIdentity,
|
||||
sameScope,
|
||||
} from "./security.ts";
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactAttachments,
|
||||
ArtifactAuthorization,
|
||||
ArtifactPreview,
|
||||
ArtifactRenderer,
|
||||
ArtifactScope,
|
||||
ArtifactType,
|
||||
ArtifactVersion,
|
||||
AuthorizationStatus,
|
||||
HuxIdentity,
|
||||
RawArtifact,
|
||||
RawArtifactEnvelope,
|
||||
RawArtifactVersion,
|
||||
RawPreviewPayload,
|
||||
Sensitivity,
|
||||
} from "./types.ts";
|
||||
|
||||
export const FOUNDATION_FLAG = "hux.foundation";
|
||||
export const PROJECTS_FLAG = "hux.projects";
|
||||
export const ARTIFACTS_FLAG = "hux.artifacts";
|
||||
const TYPES = new Set<ArtifactType>([
|
||||
"markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio",
|
||||
]);
|
||||
const SENSITIVITIES = new Set<Sensitivity>(["public", "personal", "sensitive", "restricted"]);
|
||||
const AUTHORIZATION = new Set<AuthorizationStatus>(["unavailable", "requires_approval", "authorized"]);
|
||||
const ACTORS = new Set(["user", "assistant", "tool", "system", "operator"]);
|
||||
const MAX_ARTIFACTS = 200;
|
||||
const MAX_VERSIONS = 200;
|
||||
|
||||
export function artifactsEnabled(flags?: Iterable<string>): boolean {
|
||||
const active = new Set(flags || []);
|
||||
return active.has(FOUNDATION_FLAG) && active.has(PROJECTS_FLAG) && active.has(ARTIFACTS_FLAG);
|
||||
}
|
||||
|
||||
function rendererFor(type: ArtifactType, language: string): ArtifactRenderer {
|
||||
if (type === "image") return "image";
|
||||
if (type === "audio") return "audio";
|
||||
if (["code", "html", "svg"].includes(type)) return "code";
|
||||
if (["json", "csv"].includes(type)) return "data";
|
||||
if (/^(report|research|analysis)$/i.test(language)) return "report";
|
||||
return "document";
|
||||
}
|
||||
|
||||
function mimeAllowed(type: ArtifactType, mime: string): boolean {
|
||||
if (type === "image") return ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(mime);
|
||||
if (type === "audio") return ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"].includes(mime);
|
||||
if (type === "html") return mime === "text/html";
|
||||
if (type === "svg") return mime === "image/svg+xml";
|
||||
if (type === "json") return mime === "application/json" || mime === "text/plain";
|
||||
if (type === "csv") return mime === "text/csv" || mime === "text/plain";
|
||||
if (type === "markdown") return mime === "text/markdown" || mime === "text/plain";
|
||||
if (type === "code") return mime.startsWith("text/") || mime === "application/json";
|
||||
return mime === "application/pdf" || mime === "text/plain" || mime === "text/markdown";
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: RawArtifactVersion, type: ArtifactType): ArtifactVersion | null {
|
||||
const actor = raw.created_by as Record<string, unknown> | null;
|
||||
const content = raw.content_ref as Record<string, unknown> | null;
|
||||
if (!Number.isInteger(raw.version) || (raw.version as number) < 1 || !isUtc(raw.created_at) ||
|
||||
!actor || !ACTORS.has(String(actor.type)) || typeof actor.id !== "string" ||
|
||||
actor.id.length < 1 || actor.id.length > 120 || !content ||
|
||||
!isSha256(content.hash) || !Number.isSafeInteger(content.bytes) ||
|
||||
(content.bytes as number) < 0 || !isMime(content.mime) ||
|
||||
!mimeAllowed(type, content.mime) || (raw.diff_from !== undefined &&
|
||||
(!Number.isInteger(raw.diff_from) || (raw.diff_from as number) < 1))) return null;
|
||||
let lineage: ArtifactVersion["lineage"] = null;
|
||||
if (raw.lineage !== undefined) {
|
||||
const value = raw.lineage as Record<string, unknown> | null;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") ||
|
||||
!Number.isInteger(value.version) || (value.version as number) < 1) return null;
|
||||
lineage = {artifactId: value.artifact_id, version: value.version as number};
|
||||
}
|
||||
return {
|
||||
version: raw.version as number,
|
||||
createdAt: raw.created_at,
|
||||
createdBy: String(actor.type),
|
||||
contentRef: {hash: content.hash, bytes: content.bytes as number, mime: content.mime},
|
||||
diffFrom: raw.diff_from as number | undefined || null,
|
||||
lineage,
|
||||
note: safeText(raw.note, "", 200),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeArtifact(raw: RawArtifact, expectedOwner: string, scope: ArtifactScope): Artifact | null {
|
||||
if (raw.schema !== "hux.artifact.v1" || !isOpaqueId(raw.id, "art") ||
|
||||
raw.owner !== expectedOwner || raw.project_id !== scope.projectId ||
|
||||
raw.conversation_id !== scope.conversationId || !TYPES.has(raw.type as ArtifactType) ||
|
||||
!Number.isInteger(raw.current_version) || (raw.current_version as number) < 1 ||
|
||||
!Array.isArray(raw.versions) || raw.versions.length < 1 || raw.versions.length > MAX_VERSIONS ||
|
||||
!SENSITIVITIES.has(raw.sensitivity as Sensitivity) || !isUtc(raw.created_at) ||
|
||||
!isUtc(raw.updated_at) || raw.updated_at < raw.created_at) return null;
|
||||
const type = raw.type as ArtifactType;
|
||||
const language = safeText(raw.language, "", 40);
|
||||
const title = safeText(raw.title, "", 200);
|
||||
if (!title) return null;
|
||||
const versions = raw.versions.map((item) => normalizeVersion(item as RawArtifactVersion, type));
|
||||
if (versions.some((item) => item === null)) return null;
|
||||
const ordered = versions as ArtifactVersion[];
|
||||
if (ordered.some((item, index) => item.version !== index + 1 ||
|
||||
(index > 0 && item.createdAt < ordered[index - 1].createdAt) ||
|
||||
(item.diffFrom !== null && item.diffFrom >= item.version) ||
|
||||
(item.lineage?.artifactId === raw.id && item.lineage.version >= item.version)) ||
|
||||
raw.current_version !== ordered.length) return null;
|
||||
let promotedVersion: number | null = null;
|
||||
if (raw.promotion !== undefined) {
|
||||
const promotion = raw.promotion as Record<string, unknown> | null;
|
||||
if (!promotion || promotion.project_id !== scope.projectId ||
|
||||
!Number.isInteger(promotion.version) || (promotion.version as number) < 1 ||
|
||||
(promotion.version as number) > ordered.length || !isUtc(promotion.at)) return null;
|
||||
promotedVersion = promotion.version as number;
|
||||
}
|
||||
return {
|
||||
id: raw.id, owner: expectedOwner, projectId: scope.projectId,
|
||||
conversationId: scope.conversationId, type, renderer: rendererFor(type, language), language,
|
||||
title, currentVersion: raw.current_version as number, versions: ordered,
|
||||
sensitivity: raw.sensitivity as Sensitivity, promotedVersion, updatedAt: raw.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAttachments(raw: unknown, allowed: ReadonlySet<string>): ArtifactAttachments[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: ArtifactAttachments[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
||||
const value = item as Record<string, unknown> | null;
|
||||
const sources = value?.source_ids;
|
||||
const citations = value?.citation_ids;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
|
||||
seen.has(value.artifact_id) || !Array.isArray(sources) || !Array.isArray(citations) ||
|
||||
sources.length > 500 || citations.length > 500 ||
|
||||
sources.some((id) => !isOpaqueId(id, "src")) ||
|
||||
citations.some((id) => !isOpaqueId(id, "cit")) ||
|
||||
new Set(sources).size !== sources.length || new Set(citations).size !== citations.length) continue;
|
||||
seen.add(value.artifact_id);
|
||||
result.push({artifactId: value.artifact_id, sourceCount: sources.length, citationCount: citations.length});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeAuthorizations(raw: unknown, allowed: ReadonlySet<string>): ArtifactAuthorization[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: ArtifactAuthorization[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of raw.slice(0, MAX_ARTIFACTS)) {
|
||||
const value = item as Record<string, unknown> | null;
|
||||
if (!value || !isOpaqueId(value.artifact_id, "art") || !allowed.has(value.artifact_id) ||
|
||||
seen.has(value.artifact_id) || !AUTHORIZATION.has(value.download as AuthorizationStatus) ||
|
||||
!AUTHORIZATION.has(value.share as AuthorizationStatus)) continue;
|
||||
seen.add(value.artifact_id);
|
||||
result.push({artifactId: value.artifact_id, download: value.download as AuthorizationStatus,
|
||||
share: value.share as AuthorizationStatus});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeArtifactWorkspace(raw: RawArtifactEnvelope, expected: HuxIdentity,
|
||||
scope: ArtifactScope) {
|
||||
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
|
||||
user_ref: expected.userRef, surface: expected.surface});
|
||||
if (!raw || typeof raw !== "object" || raw.schema !== "hux.artifact_workspace.v1" ||
|
||||
raw.api_version !== "hux.v1" || !sameIdentity(normalizeIdentity(raw.identity), trustedIdentity) ||
|
||||
!sameScope(normalizeScope(raw.binding), scope)) {
|
||||
throw new ArtifactContractError("Artifact workspace crossed its scope boundary");
|
||||
}
|
||||
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
|
||||
const normalized = records.slice(0, MAX_ARTIFACTS)
|
||||
.map((item) => normalizeArtifact(item as RawArtifact, trustedIdentity.userRef, scope))
|
||||
.filter((item): item is Artifact => item !== null);
|
||||
const ids = new Set<string>();
|
||||
const artifacts = normalized.filter((item) => !ids.has(item.id) && Boolean(ids.add(item.id)));
|
||||
const allowed = new Set(artifacts.map((item) => item.id));
|
||||
return {
|
||||
artifacts,
|
||||
attachments: normalizeAttachments(raw.attachments, allowed),
|
||||
authorizations: normalizeAuthorizations(raw.authorizations, allowed),
|
||||
rejected: records.length - artifacts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePreview(raw: RawPreviewPayload, artifact: Artifact, expected: HuxIdentity,
|
||||
scope: ArtifactScope): ArtifactPreview | null {
|
||||
try {
|
||||
const identity = normalizeIdentity(raw.identity);
|
||||
const trustedIdentity = normalizeIdentity({tenant_ref: expected.tenantRef,
|
||||
user_ref: expected.userRef, surface: expected.surface});
|
||||
const binding = normalizeScope(raw.binding);
|
||||
const version = artifact.versions.find((item) => item.version === raw.version);
|
||||
if (raw.schema !== "hux.artifact_preview.v1" || !sameIdentity(identity, trustedIdentity) ||
|
||||
!sameScope(binding, scope) || raw.artifact_id !== artifact.id || !version ||
|
||||
raw.hash !== version.contentRef.hash || raw.mime !== version.contentRef.mime) return null;
|
||||
if (artifact.renderer === "image" || artifact.renderer === "audio") {
|
||||
const blobUrl = safeBlobUrl(raw.blob_url);
|
||||
if (!blobUrl || raw.text !== undefined) return null;
|
||||
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
|
||||
mime: version.contentRef.mime, text: null, blobUrl};
|
||||
}
|
||||
if (raw.blob_url !== undefined) return null;
|
||||
const text = safePreviewText(raw.text);
|
||||
if (text === null) return null;
|
||||
return {artifactId: artifact.id, version: version.version, renderer: artifact.renderer,
|
||||
mime: version.contentRef.mime, text, blobUrl: null};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sameVersion(left: ArtifactVersion, right: ArtifactVersion): boolean {
|
||||
return left.version === right.version && left.createdAt === right.createdAt &&
|
||||
left.createdBy === right.createdBy && left.contentRef.hash === right.contentRef.hash &&
|
||||
left.contentRef.bytes === right.contentRef.bytes && left.contentRef.mime === right.contentRef.mime &&
|
||||
left.diffFrom === right.diffFrom && left.lineage?.artifactId === right.lineage?.artifactId &&
|
||||
left.lineage?.version === right.lineage?.version && left.note === right.note;
|
||||
}
|
||||
|
||||
/** A server mutation may append versions but never rewrite history or detach scope. */
|
||||
export function preservesImmutableHistory(before: Artifact, after: Artifact): boolean {
|
||||
return before.id === after.id && before.owner === after.owner &&
|
||||
before.projectId === after.projectId && before.conversationId === after.conversationId &&
|
||||
before.type === after.type && before.versions.length <= after.versions.length &&
|
||||
before.versions.every((version, index) => sameVersion(version, after.versions[index])) &&
|
||||
after.currentVersion === after.versions.length;
|
||||
}
|
||||
104
dockerfiles/hermes-webui-hux/artifacts/security.ts
Normal file
104
dockerfiles/hermes-webui-hux/artifacts/security.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/** Identity, content-reference, URL, and display guards for HUX-04. */
|
||||
|
||||
import type { ArtifactScope, HuxIdentity } from "./types.ts";
|
||||
|
||||
const OPAQUE_ID = /^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$/;
|
||||
const TENANT = /^tnt_[0-9a-f]{16,64}$/;
|
||||
const USER = /^usr_[0-9a-f]{16,64}$/;
|
||||
const UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
|
||||
const HASH = /^sha256:[0-9a-f]{64}$/;
|
||||
const MIME = /^[a-z0-9][a-z0-9.+-]{0,63}\/[a-z0-9][a-z0-9.+-]{0,63}$/i;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/g;
|
||||
const PREVIEW_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
const SURFACES = new Set(["chat", "worker", "telegram", "voice", "api"]);
|
||||
const CREDENTIALS = [
|
||||
/\b(?:password|passwd|token|secret|api[_-]?key)\s*[:=]\s*\S+/gi,
|
||||
/\bBearer\s+[A-Za-z0-9._~-]+/gi,
|
||||
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi,
|
||||
];
|
||||
|
||||
export class ArtifactContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ArtifactContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpaqueId(value: unknown, prefix?: string): value is string {
|
||||
return typeof value === "string" && OPAQUE_ID.test(value) &&
|
||||
(!prefix || value.startsWith(`${prefix}_`));
|
||||
}
|
||||
|
||||
export function isUtc(value: unknown): value is string {
|
||||
return typeof value === "string" && UTC.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
export function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && HASH.test(value);
|
||||
}
|
||||
|
||||
export function isMime(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length <= 120 && MIME.test(value);
|
||||
}
|
||||
|
||||
export function safeText(value: unknown, fallback: string, limit: number): string {
|
||||
if (typeof value !== "string") return fallback;
|
||||
let clean = value.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
||||
for (const pattern of CREDENTIALS) clean = clean.replace(pattern, "[redacted]");
|
||||
if (!clean) return fallback;
|
||||
return clean.length <= limit ? clean : `${clean.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function safePreviewText(value: unknown, limit = 200_000): string | null {
|
||||
return typeof value === "string" && value.length <= limit && !PREVIEW_CONTROL.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeIdentity(raw: unknown): HuxIdentity {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new ArtifactContractError("Artifact identity is invalid");
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (typeof value.tenant_ref !== "string" || !TENANT.test(value.tenant_ref) ||
|
||||
typeof value.user_ref !== "string" || !USER.test(value.user_ref) ||
|
||||
typeof value.surface !== "string" || !SURFACES.has(value.surface)) {
|
||||
throw new ArtifactContractError("Artifact identity is invalid");
|
||||
}
|
||||
return {tenantRef: value.tenant_ref, userRef: value.user_ref,
|
||||
surface: value.surface as HuxIdentity["surface"]};
|
||||
}
|
||||
|
||||
export function sameIdentity(left: HuxIdentity, right: HuxIdentity): boolean {
|
||||
return left.tenantRef === right.tenantRef && left.userRef === right.userRef &&
|
||||
left.surface === right.surface;
|
||||
}
|
||||
|
||||
export function normalizeScope(raw: unknown): ArtifactScope {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new ArtifactContractError("Artifact binding is invalid");
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (!isOpaqueId(value.project_id, "prj") || !isOpaqueId(value.conversation_id, "conv")) {
|
||||
throw new ArtifactContractError("Artifact binding is invalid");
|
||||
}
|
||||
return {projectId: value.project_id, conversationId: value.conversation_id};
|
||||
}
|
||||
|
||||
export function sameScope(left: ArtifactScope, right: ArtifactScope): boolean {
|
||||
return left.projectId === right.projectId && left.conversationId === right.conversationId;
|
||||
}
|
||||
|
||||
/** Only local object URLs created from an authorized response may reach img/audio. */
|
||||
export function safeBlobUrl(value: unknown): string | null {
|
||||
return typeof value === "string" && /^blob:[^\s]{1,2000}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export function scopedPath(base: string, scope: ArtifactScope, artifactId?: string): string {
|
||||
if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base) || base.includes("..") ||
|
||||
!isOpaqueId(scope.projectId, "prj") || !isOpaqueId(scope.conversationId, "conv") ||
|
||||
(artifactId !== undefined && !isOpaqueId(artifactId, "art"))) {
|
||||
throw new TypeError("Artifact endpoint scope is invalid");
|
||||
}
|
||||
const parts = [base.replace(/\/$/, ""), scope.projectId, "conversations", scope.conversationId, "artifacts"];
|
||||
if (artifactId) parts.push(artifactId);
|
||||
return parts.map((part, index) => index ? encodeURIComponent(part) : part).join("/");
|
||||
}
|
||||
294
dockerfiles/hermes-webui-hux/artifacts/styles.css
Normal file
294
dockerfiles/hermes-webui-hux/artifacts/styles.css
Normal file
@ -0,0 +1,294 @@
|
||||
.hux-artifacts {
|
||||
color: var(--hux-text, #dce8f4);
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: minmax(12rem, 0.75fr) minmax(20rem, 2fr) minmax(13rem, 0.85fr);
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
.hux-artifact-list,
|
||||
.hux-artifact-main,
|
||||
.hux-artifact-side {
|
||||
background: color-mix(in srgb, currentColor 2.5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
min-width: 0;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-artifact-list header h2,
|
||||
.hux-artifact-history h3,
|
||||
.hux-artifact-list button,
|
||||
.hux-artifact-actions,
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-heading,
|
||||
.hux-artifact-preview-empty,
|
||||
.hux-artifact-evidence,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hux-artifact-list header h2,
|
||||
.hux-artifact-history h3 {
|
||||
font-size: 0.9rem;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list header p,
|
||||
.hux-artifact-heading p,
|
||||
.hux-artifact-empty {
|
||||
color: color-mix(in srgb, currentColor 58%, transparent);
|
||||
font-size: 0.7rem;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list ul,
|
||||
.hux-artifact-history ol {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
list-style: none;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list button,
|
||||
.hux-artifact-history button,
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 13%, transparent);
|
||||
border-radius: 0.6rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hux-artifact-list button {
|
||||
gap: 0.55rem;
|
||||
padding: 0.55rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-artifact-list button[aria-current="page"] {
|
||||
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 11%, transparent);
|
||||
border-color: color-mix(in srgb, var(--hux-accent, #25d4c3) 45%, transparent);
|
||||
}
|
||||
|
||||
.hux-artifact-list button > span {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hux-artifact-list strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hux-artifact-list small {
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.hux-artifact-list svg,
|
||||
.hux-artifact-actions svg,
|
||||
.hux-artifact-history svg,
|
||||
.hux-artifact-preview-empty svg,
|
||||
.hux-artifact-promote svg {
|
||||
flex: 0 0 auto;
|
||||
height: 0.95rem;
|
||||
width: 0.95rem;
|
||||
}
|
||||
|
||||
.hux-artifact-heading {
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hux-artifact-heading > div:first-child > span {
|
||||
color: var(--hux-accent, #25d4c3);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-artifact-heading h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.hux-artifact-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hux-artifact-actions button,
|
||||
.hux-artifact-promote,
|
||||
.hux-artifact-diff {
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifacts button:focus-visible,
|
||||
.hux-artifacts pre:focus-visible {
|
||||
outline: 2px solid var(--hux-accent, #25d4c3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hux-artifacts button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.hux-artifact-document,
|
||||
.hux-artifact-code,
|
||||
.hux-artifact-report,
|
||||
.hux-artifact-data,
|
||||
.hux-artifact-image,
|
||||
.hux-artifact-audio,
|
||||
.hux-artifact-preview-empty {
|
||||
background: color-mix(in srgb, #050a12 70%, transparent);
|
||||
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
|
||||
border-radius: 0.75rem;
|
||||
margin: 0.85rem 0;
|
||||
min-height: 15rem;
|
||||
overflow: auto;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.hux-artifact-report h3 {
|
||||
color: var(--hux-accent, #25d4c3);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 0.65rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hux-artifact-document pre,
|
||||
.hux-artifact-code pre,
|
||||
.hux-artifact-report pre,
|
||||
.hux-artifact-data pre {
|
||||
font: 0.76rem/1.6 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hux-artifact-preview-empty {
|
||||
align-content: center;
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hux-artifact-image {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hux-artifact-image img {
|
||||
max-height: 32rem;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hux-artifact-image figcaption,
|
||||
.hux-artifact-audio span {
|
||||
color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
font-size: 0.65rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.hux-artifact-audio {
|
||||
align-content: center;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.hux-artifact-audio audio { width: 100%; }
|
||||
|
||||
.hux-artifact-evidence {
|
||||
gap: 0.55rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.hux-artifact-evidence div {
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifact-evidence strong { color: var(--hux-accent, #25d4c3); }
|
||||
.hux-artifact-evidence span { font-size: 0.65rem; }
|
||||
|
||||
.hux-artifact-promote { margin-left: auto; }
|
||||
|
||||
.hux-artifact-history ol > li {
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.hux-artifact-history ol > li[data-current="true"] { border-color: var(--hux-accent, #25d4c3); }
|
||||
|
||||
.hux-artifact-history li > button:first-child {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding: 0.48rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hux-artifact-history li > button[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--hux-accent, #25d4c3) 9%, transparent);
|
||||
}
|
||||
|
||||
.hux-artifact-history span,
|
||||
.hux-artifact-history time {
|
||||
color: color-mix(in srgb, currentColor 56%, transparent);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.hux-artifact-diff { font-size: 0.68rem; justify-self: end; }
|
||||
|
||||
.hux-artifact-error,
|
||||
.hux-artifact-warning {
|
||||
border-left: 2px solid #efb756;
|
||||
color: #efcf96;
|
||||
font-size: 0.72rem;
|
||||
margin: 0 0 0.7rem;
|
||||
padding-left: 0.55rem;
|
||||
}
|
||||
|
||||
.hux-artifact-error { border-color: #e36b7c; color: #f0a4ae; }
|
||||
|
||||
@media (max-width: 66rem) {
|
||||
.hux-artifacts { grid-template-columns: minmax(12rem, 0.7fr) minmax(20rem, 2fr); }
|
||||
.hux-artifact-side { grid-column: 1 / -1; }
|
||||
.hux-artifact-history ol { grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.hux-artifacts { grid-template-columns: 1fr; }
|
||||
.hux-artifact-side { grid-column: auto; }
|
||||
.hux-artifact-heading { align-items: stretch; }
|
||||
.hux-artifact-actions { justify-content: stretch; }
|
||||
.hux-artifact-actions button { flex: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hux-artifacts * { scroll-behavior: auto; transition: none; }
|
||||
}
|
||||
174
dockerfiles/hermes-webui-hux/artifacts/types.ts
Normal file
174
dockerfiles/hermes-webui-hux/artifacts/types.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/** Safe frontend contracts for the default-off HUX-04 artifact workspace. */
|
||||
|
||||
export type HuxSurface = "chat" | "worker" | "telegram" | "voice" | "api";
|
||||
export type ArtifactType =
|
||||
| "markdown"
|
||||
| "code"
|
||||
| "html"
|
||||
| "svg"
|
||||
| "image"
|
||||
| "json"
|
||||
| "csv"
|
||||
| "document"
|
||||
| "audio";
|
||||
export type ArtifactRenderer = "document" | "code" | "image" | "report" | "data" | "audio";
|
||||
export type Sensitivity = "public" | "personal" | "sensitive" | "restricted";
|
||||
export type AuthorizationStatus = "unavailable" | "requires_approval" | "authorized";
|
||||
|
||||
export interface HuxIdentity {
|
||||
tenantRef: string;
|
||||
userRef: string;
|
||||
surface: HuxSurface;
|
||||
}
|
||||
|
||||
export interface ArtifactScope {
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export interface RawArtifactVersion {
|
||||
version?: unknown;
|
||||
created_at?: unknown;
|
||||
created_by?: unknown;
|
||||
message_id?: unknown;
|
||||
content_ref?: unknown;
|
||||
diff_from?: unknown;
|
||||
lineage?: unknown;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
export interface RawArtifact {
|
||||
schema?: unknown;
|
||||
id?: unknown;
|
||||
owner?: unknown;
|
||||
conversation_id?: unknown;
|
||||
project_id?: unknown;
|
||||
type?: unknown;
|
||||
language?: unknown;
|
||||
title?: unknown;
|
||||
current_version?: unknown;
|
||||
versions?: unknown;
|
||||
promotion?: unknown;
|
||||
sensitivity?: unknown;
|
||||
created_at?: unknown;
|
||||
updated_at?: unknown;
|
||||
}
|
||||
|
||||
export interface RawArtifactEnvelope {
|
||||
schema?: unknown;
|
||||
api_version?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
artifacts?: unknown;
|
||||
attachments?: unknown;
|
||||
authorizations?: unknown;
|
||||
}
|
||||
|
||||
export interface ContentReference {
|
||||
hash: string;
|
||||
bytes: number;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export interface ArtifactVersion {
|
||||
version: number;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
contentRef: ContentReference;
|
||||
diffFrom: number | null;
|
||||
lineage: {artifactId: string; version: number} | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
owner: string;
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
type: ArtifactType;
|
||||
renderer: ArtifactRenderer;
|
||||
language: string;
|
||||
title: string;
|
||||
currentVersion: number;
|
||||
versions: ArtifactVersion[];
|
||||
sensitivity: Sensitivity;
|
||||
promotedVersion: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ArtifactAttachments {
|
||||
artifactId: string;
|
||||
sourceCount: number;
|
||||
citationCount: number;
|
||||
}
|
||||
|
||||
export interface ArtifactAuthorization {
|
||||
artifactId: string;
|
||||
download: AuthorizationStatus;
|
||||
share: AuthorizationStatus;
|
||||
}
|
||||
|
||||
export interface ArtifactWorkspacePage {
|
||||
artifacts: Artifact[];
|
||||
attachments: ArtifactAttachments[];
|
||||
authorizations: ArtifactAuthorization[];
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export interface RawPreviewPayload {
|
||||
schema?: unknown;
|
||||
identity?: unknown;
|
||||
binding?: unknown;
|
||||
artifact_id?: unknown;
|
||||
version?: unknown;
|
||||
hash?: unknown;
|
||||
mime?: unknown;
|
||||
text?: unknown;
|
||||
blob_url?: unknown;
|
||||
}
|
||||
|
||||
export interface ArtifactPreview {
|
||||
artifactId: string;
|
||||
version: number;
|
||||
renderer: ArtifactRenderer;
|
||||
mime: string;
|
||||
text: string | null;
|
||||
blobUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactMutationIntent {
|
||||
artifactId: string;
|
||||
expectedVersion: number;
|
||||
scope: ArtifactScope;
|
||||
}
|
||||
|
||||
export interface PromoteIntent extends ArtifactMutationIntent {
|
||||
targetProjectId: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface DiffIntent extends ArtifactMutationIntent {
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}
|
||||
|
||||
export interface ArtifactEndpoint {
|
||||
method: "GET" | "POST";
|
||||
path: string;
|
||||
responseSchema: string;
|
||||
requiredBody?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ArtifactEndpointContract {
|
||||
list: ArtifactEndpoint;
|
||||
item(artifactId: string): ArtifactEndpoint;
|
||||
preview(artifactId: string, version: number): ArtifactEndpoint;
|
||||
diff(artifactId: string, fromVersion: number, toVersion: number): ArtifactEndpoint;
|
||||
appendVersion(artifactId: string): ArtifactEndpoint;
|
||||
continueEdit(artifactId: string): ArtifactEndpoint;
|
||||
authorize(artifactId: string, action: "download" | "share"): ArtifactEndpoint;
|
||||
download(artifactId: string, version: number): ArtifactEndpoint;
|
||||
share(artifactId: string, version: number): ArtifactEndpoint;
|
||||
promote(artifactId: string): ArtifactEndpoint;
|
||||
sources(artifactId: string): ArtifactEndpoint;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user