Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { consumeSSE } from "./sse";
import type {
ApprovalDecisionControlRequestView,
ChildTaskAdmitRequestView,
DockerSandboxAdmissionRequestView,
DockerSandboxAdmissionView,
DockerSandboxCancelRequestView,
DockerSandboxCancellationView,
DockerSandboxStartRequestView,
DockerSandboxStatusView,
PriceSnapshotImportRequestView,
PriceSnapshotImportView,
PriceSnapshotListView,
ChildTaskProposalsListView,
ChildTaskProposalView,
ChildTaskReviewRequestView,
Expand Down Expand Up @@ -4242,6 +4251,72 @@ export class CyberAgentClient {
);
return parseChildTaskProposal(result, runID);
}
async listPriceSnapshots(signal?: AbortSignal): Promise<PriceSnapshotListView> {
const result = await this.get<unknown>("/models/prices", {}, signal);
return parsePriceSnapshots(result);
}

async importPriceSnapshot(body: PriceSnapshotImportRequestView, idempotencyKey: string,
signal?: AbortSignal): Promise<PriceSnapshotImportView> {
if (!this.hasControl) {
throw new Error("A control bearer token is required for this operation");
}
if (body.version !== "price_snapshot.v1" || !body.document ||
body.document.length > 64 * 1024) {
throw new Error("A bounded price_snapshot.v1 document is required");
}
const result = await this.sendControl<unknown>("/models/prices", body, idempotencyKey, signal);
return parsePriceSnapshotImport(result);
}

async getDockerSandboxStatus(admissionID: string,
signal?: AbortSignal): Promise<DockerSandboxStatusView> {
const result = await this.get<unknown>("/sandbox/docker/status", { admission_id: admissionID }, signal);
if (!isRecord(result) || !boundedIdentity(String(result.admission_id))) {
throw new APIRequestError("Docker sandbox status is invalid", "INVALID_RESPONSE", 502);
}
return result as unknown as DockerSandboxStatusView;
}

async admitDockerSandbox(body: DockerSandboxAdmissionRequestView, idempotencyKey: string,
signal?: AbortSignal): Promise<DockerSandboxAdmissionView> {
if (!this.hasControl) {
throw new Error("A control bearer token is required for this operation");
}
if (!boundedIdentity(body.plan_id) || !boundedIdentity(body.requested_by) ||
!isRecord(body.manifest)) {
throw new Error("A plan identity, requester, and manifest object are required");
}
const result = await this.sendControl<unknown>("/sandbox/docker/admissions", body, idempotencyKey, signal);
if (!isRecord(result) || typeof result.allowed !== "boolean") {
throw new APIRequestError("Docker sandbox admission is invalid", "INVALID_RESPONSE", 502);
}
return result as unknown as DockerSandboxAdmissionView;
}

async startDockerSandbox(body: DockerSandboxStartRequestView, idempotencyKey: string,
signal?: AbortSignal): Promise<DockerSandboxStatusView> {
if (!this.hasControl) {
throw new Error("A control bearer token is required for this operation");
}
if (!boundedIdentity(body.admission_id) || !boundedIdentity(body.requested_by)) {
throw new Error("An admission identity and requester are required");
}
const result = await this.sendControl<unknown>("/sandbox/docker/starts", body, idempotencyKey, signal);
return result as unknown as DockerSandboxStatusView;
}

async cancelDockerSandbox(body: DockerSandboxCancelRequestView, idempotencyKey: string,
signal?: AbortSignal): Promise<DockerSandboxCancellationView> {
if (!this.hasControl) {
throw new Error("A control bearer token is required for this operation");
}
if (!boundedIdentity(body.admission_id) || !boundedIdentity(body.requested_by)) {
throw new Error("An admission identity and requester are required");
}
const result = await this.sendControl<unknown>("/sandbox/docker/cancellations", body, idempotencyKey, signal);
return result as unknown as DockerSandboxCancellationView;
}
async selectPlanDirection(runID: string, body: PlanDirectionControlRequestView,
idempotencyKey: string, signal?: AbortSignal): Promise<PlanDirectionControlView> {
if (!this.hasPlanDelivery) {
Expand Down Expand Up @@ -4639,3 +4714,25 @@ function parseChildTaskProposal(value: unknown, runID: string): ChildTaskProposa
}
return value as unknown as ChildTaskProposalView;
}
function parsePriceSnapshots(value: unknown): PriceSnapshotListView {
if (!isRecord(value) || value.protocol_version !== "price_snapshot_list.v1" ||
!Array.isArray(value.items) || value.items.length > 32) {
throw new APIRequestError("Price snapshot list is invalid", "INVALID_RESPONSE", 502);
}
for (const item of value.items) {
if (!isRecord(item) || !boundedIdentity(String(item.id)) || !validDate(item.imported_at) ||
!Array.isArray(item.entries) || item.entries.length > 512) {
throw new APIRequestError("Price snapshot item is invalid", "INVALID_RESPONSE", 502);
}
}
return value as unknown as PriceSnapshotListView;
}

