This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
// The approval queue: what a run's tool asked a person to decide. The panel renders the EXACT
|
||||
// stored proposed arguments — a pretty-printed JSON of what the tool was called with — never a
|
||||
// model's narration of them, and it carries the AI disclosure a reviewer reads before deciding.
|
||||
|
||||
import { AiDisclosureBadge, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { ApprovalView } from "../contract/index.js"
|
||||
|
||||
export interface ApprovalQueueLabels {
|
||||
readonly heading: string
|
||||
readonly empty: string
|
||||
readonly proposed: string
|
||||
readonly review: string
|
||||
readonly approve: string
|
||||
readonly reject: string
|
||||
readonly decided: string
|
||||
readonly disclosure: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function ApprovalQueue({
|
||||
slugs,
|
||||
approvals,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
approvals: readonly ApprovalView[]
|
||||
labels: ApprovalQueueLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const decide = async (approval: ApprovalView, decision: "approve" | "reject"): Promise<void> => {
|
||||
setBusy(`${approval.runId}:${approval.stepKey}`)
|
||||
setMessage(null)
|
||||
try {
|
||||
await call("agent.approval.review", { ...slugs, runId: approval.runId, stepKey: approval.stepKey, decision })
|
||||
setMessage(labels.decided)
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setMessage(labels.failed.replace("{detail}", describe(error)))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="approvals-heading">
|
||||
<h2 id="approvals-heading">{labels.heading}</h2>
|
||||
{approvals.length === 0 ? <p>{labels.empty}</p> : null}
|
||||
{approvals.map((approval) => (
|
||||
<article key={`${approval.runId}:${approval.stepKey}`} aria-label={approval.tool}>
|
||||
<h3>{approval.tool}</h3>
|
||||
<p>{approval.goal}</p>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<h4>{labels.proposed}</h4>
|
||||
<pre>{JSON.stringify(approval.arguments, null, 2)}</pre>
|
||||
<div>
|
||||
<button type="button" disabled={busy !== null} onClick={() => void decide(approval, "approve")}>
|
||||
{labels.approve}
|
||||
</button>
|
||||
<button type="button" disabled={busy !== null} onClick={() => void decide(approval, "reject")}>
|
||||
{labels.reject}
|
||||
</button>
|
||||
{approval.reviewUrl !== null ? (
|
||||
<a href={approval.reviewUrl} target="_blank" rel="noreferrer">
|
||||
{labels.review}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{message !== null ? (
|
||||
<p role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ApprovalQueue } from "./approval-queue.js"
|
||||
export { agentPage, runStatusKey } from "./pages.js"
|
||||
export { RunControls } from "./run-controls.js"
|
||||
export { StartForm } from "./start-form.js"
|
||||
@@ -0,0 +1,179 @@
|
||||
// The agent module's page, mounted by the web shell under `/<org>/<workspace>/<project>/agent`.
|
||||
//
|
||||
// /agent the run list, the approval queue and the start form
|
||||
// /agent/<runId> one run: the plan with per-step progress, the budget, the recovery controls
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses. Progress is rendered from the
|
||||
// durable step log, so a refresh after a restart tells the truth; the progress bar is a real
|
||||
// <progress> element, keyboard-operable controls only.
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import type { ProjectPage } from "@conusai-kit/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
|
||||
import { ApprovalQueue } from "./approval-queue.js"
|
||||
import { RunControls } from "./run-controls.js"
|
||||
import { StartForm } from "./start-form.js"
|
||||
|
||||
export type AgentPage = ProjectPage<TransportCaller, I18n, Container>
|
||||
|
||||
export const runStatusKey: Record<RunView["status"], string> = {
|
||||
queued: "runs.statusQueued",
|
||||
running: "runs.statusRunning",
|
||||
awaiting_approval: "runs.statusAwaitingApproval",
|
||||
completed: "runs.statusCompleted",
|
||||
failed: "runs.statusFailed",
|
||||
cancelled: "runs.statusCancelled",
|
||||
uncertain: "runs.statusUncertain",
|
||||
}
|
||||
|
||||
const stepStatusKey: Record<RunView["plan"][number]["status"], string> = {
|
||||
pending: "runs.stepPending",
|
||||
committed: "runs.stepCommitted",
|
||||
failed: "runs.stepFailed",
|
||||
cancelled: "runs.stepCancelled",
|
||||
uncertain: "runs.stepUncertain",
|
||||
}
|
||||
|
||||
export const agentPage: AgentPage = async ({ slugs, segments, caller, i18n }): Promise<ReactElement> => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
|
||||
|
||||
const startLabels = {
|
||||
heading: t("start.heading"),
|
||||
goalLabel: t("start.goalLabel"),
|
||||
goalHint: t("start.goalHint"),
|
||||
planLabel: t("start.planLabel"),
|
||||
planHint: t("start.planHint"),
|
||||
submit: t("start.submit"),
|
||||
starting: t("start.starting"),
|
||||
failed,
|
||||
}
|
||||
const approvalLabels = {
|
||||
heading: t("approvals.heading"),
|
||||
empty: t("approvals.empty"),
|
||||
proposed: t("approvals.proposed"),
|
||||
review: t("approvals.review"),
|
||||
approve: t("approvals.approve"),
|
||||
reject: t("approvals.reject"),
|
||||
decided: t("approvals.decided"),
|
||||
disclosure: t("approvals.disclosure"),
|
||||
failed,
|
||||
}
|
||||
const controlsLabels = {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
refresh: t("runs.refresh"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("intro")}</p>
|
||||
<ApprovalQueue slugs={slugs} approvals={approvals.approvals as ApprovalView[]} labels={approvalLabels} />
|
||||
<StartForm slugs={slugs} labels={startLabels} />
|
||||
<section aria-labelledby="runs-heading">
|
||||
<h2 id="runs-heading">{t("runs.heading")}</h2>
|
||||
{runs.runs.length === 0 ? <p>{t("runs.empty")}</p> : null}
|
||||
<table>
|
||||
<caption>{t("runs.heading")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("runs.goal")}</th>
|
||||
<th scope="col">{t("runs.status")}</th>
|
||||
<th scope="col">{t("runs.progress")}</th>
|
||||
<th scope="col">{t("runs.startedBy")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.runs.map((run) => (
|
||||
<tr key={run.runId}>
|
||||
<td>
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent/${run.runId}`}>{run.goal}</a>
|
||||
</td>
|
||||
<td>{t(runStatusKey[run.status])}</td>
|
||||
<td>
|
||||
<progress value={run.percent} max={1}>
|
||||
{Math.round(run.percent * 100)}%
|
||||
</progress>
|
||||
</td>
|
||||
<td>{run.actorId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const [runId, ...rest] = segments
|
||||
if (runId === undefined || rest.length > 0) throw notFoundPage()
|
||||
const run = await caller.call(runStatus, { ...slugs, runId })
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{t("runs.back")}</a>
|
||||
</p>
|
||||
<h1>{run.goal}</h1>
|
||||
<dl>
|
||||
<dt>{t("runs.status")}</dt>
|
||||
<dd>{t(runStatusKey[run.status])}</dd>
|
||||
<dt>{t("runs.startedBy")}</dt>
|
||||
<dd>{run.actorId}</dd>
|
||||
<dt>{t("runs.usage")}</dt>
|
||||
<dd>{t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents })}</dd>
|
||||
</dl>
|
||||
<progress value={run.percent} max={1} aria-label={t("runs.progress")}>
|
||||
{Math.round(run.percent * 100)}%
|
||||
</progress>
|
||||
<RunControls slugs={slugs} run={run} labels={controlsLabels} />
|
||||
<section aria-labelledby="plan-heading">
|
||||
<h2 id="plan-heading">{t("runs.plan")}</h2>
|
||||
<table>
|
||||
<caption>{t("runs.plan")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("runs.step")}</th>
|
||||
<th scope="col">{t("runs.tool")}</th>
|
||||
<th scope="col">{t("runs.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.plan.map((step) => (
|
||||
<tr key={step.key}>
|
||||
<td>{step.label}</td>
|
||||
<td>
|
||||
<code>{step.tool}</code>
|
||||
</td>
|
||||
<td>
|
||||
{t(stepStatusKey[step.status])}
|
||||
{step.attempts > 1 ? ` (${t("runs.attempts", { count: step.attempts })})` : ""}
|
||||
{step.error !== null ? `: ${step.error}` : ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const notFoundPage = () => {
|
||||
// A segment that is not a run id is a page that does not exist.
|
||||
return Object.assign(new Error("no such page"), { status: 404 })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
// One run's controls: cancel while it runs, and recovery from its terminal states. The recovery
|
||||
// copy tells the person what the state means and what they can do — failed runs can be started
|
||||
// again from the same plan, cancelled runs are simply over, and an uncertain run (a timeout after
|
||||
// an external effect) is reconciled by a person, not retried blindly.
|
||||
|
||||
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
|
||||
export interface RunControlsLabels {
|
||||
readonly cancel: string
|
||||
readonly cancelling: string
|
||||
readonly refresh: string
|
||||
readonly failedHint: string
|
||||
readonly cancelledHint: string
|
||||
readonly uncertainHint: string
|
||||
readonly budgetExhausted: string
|
||||
readonly approvalUnavailable: string
|
||||
readonly toolError: string
|
||||
readonly planRefused: string
|
||||
readonly internal: string
|
||||
}
|
||||
|
||||
export function RunControls({
|
||||
slugs,
|
||||
run,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
run: Pick<RunView, "runId" | "status" | "failureCode">
|
||||
labels: RunControlsLabels
|
||||
}): ReactElement {
|
||||
const { call } = useTransport()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const cancel = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await call("agent.run.cancel", { ...slugs, runId: run.runId })
|
||||
window.location.reload()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const active = run.status === "queued" || run.status === "running" || run.status === "awaiting_approval"
|
||||
const failureText = (code: RunView["failureCode"]): string => {
|
||||
switch (code) {
|
||||
case "budget_exhausted":
|
||||
return labels.budgetExhausted
|
||||
case "approval_unavailable":
|
||||
return labels.approvalUnavailable
|
||||
case "tool_error":
|
||||
return labels.toolError
|
||||
case "plan_refused":
|
||||
return labels.planRefused
|
||||
default:
|
||||
return labels.internal
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{active ? (
|
||||
<button type="button" disabled={busy} onClick={() => void cancel()}>
|
||||
{busy ? labels.cancelling : labels.cancel}
|
||||
</button>
|
||||
) : null}
|
||||
{run.status === "failed" ? (
|
||||
<p role="alert">
|
||||
{failureText(run.failureCode)} {labels.failedHint}
|
||||
</p>
|
||||
) : null}
|
||||
{run.status === "cancelled" ? <p role="status">{labels.cancelledHint}</p> : null}
|
||||
{run.status === "uncertain" ? <p role="alert">{labels.uncertainHint}</p> : null}
|
||||
<a href="?">{labels.refresh}</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
// Starting a run: the goal and the plan as JSON. The plan is what the run will do, shown to the
|
||||
// person before they start it, and admitted by the server against the hard ceilings.
|
||||
|
||||
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
|
||||
export interface StartFormLabels {
|
||||
readonly heading: string
|
||||
readonly goalLabel: string
|
||||
readonly goalHint: string
|
||||
readonly planLabel: string
|
||||
readonly planHint: string
|
||||
readonly submit: string
|
||||
readonly starting: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type State = { kind: "idle" } | { kind: "busy" } | { kind: "failed"; detail: string }
|
||||
|
||||
const EXAMPLE = JSON.stringify([
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "…" } },
|
||||
])
|
||||
|
||||
export function StartForm({ slugs, labels }: { slugs: ProjectSlugs; labels: StartFormLabels }): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const goalId = useId()
|
||||
const planId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const start = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const goal = String(form.get("goal") ?? "").trim()
|
||||
let plan: unknown
|
||||
try {
|
||||
plan = JSON.parse(String(form.get("plan") ?? ""))
|
||||
} catch {
|
||||
setState({ kind: "failed", detail: "invalid JSON" })
|
||||
return
|
||||
}
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
await call("agent.run.start", { ...slugs, goal, plan })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${goalId}-heading`}>
|
||||
<h2 id={`${goalId}-heading`}>{labels.heading}</h2>
|
||||
<form onSubmit={(event) => void start(event)}>
|
||||
<label htmlFor={goalId}>{labels.goalLabel}</label>
|
||||
<input id={goalId} name="goal" required maxLength={2_000} aria-describedby={`${goalId}-hint`} />
|
||||
<p id={`${goalId}-hint`}>{labels.goalHint}</p>
|
||||
<label htmlFor={planId}>{labels.planLabel}</label>
|
||||
<textarea
|
||||
id={planId}
|
||||
name="plan"
|
||||
required
|
||||
rows={6}
|
||||
defaultValue={EXAMPLE}
|
||||
aria-describedby={`${planId}-hint`}
|
||||
/>
|
||||
<p id={`${planId}-hint`}>{labels.planHint.replace("{example}", EXAMPLE)}</p>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.starting : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user