188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
// The agent module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web shell
|
|
// runs on the server under `/<org>/<workspace>/<project>/agent`, and the view of what it returned.
|
|
//
|
|
// /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 and
|
|
// a completed run's AI-generated answer, live through the run's event stream
|
|
//
|
|
// Every read is a procedure through the same caller the API uses, and every label is translated in the
|
|
// loader. The run page's first render is `agent.run.status`, the seed of a client island that follows the
|
|
// run's AG-UI events (live-run.tsx); both come from the durable record, so a reload after a restart tells
|
|
// the truth. The progress bar is a real <progress> element, keyboard-operable controls only.
|
|
|
|
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
|
import type { I18n } from "@conusai-kit/i18n"
|
|
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
|
import { DetailLayout, FactList, Page, PageHeader } from "@conusai-kit/ui/patterns"
|
|
import type { ReactElement } from "react"
|
|
import { type ApprovalView, approvalList, type RunView, runList, runStatus } from "../contract/index.js"
|
|
import { ApprovalQueue, type ApprovalQueueLabels } from "./approval-queue.js"
|
|
import { LiveRun, type LiveRunLabels } from "./live-run.js"
|
|
import { RunList, type RunListLabels } from "./live-runs.js"
|
|
import { StartForm, type StartFormLabels } from "./start-form.js"
|
|
|
|
/** The run list, the approval queue and the start form. */
|
|
export interface AgentListData {
|
|
readonly kind: "list"
|
|
readonly title: string
|
|
readonly intro: string
|
|
readonly approvals: readonly ApprovalView[]
|
|
readonly approvalLabels: ApprovalQueueLabels
|
|
readonly start: StartFormLabels
|
|
readonly runLabels: RunListLabels
|
|
/** The first page of runs: the live list's seed. */
|
|
readonly runs: readonly RunView[]
|
|
}
|
|
|
|
/** One run: the seed of the live island, and the facts that do not change while it runs. */
|
|
export interface AgentRunData {
|
|
readonly kind: "run"
|
|
readonly back: string
|
|
readonly startedBy: string
|
|
readonly usageLabel: string
|
|
readonly usage: string
|
|
readonly run: RunView
|
|
readonly labels: LiveRunLabels
|
|
}
|
|
|
|
export type AgentPageData = AgentListData | AgentRunData
|
|
|
|
export type AgentPage = ProjectPage<TransportCaller, I18n, Container, AgentPageData>
|
|
|
|
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",
|
|
}
|
|
|
|
const mapValues = <K extends string>(keys: Record<K, string>, t: (key: string) => string): Record<K, string> =>
|
|
Object.fromEntries(Object.entries<string>(keys).map(([status, key]) => [status, t(key)])) as Record<K, string>
|
|
|
|
export const agentPage: AgentPage = projectPage({
|
|
async load({ slugs, segments, caller, i18n }): Promise<AgentPageData> {
|
|
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
|
const failed = i18n.message("app.failed")
|
|
|
|
if (segments.length === 0) {
|
|
const [runs, approvals] = await Promise.all([caller.call(runList, slugs), caller.call(approvalList, slugs)])
|
|
return {
|
|
kind: "list",
|
|
title: t("title"),
|
|
intro: t("intro"),
|
|
approvals: approvals.approvals as ApprovalView[],
|
|
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,
|
|
},
|
|
start: {
|
|
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,
|
|
},
|
|
runLabels: {
|
|
heading: t("runs.heading"),
|
|
empty: t("runs.empty"),
|
|
goal: t("runs.goal"),
|
|
status: t("runs.status"),
|
|
progress: t("runs.progress"),
|
|
startedBy: t("runs.startedBy"),
|
|
statuses: mapValues(runStatusKey, t),
|
|
},
|
|
runs: runs.runs,
|
|
}
|
|
}
|
|
|
|
// A path that is not one run id is a page that does not exist.
|
|
const [runId, ...rest] = segments
|
|
if (runId === undefined || rest.length > 0) throw notFound("no such page")
|
|
const run = await caller.call(runStatus, { ...slugs, runId })
|
|
return {
|
|
kind: "run",
|
|
back: t("runs.back"),
|
|
startedBy: t("runs.startedBy"),
|
|
usageLabel: t("runs.usageLabel"),
|
|
usage: t("runs.usage", { steps: run.usageSteps, cost: run.usageCostMicrocents }),
|
|
run,
|
|
labels: {
|
|
status: t("runs.status"),
|
|
progress: t("runs.progress"),
|
|
plan: t("runs.plan"),
|
|
step: t("runs.step"),
|
|
tool: t("runs.tool"),
|
|
needsApproval: t("runs.needsApproval"),
|
|
answer: t("runs.answer"),
|
|
answerDisclosure: t("runs.answerDisclosure"),
|
|
streamLost: t("runs.streamLost"),
|
|
statuses: mapValues(runStatusKey, t),
|
|
stepStatuses: mapValues(stepStatusKey, t),
|
|
controls: {
|
|
cancel: t("runs.cancel"),
|
|
cancelling: t("runs.cancelling"),
|
|
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"),
|
|
},
|
|
},
|
|
}
|
|
},
|
|
|
|
View({ slugs, data }: ProjectPageViewProps<AgentPageData>): ReactElement {
|
|
if (data.kind === "list") {
|
|
return (
|
|
<Page>
|
|
<PageHeader title={data.title} description={data.intro} />
|
|
<ApprovalQueue slugs={slugs} seed={data.approvals} labels={data.approvalLabels} />
|
|
<DetailLayout aside={<StartForm slugs={slugs} labels={data.start} />}>
|
|
<RunList slugs={slugs} seed={data.runs} labels={data.runLabels} />
|
|
</DetailLayout>
|
|
</Page>
|
|
)
|
|
}
|
|
const { run } = data
|
|
return (
|
|
<Page>
|
|
<PageHeader
|
|
title={run.goal}
|
|
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/agent`}>{data.back}</a>}
|
|
/>
|
|
<FactList
|
|
facts={[
|
|
{ label: data.startedBy, value: run.actorId },
|
|
{ label: data.usageLabel, value: data.usage },
|
|
]}
|
|
/>
|
|
<LiveRun slugs={slugs} seed={run} labels={data.labels} />
|
|
</Page>
|
|
)
|
|
},
|
|
})
|