function parsePriceSnapshotImport(value: unknown): PriceSnapshotImportView {
if (!isRecord(value) || value.protocol_version !== "price_snapshot.v1" ||
!boundedIdentity(String(value.id)) || !boundedIdentity(String(value.fingerprint)) ||
!safeBoundedCount(value.entry_count, 512) || typeof value.replayed !== "boolean") {
throw new APIRequestError("Price snapshot import is invalid", "INVALID_RESPONSE", 502);
}
return value as unknown as PriceSnapshotImportView;
}
9 changes: 9 additions & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ export type ChildTaskProposalsListView = components["schemas"]["ChildTaskProposa
export type ChildTaskProposalView = components["schemas"]["ChildTaskProposalView"];
export type ChildTaskReviewRequestView = components["schemas"]["ChildTaskReviewRequestView"];
export type ChildTaskAdmitRequestView = components["schemas"]["ChildTaskAdmitRequestView"];
export type PriceSnapshotListView = components["schemas"]["PriceSnapshotListView"];
export type PriceSnapshotImportRequestView = components["schemas"]["PriceSnapshotImportRequestView"];
export type PriceSnapshotImportView = components["schemas"]["PriceSnapshotImportView"];
export type DockerSandboxStatusView = components["schemas"]["DockerSandboxStatusView"];
export type DockerSandboxAdmissionRequestView = components["schemas"]["DockerSandboxAdmissionRequestView"];
export type DockerSandboxAdmissionView = components["schemas"]["DockerSandboxAdmissionView"];
export type DockerSandboxStartRequestView = components["schemas"]["DockerSandboxStartRequestView"];
export type DockerSandboxCancelRequestView = components["schemas"]["DockerSandboxCancelRequestView"];
export type DockerSandboxCancellationView = components["schemas"]["DockerSandboxCancellationView"];
export type FanoutExecutionView = components["schemas"]["FanoutExecutionView"];
export type FanoutExecutionCancelRequestView =
components["schemas"]["FanoutExecutionCancelRequestView"];
Expand Down
85 changes: 85 additions & 0 deletions web/src/components/docker-sandbox-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Box, LoaderCircle, Server } from "lucide-react";
import type { CyberAgentClient } from "../api/client";
import { ErrorState, LoadingState, StatusBadge } from "./common";
import { useLocale } from "../lib/locale";

export function DockerSandboxPanel({ client }: { client: CyberAgentClient }) {
const { t } = useLocale();
const [planID, setPlanID] = useState("");
const [manifest, setManifest] = useState("");
const [admissionID, setAdmissionID] = useState("");
const [lastAdmission, setLastAdmission] = useState("");
const [message, setMessage] = useState("");
const statusQuery = useQuery({
queryKey: ["sandbox", "docker", "status", admissionID],
queryFn: ({ signal }) => client.getDockerSandboxStatus(admissionID, signal),
enabled: Boolean(admissionID),
});
const admit = useMutation({
mutationFn: () => {
let parsed: unknown;
try { parsed = JSON.parse(manifest); } catch { throw new Error("manifest must be valid JSON"); }
return client.admitDockerSandbox({ plan_id: planID, requested_by: "web_operator", manifest: parsed as never },
`web-docker-admit-${globalThis.crypto.randomUUID()}`);
},
onSuccess: (result) => {
setLastAdmission(result.admission_id ?? "");
setAdmissionID(result.admission_id ?? "");
setMessage(result.allowed ? "admission authorized" : "admission denied");
},
});
const start = useMutation({
mutationFn: () => client.startDockerSandbox({ admission_id: admissionID, requested_by: "web_operator" },
`web-docker-start-${globalThis.crypto.randomUUID()}`),
onSuccess: () => setMessage("start request accepted"),
});
const cancel = useMutation({
mutationFn: () => client.cancelDockerSandbox({ admission_id: admissionID, requested_by: "web_operator" },
`web-docker-cancel-${globalThis.crypto.randomUUID()}`),
onSuccess: () => setMessage("cancellation accepted"),
});
return (
<section className="detail-section docker-sandbox-section" aria-label={t("Docker 沙箱", "Docker sandbox")}>
<div className="section-heading"><h2><Server aria-hidden="true" size={15} />{t("Docker 沙箱", "Docker sandbox")}</h2><span>{t("network-none 精确准入", "network-none exact admission")}</span></div>
<div className="run-execution-control">
<label htmlFor="docker-plan-id">{t("计划 ID", "Plan ID")}</label>
<input id="docker-plan-id" maxLength={256} onChange={(event) => setPlanID(event.target.value)} value={planID} />
</div>
<div className="run-execution-control">
<label htmlFor="docker-manifest">{t("Manifest JSON", "Manifest JSON")}</label>
<textarea id="docker-manifest" onChange={(event) => setManifest(event.target.value)} rows={8} value={manifest} />
</div>
{client.hasControl && (
<button className="command-button" disabled={admit.isPending || !planID || !manifest}
onClick={() => admit.mutate()} type="button">
{admit.isPending ? <LoaderCircle aria-hidden="true" className="spin" size={15} /> : <Box aria-hidden="true" size={15} />}
{t("评估并准入", "Evaluate and admit")}
</button>
)}
<div className="run-execution-control">
<label htmlFor="docker-admission-id">{t("准入 ID", "Admission ID")}</label>
<input id="docker-admission-id" maxLength={256} onChange={(event) => setAdmissionID(event.target.value)} value={admissionID} />
</div>
{statusQuery.data && (
<dl className="detail-grid compact">
<div><dt>{t("状态", "Status")}</dt><dd><StatusBadge status={String(statusQuery.data.state)} /></dd></div>
<div><dt>{t("决策", "Decision")}</dt><dd>{String(statusQuery.data.decision)}</dd></div>
</dl>
)}
{client.hasControl && admissionID && (
<div className="run-execution-control">
<button className="command-button" disabled={start.isPending} onClick={() => start.mutate()} type="button">{t("启动", "Start")}</button>
<button className="command-button danger" disabled={cancel.isPending} onClick={() => cancel.mutate()} type="button">{t("取消", "Cancel")}</button>
</div>
)}
{message && <div className="projection-placeholder" role="status">{message}</div>}
{lastAdmission && !admissionID && <div className="projection-placeholder">{t("最近准入", "Latest admission")}: {lastAdmission}</div>}
{(admit.isError || start.isError || cancel.isError) && <div className="inline-warning" role="alert">
{String((admit.isError ? admit.error : start.isError ? start.error : cancel.error) ?? t("Docker 沙箱操作失败", "Docker sandbox operation failed"))}
</div>}
</section>
);
}

2 changes: 1 addition & 1 deletion web/src/components/evidence-inventory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function EvidenceInventory({ client, runID, onOpenSource }: {
{query.data && query.data.items.length > 0 && <div className="evidence-inventory-list">
{query.data.items.map((item) => <div key={item.attachment_id}>
<ShieldCheck aria-hidden="true" size={16} />
<span><strong>{item.source_ref}</strong><code>{item.content_sha256}</code></span>
<span><strong>{item.source_ref}</strong><code>{item.content_sha256}</code>{item.instruction_authorized && <small>{t("含授权指令", "instruction authorized")}</small>}</span>
<time dateTime={item.attached_at}>{formatDate(item.attached_at)}</time>
<button aria-label={t(`打开 ${item.source_ref}`, `Open ${item.source_ref}`)} className="icon-button"
onClick={() => onOpenSource(item.source_ref)} title={t("打开来源", "Open source")} type="button">
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/model-availability-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ModelHarnessQualificationView, ProviderDiagnosticView } from "../a
import { ErrorState, LoadingState, StatusBadge } from "./common";
import { useLocale } from "../lib/locale";
import { useModalFocusTrap } from "../hooks/use-modal-focus-trap";
import { PriceSnapshotsSection } from "./price-snapshots-panel";

export function ModelAvailabilityDialog({ client, open, onClose }: {
client: CyberAgentClient;
Expand Down Expand Up @@ -291,6 +292,7 @@ function ModelAvailabilitySurface({ client, open, onClose, presentation }: {
{credentialError}
</div>}
</section>}
<PriceSnapshotsSection client={client} />
<section className="model-availability-section">
<h3><Route aria-hidden="true" size={14} />{t("模型路由", "Routes")}</h3>
<div className="model-route-list">
Expand Down
1 change: 1 addition & 0 deletions web/src/components/operation-receipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function OperationReceipt({ receipt }: { receipt: OperationReceiptView })
<div>
<strong>{receipt.outcome}</strong>
<span>{receipt.kind.replaceAll("_", " ")} / {t("持久化", "durable")}{receipt.replayed ? t(" / 已重放", " / replayed") : ""}</span>
{(receipt.retry_strategy || receipt.recovery_action) && <small>{t("重试", "Retry")}: {receipt.retry_strategy || "-"} · {t("恢复", "Recovery")}: {receipt.recovery_action || "-"}</small>}
{failed && <small>{t("相同操作键会重放这份持久化失败结果。", "The durable failed result will replay for the same operation key.")}</small>}
{pending && <small>{t("暂存区等待清理;请在清理宽限期后使用相同操作键重试。", "Staging cleanup is pending. Retry the same operation after the cleanup grace period.")}</small>}
</div>
Expand Down
58 changes: 58 additions & 0 deletions web/src/components/price-snapshots-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { LoaderCircle, Tag } from "lucide-react";
import type { CyberAgentClient } from "../api/client";
import { ErrorState, LoadingState } from "./common";
import { useLocale } from "../lib/locale";

export function PriceSnapshotsSection({ client }: { client: CyberAgentClient }) {
const { t } = useLocale();
const queryClient = useQueryClient();
const [document, setDocument] = useState("");
const query = useQuery({
queryKey: ["models", "prices"],
queryFn: ({ signal }) => client.listPriceSnapshots(signal),
});
const importMutation = useMutation({
mutationFn: () => client.importPriceSnapshot({ version: "price_snapshot.v1", document },
`web-price-import-${globalThis.crypto.randomUUID()}`),
onSuccess: () => {
setDocument("");
void queryClient.invalidateQueries({ queryKey: ["models", "prices"] });
},
});
return (
<section className="model-availability-section">
<h3><Tag aria-hidden="true" size={14} />{t("价格快照", "Price snapshots")}</h3>
{query.isLoading && <LoadingState label={t("加载价格快照", "Loading price snapshots")} />}
{query.isError && <ErrorState error={query.error} />}
{query.data && (
<div className="provider-credential-list">
{query.data.items.length === 0 && <div className="projection-placeholder">{t("尚无导入", "none imported")}</div>}
{query.data.items.map((item) => (
<div className="provider-credential-row" key={item.id}>
<div><strong>{item.id}</strong><small>{item.source} · {item.currency} · {item.entry_count} {t("条", "entries")}</small></div>
<code>{item.fingerprint.slice(0, 16)}</code>
</div>
))}
</div>
)}
{client.hasControl && (
<div className="run-execution-control">
<textarea aria-label={t("价格文档", "price_snapshot.v1 document")} onChange={(event) => setDocument(event.target.value)}
placeholder='{"protocol_version":"price_snapshot.v1","id":"...","source":"operator_import","currency":"USD","imported_by":"operator","valid_from":"...","valid_until":"...","entries":[...]}'
rows={6} value={document} />
<button className="command-button" disabled={importMutation.isPending || !document}
onClick={() => importMutation.mutate()} type="button">
{importMutation.isPending ? <LoaderCircle aria-hidden="true" className="spin" size={15} /> : <Tag aria-hidden="true" size={15} />}
{t("导入", "Import")}
</button>
{importMutation.error && <div className="inline-warning" role="alert">
{importMutation.error instanceof Error ? importMutation.error.message : t("价格导入失败", "Price import failed")}
</div>}
</div>
)}
</section>
);
}

Loading