mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Remove agent builder from processor (#7063)
# Description of Changes Remove agent builder from processor because we don't intend to implement it any time soon.
This commit is contained in:
@@ -6136,90 +6136,6 @@ free = "Editor plan"
|
||||
subscribed = "Processor plan"
|
||||
unlinked = "Not linked"
|
||||
|
||||
[portal.agentBuilder]
|
||||
bootstrapFromDocument = "Bootstrap from document"
|
||||
sectionsAriaLabel = "Agent builder sections"
|
||||
selectorAriaLabel = "Agents"
|
||||
subtitle = "Design, test and ship the AI agents that classify, extract from and route your documents. Define scenarios, fence tool access, run a golden set, and publish a version."
|
||||
title = "Agent Builder"
|
||||
|
||||
[portal.agentBuilder.bootstrap]
|
||||
cancel = "Cancel"
|
||||
dropzoneText = "Choose a sample document (PDF or image)"
|
||||
lead = "Drop a sample document and we'll propose scenarios and an extraction schema you can refine. Nothing is published until you review it."
|
||||
submit = "Bootstrap agent"
|
||||
subtitle = "Seed a new agent from one representative file"
|
||||
title = "Bootstrap from a document"
|
||||
|
||||
[portal.agentBuilder.empty]
|
||||
description = "Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish."
|
||||
title = "No agents yet"
|
||||
|
||||
[portal.agentBuilder.evals]
|
||||
casesPassing = "Cases passing"
|
||||
columnCase = "Eval case"
|
||||
columnLatency = "Latency"
|
||||
columnResult = "Result"
|
||||
fail = "fail"
|
||||
goldenSetPassRate = "Golden-set pass rate"
|
||||
latencyMs = "{{ms}} ms"
|
||||
notRun = "not run"
|
||||
pass = "pass"
|
||||
passRate = "Pass rate"
|
||||
runEvals = "Run evals"
|
||||
|
||||
[portal.agentBuilder.evals.empty]
|
||||
description = "Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
|
||||
title = "No golden set yet"
|
||||
|
||||
[portal.agentBuilder.kpi]
|
||||
acrossGoldenSets = "across golden sets"
|
||||
activeAgents = "Active agents"
|
||||
avgPassRate = "Avg eval pass rate"
|
||||
fleetWide = "fleet-wide"
|
||||
latestPublished = "Latest published"
|
||||
scenarios = "Scenarios"
|
||||
testCases = "test cases"
|
||||
totalDescription_one = "{{count}} total"
|
||||
totalDescription_other = "{{count}} total"
|
||||
|
||||
[portal.agentBuilder.scenarios]
|
||||
add = "Add"
|
||||
addScenario = "Add scenario"
|
||||
enable = "Enable"
|
||||
expectationLabel = "Expected behaviour"
|
||||
expectationPlaceholder = "What the agent should do"
|
||||
inEval = "in eval"
|
||||
mute = "Mute"
|
||||
muted = "muted"
|
||||
nameLabel = "Name"
|
||||
namePlaceholder = "e.g. Compliance escalation"
|
||||
|
||||
[portal.agentBuilder.status]
|
||||
draft = "Draft"
|
||||
published = "Published"
|
||||
|
||||
[portal.agentBuilder.tabs]
|
||||
evals = "Evals"
|
||||
scenarios = "Scenarios"
|
||||
tools = "Tools"
|
||||
versions = "Versions"
|
||||
|
||||
[portal.agentBuilder.tools]
|
||||
broadAccess = "Broad access"
|
||||
deniedHint = "Selected tools are blocked. Everything else stays callable."
|
||||
deniedTools = "Denied tools"
|
||||
governanceGate = "Tool governance is available on the Enterprise plan."
|
||||
restricted = "Restricted"
|
||||
restrictedAccess = "Restricted tool access"
|
||||
restrictedDescription = "Allow every tool except the ones you deny below."
|
||||
|
||||
[portal.agentBuilder.versions]
|
||||
current = "current"
|
||||
historyGate = "Full version history and rollback are available on the Enterprise plan."
|
||||
publish = "Publish"
|
||||
rollBack = "Roll back"
|
||||
|
||||
[portal.assistant]
|
||||
close = "Close assistant"
|
||||
error = "Couldn't reach the assistant."
|
||||
@@ -7988,7 +7904,6 @@ subtitle = "Reusable input connections that feed documents into Stirling. Config
|
||||
title = "Sources"
|
||||
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
|
||||
[portal.sources.builder]
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
|
||||
|
||||
describe("AgentBuilderAction (SaaS)", () => {
|
||||
it("renders nothing — Agent Builder is hidden pre-release", () => {
|
||||
const { container } = render(<AgentBuilderAction />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* SaaS pre-release: Agent Builder isn't shipped yet, so its Sources-header entry
|
||||
* point is hidden.
|
||||
*/
|
||||
export function AgentBuilderAction() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
|
||||
/**
|
||||
* SaaS pre-release: Agent Builder isn't shipped yet and its Sources entry point is
|
||||
* hidden, so redirect any /agent-builder deep link back to Home.
|
||||
*/
|
||||
export function AgentBuilder() {
|
||||
return <Navigate to={toPortalPath(VIEW_PATHS.home)} replace />;
|
||||
}
|
||||
@@ -62,7 +62,6 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
|
||||
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
|
||||
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
|
||||
| Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` |
|
||||
| Agent Builder | `GET /v1/agents` | `tier` | `fetchAgents` | `AgentsResponse` |
|
||||
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
|
||||
| Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` |
|
||||
| Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` |
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Pipelines } from "@portal/views/Pipelines";
|
||||
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
import { Sources } from "@portal/views/Sources";
|
||||
import { SourceBuilder } from "@portal/views/SourceBuilder";
|
||||
import { AgentBuilder } from "@portal/views/AgentBuilder";
|
||||
import { Policies } from "@portal/views/Policies";
|
||||
import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
@@ -51,10 +50,6 @@ export function ViewRouter() {
|
||||
path={`${rel(VIEW_PATHS.sources)}/:id`}
|
||||
element={<SourceBuilder />}
|
||||
/>
|
||||
<Route
|
||||
path={rel(VIEW_PATHS["agent-builder"])}
|
||||
element={<AgentBuilder />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
|
||||
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
|
||||
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
|
||||
/*
|
||||
* An "agent" here is an AI agent that classifies, extracts from, and routes
|
||||
* documents. The builder is its lifecycle surface: scenarios (named test
|
||||
* cases), tool-access governance, an eval / golden set, and version history.
|
||||
*/
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Domain types */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type AgentStatus = "draft" | "published";
|
||||
|
||||
/**
|
||||
* Tool-access posture. `broad` lets the agent call any tool it can reach;
|
||||
* `restricted` is allow-by-default minus an explicit deny list (the governance
|
||||
* mode enterprise tenants use to fence agents away from sensitive tools).
|
||||
*/
|
||||
export type ToolMode = "broad" | "restricted";
|
||||
|
||||
/** A named test case describing expected agent behaviour for a kind of input. */
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What the agent is expected to do for this input. */
|
||||
expectation: string;
|
||||
/** Whether this scenario is currently exercised by the eval run. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** A single golden-set check with its last-run outcome. */
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Last observed pass/fail; null when the case has never been run. */
|
||||
passing: boolean | null;
|
||||
/** Mean latency of the last run in milliseconds. */
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface AgentVersion {
|
||||
/** Display label, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
status: AgentStatus;
|
||||
/** ISO timestamp the version was created. */
|
||||
createdAt: string;
|
||||
author: string;
|
||||
/** One-line change summary. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
/** One-line role description shown under the name in the selector. */
|
||||
role: string;
|
||||
status: AgentStatus;
|
||||
/** Current working version, e.g. "v3" or "v2-draft". */
|
||||
version: string;
|
||||
model: string;
|
||||
scenarios: Scenario[];
|
||||
toolMode: ToolMode;
|
||||
/** Tools the agent may not call when `toolMode` is "restricted". */
|
||||
deniedTools: string[];
|
||||
/** Count of golden-set cases currently passing. */
|
||||
evalsPassing: number;
|
||||
/** Total golden-set cases. */
|
||||
evalsTotal: number;
|
||||
evalCases: EvalCase[];
|
||||
versions: AgentVersion[];
|
||||
}
|
||||
|
||||
export interface AgentsSummary {
|
||||
/** Agents in the "published" state. */
|
||||
activeAgents: number;
|
||||
/** Total agents regardless of status. */
|
||||
totalAgents: number;
|
||||
/** Mean eval pass-rate across all agents, 0..1. */
|
||||
avgPassRate: number;
|
||||
/** Total scenarios across all agents. */
|
||||
totalScenarios: number;
|
||||
/** Latest published version label across the fleet, e.g. "v3". */
|
||||
latestPublished: string;
|
||||
}
|
||||
|
||||
export interface AgentsResponse {
|
||||
summary: AgentsSummary;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Presentation metadata (chip tone per status). Product copy, client-side. */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
|
||||
published: "success",
|
||||
draft: "neutral",
|
||||
};
|
||||
|
||||
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
published: "portal.agentBuilder.status.published",
|
||||
draft: "portal.agentBuilder.status.draft",
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
|
||||
* palette in restricted mode so the deny list is picked from a known set
|
||||
* rather than free-typed.
|
||||
*/
|
||||
export const TOOL_CATALOGUE = [
|
||||
"extract.fields",
|
||||
"classify.document",
|
||||
"route.pipeline",
|
||||
"lookup.crm",
|
||||
"send.email",
|
||||
"write.audit",
|
||||
"read.pii",
|
||||
"invoke.webhook",
|
||||
] as const;
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoints */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
|
||||
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
|
||||
return apiClient.local.json<AgentsResponse>(
|
||||
`/v1/agents?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ describe("portal demo data seam", () => {
|
||||
it("is inert until enabled", async () => {
|
||||
expect(
|
||||
await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
new URL("/v1/notifications", window.location.origin),
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
@@ -22,12 +22,12 @@ describe("portal demo data seam", () => {
|
||||
it("answers from the fixture handlers while enabled", async () => {
|
||||
await enablePortalDemoData();
|
||||
const res = await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
new URL("/v1/notifications", window.location.origin),
|
||||
{},
|
||||
);
|
||||
expect(res?.status).toBe(200);
|
||||
const body = (await res?.json()) as { agents: unknown[] };
|
||||
expect(body.agents.length).toBeGreaterThan(0);
|
||||
const body = (await res?.json()) as unknown[];
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("releases back to the network on disable", async () => {
|
||||
@@ -35,7 +35,7 @@ describe("portal demo data seam", () => {
|
||||
disablePortalDemoData();
|
||||
expect(
|
||||
await resolveDemoResponse(
|
||||
new URL("/v1/agents?tier=pro", window.location.origin),
|
||||
new URL("/v1/notifications", window.location.origin),
|
||||
{},
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof AgentBuilderPanel> = {
|
||||
title: "Portal/AgentBuilder/AgentBuilderPanel",
|
||||
component: AgentBuilderPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "52rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentBuilderPanel>;
|
||||
|
||||
/** Published agent with enterprise governance unlocked. */
|
||||
export const Published: Story = {
|
||||
args: { agent: PRO[0], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Draft agent with governance locked (pro/free posture). */
|
||||
export const DraftLocked: Story = {
|
||||
args: { agent: PRO[2], governanceUnlocked: false },
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
type BuilderTab = "scenarios" | "tools" | "evals" | "versions";
|
||||
|
||||
interface AgentBuilderPanelProps {
|
||||
agent: Agent;
|
||||
/** Enterprise unlocks restricted-tools governance and deep version history. */
|
||||
governanceUnlocked: boolean;
|
||||
}
|
||||
|
||||
/** The selected agent's builder: header + tabbed Scenarios / Tools / Evals / Versions. */
|
||||
export function AgentBuilderPanel({
|
||||
agent,
|
||||
governanceUnlocked,
|
||||
}: AgentBuilderPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<BuilderTab>("scenarios");
|
||||
|
||||
const tabs: TabItem<BuilderTab>[] = [
|
||||
{
|
||||
key: "scenarios",
|
||||
label: t("portal.agentBuilder.tabs.scenarios"),
|
||||
count: agent.scenarios.length,
|
||||
},
|
||||
{ key: "tools", label: t("portal.agentBuilder.tabs.tools") },
|
||||
{
|
||||
key: "evals",
|
||||
label: t("portal.agentBuilder.tabs.evals"),
|
||||
count: agent.evalsTotal > 0 ? agent.evalsTotal : undefined,
|
||||
},
|
||||
{
|
||||
key: "versions",
|
||||
label: t("portal.agentBuilder.tabs.versions"),
|
||||
count: agent.versions.length,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="portal-agents__builder">
|
||||
<header className="portal-agents__builder-head">
|
||||
<div>
|
||||
<h2 className="portal-agents__builder-title">{agent.name}</h2>
|
||||
<span className="portal-agents__builder-sub">{agent.role}</span>
|
||||
</div>
|
||||
<div className="portal-agents__builder-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
|
||||
{t(AGENT_STATUS_LABEL[agent.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__builder-version">
|
||||
{agent.version}
|
||||
</code>
|
||||
<code className="portal-agents__builder-model">{agent.model}</code>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs<BuilderTab>
|
||||
items={tabs}
|
||||
activeKey={tab}
|
||||
onChange={setTab}
|
||||
variant="underline"
|
||||
ariaLabel={t("portal.agentBuilder.sectionsAriaLabel")}
|
||||
/>
|
||||
|
||||
{tab === "scenarios" && <ScenariosPanel agent={agent} />}
|
||||
{tab === "tools" && (
|
||||
<ToolsPanel agent={agent} governanceUnlocked={governanceUnlocked} />
|
||||
)}
|
||||
{tab === "evals" && <EvalsPanel agent={agent} />}
|
||||
{tab === "versions" && (
|
||||
<VersionsPanel agent={agent} historyUnlocked={governanceUnlocked} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { buildAgentsResponse } from "@portal/mocks/agents";
|
||||
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
|
||||
|
||||
const meta: Meta<typeof AgentKpiStrip> = {
|
||||
title: "Portal/AgentBuilder/AgentKpiStrip",
|
||||
component: AgentKpiStrip,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentKpiStrip>;
|
||||
|
||||
export const Pro: Story = {
|
||||
args: { summary: buildAgentsResponse("pro").summary, loading: false },
|
||||
};
|
||||
|
||||
export const Enterprise: Story = {
|
||||
args: { summary: buildAgentsResponse("enterprise").summary, loading: false },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: { summary: null, loading: true },
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MetricCard, MetricStrip } from "@app/ui";
|
||||
import type { AgentsSummary } from "@portal/api/agents";
|
||||
|
||||
interface AgentKpiStripProps {
|
||||
summary: AgentsSummary | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* KPI labels are product copy — they describe what each metric IS, not its
|
||||
* value — so the strip's structure stays stable across loading / ready
|
||||
* states; only values flow from the API.
|
||||
*/
|
||||
const kpiLabels = [
|
||||
t("portal.agentBuilder.kpi.activeAgents"),
|
||||
t("portal.agentBuilder.kpi.avgPassRate"),
|
||||
t("portal.agentBuilder.kpi.scenarios"),
|
||||
t("portal.agentBuilder.kpi.latestPublished"),
|
||||
] as const;
|
||||
|
||||
const values: (string | number)[] = summary
|
||||
? [
|
||||
summary.activeAgents,
|
||||
`${Math.round(summary.avgPassRate * 100)}%`,
|
||||
summary.totalScenarios,
|
||||
summary.latestPublished,
|
||||
]
|
||||
: ["—", "—", "—", "—"];
|
||||
|
||||
const descriptions: (string | undefined)[] = summary
|
||||
? [
|
||||
t("portal.agentBuilder.kpi.totalDescription", {
|
||||
count: summary.totalAgents,
|
||||
}),
|
||||
t("portal.agentBuilder.kpi.acrossGoldenSets"),
|
||||
t("portal.agentBuilder.kpi.testCases"),
|
||||
t("portal.agentBuilder.kpi.fleetWide"),
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<MetricStrip>
|
||||
{kpiLabels.map((label, i) => (
|
||||
<MetricCard
|
||||
key={label}
|
||||
label={label}
|
||||
value={loading ? "—" : values[i]}
|
||||
description={loading ? undefined : descriptions[i]}
|
||||
/>
|
||||
))}
|
||||
</MetricStrip>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
|
||||
|
||||
const AGENTS = agentsFor("enterprise");
|
||||
|
||||
const meta: Meta<typeof AgentSelector> = {
|
||||
title: "Portal/AgentBuilder/AgentSelector",
|
||||
component: AgentSelector,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "18rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AgentSelector>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { agents: AGENTS, selectedId: AGENTS[0].id, onSelect: () => {} },
|
||||
};
|
||||
|
||||
/** Clicking a row moves the selection — drives which builder is shown. */
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [id, setId] = useState(AGENTS[0].id);
|
||||
return <AgentSelector agents={AGENTS} selectedId={id} onSelect={setId} />;
|
||||
},
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface AgentSelectorProps {
|
||||
agents: Agent[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Left-rail list of agents; the selected row drives which builder is shown. */
|
||||
export function AgentSelector({
|
||||
agents,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: AgentSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav
|
||||
className="portal-agents__selector"
|
||||
aria-label={t("portal.agentBuilder.selectorAriaLabel")}
|
||||
>
|
||||
{agents.map((a) => (
|
||||
<Button
|
||||
key={a.id}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-agents__selector-item" +
|
||||
(a.id === selectedId ? " is-selected" : "")
|
||||
}
|
||||
aria-current={a.id === selectedId}
|
||||
onClick={() => onSelect(a.id)}
|
||||
>
|
||||
<span className="portal-agents__selector-main">
|
||||
<strong className="portal-agents__selector-name">{a.name}</strong>
|
||||
<span className="portal-agents__selector-role">{a.role}</span>
|
||||
</span>
|
||||
<span className="portal-agents__selector-meta">
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
|
||||
{t(AGENT_STATUS_LABEL[a.status])}
|
||||
</StatusBadge>
|
||||
<code className="portal-agents__selector-version">{a.version}</code>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "@app/ui";
|
||||
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
|
||||
|
||||
const meta: Meta<typeof BootstrapDialog> = {
|
||||
title: "Portal/AgentBuilder/BootstrapDialog",
|
||||
component: BootstrapDialog,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BootstrapDialog>;
|
||||
|
||||
/** Open by default so the dialog is visible in the canvas. */
|
||||
export const Open: Story = {
|
||||
args: { open: true, onClose: () => {} },
|
||||
};
|
||||
|
||||
/** Toggled from a trigger, mirroring how the view drives it. */
|
||||
export const Triggered: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
<Button onClick={() => setOpen(true)}>Bootstrap from document</Button>
|
||||
<BootstrapDialog open={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
|
||||
import { Button, Modal } from "@app/ui";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface BootstrapDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a new agent from a sample document — the user drops one representative
|
||||
* file and the backend proposes scenarios and an extraction schema. Demo stub:
|
||||
* it captures the chosen file name locally and closes without provisioning.
|
||||
*/
|
||||
export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
function close() {
|
||||
onClose();
|
||||
setTimeout(() => setFileName(null), 200);
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
// TODO(backend): POST /v1/agents/bootstrap (multipart sample document) —
|
||||
// infer a starter agent (scenarios + extraction schema) from the file.
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="md"
|
||||
title={t("portal.agentBuilder.bootstrap.title")}
|
||||
subtitle={t("portal.agentBuilder.bootstrap.subtitle")}
|
||||
footer={
|
||||
<div className="portal-agents__dialog-footer">
|
||||
<Button variant="tertiary" size="sm" onClick={close}>
|
||||
{t("portal.agentBuilder.bootstrap.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={bootstrap} disabled={!fileName}>
|
||||
{t("portal.agentBuilder.bootstrap.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-agents__bootstrap">
|
||||
<p className="portal-agents__bootstrap-lead">
|
||||
{t("portal.agentBuilder.bootstrap.lead")}
|
||||
</p>
|
||||
<label className="portal-agents__dropzone">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.png,.jpg,.jpeg,.tiff"
|
||||
className="portal-agents__dropzone-input"
|
||||
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
|
||||
/>
|
||||
<span className="portal-agents__dropzone-icon" aria-hidden>
|
||||
<FileUploadRounded style={{ fontSize: "1.5rem" }} />
|
||||
</span>
|
||||
<span className="portal-agents__dropzone-text">
|
||||
{fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
const FREE = agentsFor("free");
|
||||
|
||||
const meta: Meta<typeof EvalsPanel> = {
|
||||
title: "Portal/AgentBuilder/EvalsPanel",
|
||||
component: EvalsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EvalsPanel>;
|
||||
|
||||
/** High pass-rate agent — bar and pass-rate tile read green. */
|
||||
export const Passing: Story = {
|
||||
args: { agent: PRO[0] },
|
||||
};
|
||||
|
||||
/** KYC draft sits below the green band, so the pass-rate tile warns. */
|
||||
export const BelowTarget: Story = {
|
||||
args: { agent: PRO[2] },
|
||||
};
|
||||
|
||||
/** Free-tier agent has no golden set — the panel shows the upgrade gate. */
|
||||
export const NoGoldenSet: Story = {
|
||||
args: { agent: FREE[0] },
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
ProgressBar,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@app/ui";
|
||||
import type { Agent, EvalCase } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface EvalsPanelProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
/** Golden-set pass-rate, the per-case results table, and a run affordance. */
|
||||
export function EvalsPanel({ agent }: EvalsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: TableColumn<EvalCase>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: t("portal.agentBuilder.evals.columnCase"),
|
||||
render: (c) => c.name,
|
||||
},
|
||||
{
|
||||
key: "result",
|
||||
header: t("portal.agentBuilder.evals.columnResult"),
|
||||
render: (c) =>
|
||||
c.passing === null ? (
|
||||
<span className="portal-agents__muted">
|
||||
{t("portal.agentBuilder.evals.notRun")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
|
||||
{c.passing
|
||||
? t("portal.agentBuilder.evals.pass")
|
||||
: t("portal.agentBuilder.evals.fail")}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "latency",
|
||||
header: t("portal.agentBuilder.evals.columnLatency"),
|
||||
align: "right",
|
||||
render: (c) => (
|
||||
<span className="portal-agents__mono">
|
||||
{t("portal.agentBuilder.evals.latencyMs", { ms: c.latencyMs })}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (agent.evalsTotal === 0) {
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<EmptyState
|
||||
title={t("portal.agentBuilder.evals.empty.title")}
|
||||
description={t("portal.agentBuilder.evals.empty.description")}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rate = agent.evalsPassing / agent.evalsTotal;
|
||||
|
||||
function runEvals() {
|
||||
// TODO(backend): POST /v1/agents/{id}/evals/run — kick off a golden-set
|
||||
// run, then poll for the updated case results.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<div className="portal-agents__eval-head">
|
||||
<div className="portal-agents__stat-grid portal-agents__stat-grid--two">
|
||||
<StatTile
|
||||
label={t("portal.agentBuilder.evals.passRate")}
|
||||
value={`${Math.round(rate * 100)}%`}
|
||||
tone={rate >= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("portal.agentBuilder.evals.casesPassing")}
|
||||
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={runEvals}>
|
||||
{t("portal.agentBuilder.evals.runEvals")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="portal-agents__bar-row">
|
||||
<div className="portal-agents__bar-head">
|
||||
<span>{t("portal.agentBuilder.evals.goldenSetPassRate")}</span>
|
||||
<strong>{Math.round(rate * 100)}%</strong>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
color={rate >= 0.95 ? "var(--color-green)" : "var(--color-amber)"}
|
||||
label={t("portal.agentBuilder.evals.goldenSetPassRate")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table<EvalCase>
|
||||
columns={columns}
|
||||
rows={agent.evalCases}
|
||||
rowKey={(c) => c.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
|
||||
|
||||
const AGENTS = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof ScenariosPanel> = {
|
||||
title: "Portal/AgentBuilder/ScenariosPanel",
|
||||
component: ScenariosPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ScenariosPanel>;
|
||||
|
||||
/** Contract Router ships three scenarios; the add-row stages a fourth locally. */
|
||||
export const Default: Story = {
|
||||
args: { agent: AGENTS[0] },
|
||||
};
|
||||
|
||||
/** An agent with a muted scenario (excluded from the eval run). */
|
||||
export const WithMutedScenario: Story = {
|
||||
args: { agent: AGENTS[1] },
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, FormField, Input, StatusBadge } from "@app/ui";
|
||||
import type { Agent, Scenario } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface ScenariosPanelProps {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named test cases describing expected behaviour. Edits live in local state —
|
||||
* the surface is mock-driven, so adding a row only stages it client-side until
|
||||
* the submit endpoint exists.
|
||||
*/
|
||||
export function ScenariosPanel({ agent }: ScenariosPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
// Seed from the agent and re-seed when the selection changes (key prop on the
|
||||
// builder forces a remount, so a plain useState initialiser is enough).
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>(agent.scenarios);
|
||||
const [name, setName] = useState("");
|
||||
const [expectation, setExpectation] = useState("");
|
||||
|
||||
const canAdd = name.trim() !== "" && expectation.trim() !== "";
|
||||
|
||||
function addScenario() {
|
||||
if (!canAdd) return;
|
||||
const next: Scenario = {
|
||||
id: `sc-local-${Date.now()}`,
|
||||
name: name.trim(),
|
||||
expectation: expectation.trim(),
|
||||
enabled: true,
|
||||
};
|
||||
// TODO(backend): POST /v1/agents/{id}/scenarios { name, expectation } —
|
||||
// persist the scenario, then replace the optimistic row with the response.
|
||||
setScenarios((cur) => [...cur, next]);
|
||||
setName("");
|
||||
setExpectation("");
|
||||
}
|
||||
|
||||
function toggleEnabled(id: string) {
|
||||
setScenarios((cur) =>
|
||||
cur.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<ul className="portal-agents__scenarios">
|
||||
{scenarios.map((s) => (
|
||||
<li key={s.id} className="portal-agents__scenario">
|
||||
<div className="portal-agents__scenario-text">
|
||||
<div className="portal-agents__scenario-head">
|
||||
<strong>{s.name}</strong>
|
||||
<StatusBadge
|
||||
tone={s.enabled ? "success" : "neutral"}
|
||||
size="sm"
|
||||
showDot={false}
|
||||
>
|
||||
{s.enabled
|
||||
? t("portal.agentBuilder.scenarios.inEval")
|
||||
: t("portal.agentBuilder.scenarios.muted")}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<span className="portal-agents__scenario-expect">
|
||||
{s.expectation}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={() => toggleEnabled(s.id)}
|
||||
>
|
||||
{s.enabled
|
||||
? t("portal.agentBuilder.scenarios.mute")
|
||||
: t("portal.agentBuilder.scenarios.enable")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="portal-agents__scenario-add">
|
||||
<Chip accent="default" size="sm">
|
||||
{t("portal.agentBuilder.scenarios.addScenario")}
|
||||
</Chip>
|
||||
<div className="portal-agents__scenario-form">
|
||||
<FormField label={t("portal.agentBuilder.scenarios.nameLabel")}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("portal.agentBuilder.scenarios.namePlaceholder")}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.agentBuilder.scenarios.expectationLabel")}
|
||||
>
|
||||
<Input
|
||||
value={expectation}
|
||||
onChange={(e) => setExpectation(e.target.value)}
|
||||
placeholder={t(
|
||||
"portal.agentBuilder.scenarios.expectationPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button size="sm" onClick={addScenario} disabled={!canAdd}>
|
||||
{t("portal.agentBuilder.scenarios.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
|
||||
|
||||
const AGENTS = agentsFor("enterprise");
|
||||
|
||||
const meta: Meta<typeof ToolsPanel> = {
|
||||
title: "Portal/AgentBuilder/ToolsPanel",
|
||||
component: ToolsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ToolsPanel>;
|
||||
|
||||
/** Restricted mode with a deny list — the enterprise governance posture. */
|
||||
export const Restricted: Story = {
|
||||
args: { agent: AGENTS[0], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Broad access — every tool callable. */
|
||||
export const Broad: Story = {
|
||||
args: { agent: AGENTS[1], governanceUnlocked: true },
|
||||
};
|
||||
|
||||
/** Governance locked: the toggle is disabled and explains the upgrade. */
|
||||
export const GovernanceLocked: Story = {
|
||||
args: { agent: AGENTS[0], governanceUnlocked: false },
|
||||
};
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, ToggleSwitch } from "@app/ui";
|
||||
import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface ToolsPanelProps {
|
||||
agent: Agent;
|
||||
/** Restricted-tools governance is an enterprise capability. */
|
||||
governanceUnlocked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-access posture. `broad` grants every tool; `restricted` is allow-by-
|
||||
* default minus an explicit deny list, picked from the known tool catalogue.
|
||||
*/
|
||||
export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<ToolMode>(agent.toolMode);
|
||||
const [denied, setDenied] = useState<string[]>(agent.deniedTools);
|
||||
|
||||
function setRestricted(on: boolean) {
|
||||
// TODO(backend): PATCH /v1/agents/{id}/tools { mode, deniedTools } —
|
||||
// persist the access posture.
|
||||
setMode(on ? "restricted" : "broad");
|
||||
}
|
||||
|
||||
function toggleDenied(tool: string) {
|
||||
setDenied((cur) =>
|
||||
cur.includes(tool) ? cur.filter((t) => t !== tool) : [...cur, tool],
|
||||
);
|
||||
}
|
||||
|
||||
const restricted = mode === "restricted";
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<div className="portal-agents__tool-mode">
|
||||
<ToggleSwitch
|
||||
checked={restricted}
|
||||
onChange={setRestricted}
|
||||
disabled={!governanceUnlocked}
|
||||
label={t("portal.agentBuilder.tools.restrictedAccess")}
|
||||
description={
|
||||
governanceUnlocked
|
||||
? t("portal.agentBuilder.tools.restrictedDescription")
|
||||
: t("portal.agentBuilder.tools.governanceGate")
|
||||
}
|
||||
/>
|
||||
<Chip accent={restricted ? "warning" : "success"} size="sm">
|
||||
{restricted
|
||||
? t("portal.agentBuilder.tools.restricted")
|
||||
: t("portal.agentBuilder.tools.broadAccess")}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{restricted && (
|
||||
<div className="portal-agents__detail-section">
|
||||
<span className="portal-agents__detail-heading">
|
||||
{t("portal.agentBuilder.tools.deniedTools")}
|
||||
</span>
|
||||
<p className="portal-agents__hint">
|
||||
{t("portal.agentBuilder.tools.deniedHint")}
|
||||
</p>
|
||||
<div className="portal-agents__chips">
|
||||
{TOOL_CATALOGUE.map((tool) => {
|
||||
const isDenied = denied.includes(tool);
|
||||
return (
|
||||
<Chip
|
||||
key={tool}
|
||||
accent={isDenied ? "danger" : "neutral"}
|
||||
size="sm"
|
||||
onClick={
|
||||
governanceUnlocked ? () => toggleDenied(tool) : undefined
|
||||
}
|
||||
>
|
||||
{isDenied ? "✕ " : ""}
|
||||
{tool}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { agentsFor } from "@portal/mocks/agents";
|
||||
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
|
||||
|
||||
const PRO = agentsFor("pro");
|
||||
|
||||
const meta: Meta<typeof VersionsPanel> = {
|
||||
title: "Portal/AgentBuilder/VersionsPanel",
|
||||
component: VersionsPanel,
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof VersionsPanel>;
|
||||
|
||||
/** Full history with rollback on prior published versions. */
|
||||
export const FullHistory: Story = {
|
||||
args: { agent: PRO[0], historyUnlocked: true },
|
||||
};
|
||||
|
||||
/** A draft current version offers a publish action. */
|
||||
export const DraftAwaitingPublish: Story = {
|
||||
args: { agent: PRO[2], historyUnlocked: true },
|
||||
};
|
||||
|
||||
/** History locked: only the current version shows, with an upgrade hint. */
|
||||
export const HistoryLocked: Story = {
|
||||
args: { agent: PRO[0], historyUnlocked: false },
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, StatusBadge } from "@app/ui";
|
||||
import {
|
||||
type Agent,
|
||||
AGENT_STATUS_LABEL,
|
||||
AGENT_STATUS_TONE,
|
||||
} from "@portal/api/agents";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
interface VersionsPanelProps {
|
||||
agent: Agent;
|
||||
/** Deep version history is an enterprise capability; lower tiers see the gate. */
|
||||
historyUnlocked: boolean;
|
||||
}
|
||||
|
||||
/** Render an ISO timestamp as a short, locale-stable date. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Version history with publish / rollback actions per row. */
|
||||
export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
// Without governance, only the current version is meaningful to show.
|
||||
const versions = historyUnlocked
|
||||
? agent.versions
|
||||
: agent.versions.slice(0, 1);
|
||||
const publishedExists = agent.versions.some((v) => v.status === "published");
|
||||
|
||||
function publish(version: string) {
|
||||
void version;
|
||||
// TODO(backend): POST /v1/agents/{id}/versions/{v}/publish — promote the
|
||||
// draft to published and demote the previous published version.
|
||||
}
|
||||
|
||||
function rollback(version: string) {
|
||||
void version;
|
||||
// TODO(backend): POST /v1/agents/{id}/versions/{v}/rollback — re-publish a
|
||||
// prior version as the active one.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="portal-agents__panel">
|
||||
<ol className="portal-agents__versions">
|
||||
{versions.map((v) => {
|
||||
const isCurrent = v.version === agent.version;
|
||||
return (
|
||||
<li key={v.version} className="portal-agents__version">
|
||||
<div className="portal-agents__version-main">
|
||||
<div className="portal-agents__version-head">
|
||||
<code className="portal-agents__version-tag">
|
||||
{v.version}
|
||||
</code>
|
||||
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
|
||||
{t(AGENT_STATUS_LABEL[v.status])}
|
||||
</StatusBadge>
|
||||
{isCurrent && (
|
||||
<StatusBadge tone="info" size="sm" showDot={false}>
|
||||
{t("portal.agentBuilder.versions.current")}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<span className="portal-agents__version-note">{v.note}</span>
|
||||
<span className="portal-agents__version-meta">
|
||||
{formatDate(v.createdAt)} · {v.author}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-agents__version-actions">
|
||||
{v.status === "draft" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => publish(v.version)}
|
||||
>
|
||||
{t("portal.agentBuilder.versions.publish")}
|
||||
</Button>
|
||||
)}
|
||||
{v.status === "published" && !isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onClick={() => rollback(v.version)}
|
||||
>
|
||||
{t("portal.agentBuilder.versions.rollBack")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{!historyUnlocked && publishedExists && (
|
||||
<p className="portal-agents__hint">
|
||||
{t("portal.agentBuilder.versions.historyGate")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,18 +221,6 @@ export function PoliciesIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentBuilderIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<line x1="8" y1="16" x2="8" y2="16" />
|
||||
<line x1="16" y1="16" x2="16" y2="16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProcurementIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
import { AgentBuilderIcon } from "@portal/components/icons";
|
||||
|
||||
/**
|
||||
* The "Agent Builder" action in the Sources header. A flavor seam: the SaaS build
|
||||
* shadows this with a no-op (Agent Builder isn't shipped there yet).
|
||||
*/
|
||||
export function AgentBuilderAction() {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setActiveView("agent-builder")}
|
||||
leftSection={<AgentBuilderIcon size={16} />}
|
||||
>
|
||||
{t("portal.sources.actions.agentBuilder")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export type ViewId =
|
||||
| "editor"
|
||||
| "users"
|
||||
| "sources"
|
||||
| "agent-builder"
|
||||
| "policies"
|
||||
| "pipelines"
|
||||
| "documents"
|
||||
@@ -22,7 +21,6 @@ export const VIEW_LABELS: Record<ViewId, string> = {
|
||||
editor: "Editor",
|
||||
users: "Users",
|
||||
sources: "Sources",
|
||||
"agent-builder": "Agent Builder",
|
||||
policies: "Policies",
|
||||
pipelines: "Pipelines",
|
||||
documents: "Documents",
|
||||
@@ -38,7 +36,6 @@ export const VIEW_PATHS: Record<ViewId, string> = {
|
||||
editor: "/editor",
|
||||
users: "/users",
|
||||
sources: "/sources",
|
||||
"agent-builder": "/agent-builder",
|
||||
policies: "/policies",
|
||||
pipelines: "/pipelines",
|
||||
documents: "/documents",
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
/**
|
||||
* Agent Builder fixtures. Types live in api/agents.ts (the backend contract);
|
||||
* this module only builds fake data for Storybook and tests.
|
||||
*
|
||||
* An "agent" here is an AI agent that classifies, extracts from, and routes
|
||||
* documents. The builder is its lifecycle surface: scenarios (named test
|
||||
* cases), tool-access governance, an eval / golden set, and version history.
|
||||
*/
|
||||
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type { Agent, AgentsResponse, AgentsSummary } from "@portal/api/agents";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Fixture builders */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const CONTRACT_ROUTER: Agent = {
|
||||
id: "agent-contract-router",
|
||||
name: "Contract Router",
|
||||
role: "Classifies inbound contracts and routes to the right review queue",
|
||||
status: "published",
|
||||
version: "v3",
|
||||
model: "claude-opus-4.1",
|
||||
toolMode: "restricted",
|
||||
deniedTools: ["send.email", "read.pii"],
|
||||
evalsPassing: 23,
|
||||
evalsTotal: 24,
|
||||
scenarios: [
|
||||
{
|
||||
id: "sc-cr-1",
|
||||
name: "New customer file",
|
||||
expectation: "Route to onboarding queue and tag as net-new account",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-cr-2",
|
||||
name: "Renewal with price change",
|
||||
expectation: "Flag the delta and route to legal for redline review",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-cr-3",
|
||||
name: "Non-standard liability clause",
|
||||
expectation: "Escalate to senior counsel rather than auto-approving",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
evalCases: [
|
||||
{
|
||||
id: "ec-cr-1",
|
||||
name: "MSA — standard terms",
|
||||
passing: true,
|
||||
latencyMs: 1840,
|
||||
},
|
||||
{
|
||||
id: "ec-cr-2",
|
||||
name: "MSA — capped liability",
|
||||
passing: true,
|
||||
latencyMs: 2010,
|
||||
},
|
||||
{
|
||||
id: "ec-cr-3",
|
||||
name: "Order form — multi-year",
|
||||
passing: true,
|
||||
latencyMs: 1620,
|
||||
},
|
||||
{
|
||||
id: "ec-cr-4",
|
||||
name: "DPA — cross-border transfer",
|
||||
passing: false,
|
||||
latencyMs: 2440,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
version: "v3",
|
||||
status: "published",
|
||||
createdAt: "2026-05-28T14:12:00Z",
|
||||
author: "legal-ops@acme.com",
|
||||
note: "Added cross-border DPA detection",
|
||||
},
|
||||
{
|
||||
version: "v2",
|
||||
status: "published",
|
||||
createdAt: "2026-04-09T09:30:00Z",
|
||||
author: "legal-ops@acme.com",
|
||||
note: "Tightened renewal price-change routing",
|
||||
},
|
||||
{
|
||||
version: "v1",
|
||||
status: "published",
|
||||
createdAt: "2026-02-15T16:45:00Z",
|
||||
author: "you@acme.com",
|
||||
note: "Initial publish",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const INVOICE_AGENT: Agent = {
|
||||
id: "agent-invoice",
|
||||
name: "Invoice Agent",
|
||||
role: "Extracts line items and routes invoices into the AP pipeline",
|
||||
status: "published",
|
||||
version: "v5",
|
||||
model: "claude-sonnet-4.5",
|
||||
toolMode: "broad",
|
||||
deniedTools: [],
|
||||
evalsPassing: 31,
|
||||
evalsTotal: 32,
|
||||
scenarios: [
|
||||
{
|
||||
id: "sc-inv-1",
|
||||
name: "Multi-page invoice",
|
||||
expectation: "Extract all line items across pages into one record",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-inv-2",
|
||||
name: "Foreign currency total",
|
||||
expectation: "Preserve original currency and attach FX note",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-inv-3",
|
||||
name: "Missing PO number",
|
||||
expectation: "Route to manual matching rather than auto-posting",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
evalCases: [
|
||||
{ id: "ec-inv-1", name: "Single-page USD", passing: true, latencyMs: 980 },
|
||||
{ id: "ec-inv-2", name: "Multi-page EUR", passing: true, latencyMs: 1320 },
|
||||
{
|
||||
id: "ec-inv-3",
|
||||
name: "Handwritten total",
|
||||
passing: true,
|
||||
latencyMs: 1510,
|
||||
},
|
||||
{
|
||||
id: "ec-inv-4",
|
||||
name: "No PO reference",
|
||||
passing: false,
|
||||
latencyMs: 1100,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
version: "v5",
|
||||
status: "published",
|
||||
createdAt: "2026-06-01T11:00:00Z",
|
||||
author: "platform@acme.com",
|
||||
note: "FX note extraction for foreign-currency invoices",
|
||||
},
|
||||
{
|
||||
version: "v4",
|
||||
status: "published",
|
||||
createdAt: "2026-05-10T08:20:00Z",
|
||||
author: "platform@acme.com",
|
||||
note: "Multi-page line-item stitching",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const KYC_AGENT: Agent = {
|
||||
id: "agent-kyc",
|
||||
name: "KYC Agent",
|
||||
role: "Verifies identity documents and flags compliance gaps",
|
||||
status: "draft",
|
||||
version: "v2-draft",
|
||||
model: "claude-sonnet-4.5",
|
||||
toolMode: "restricted",
|
||||
deniedTools: ["send.email", "invoke.webhook"],
|
||||
evalsPassing: 14,
|
||||
evalsTotal: 20,
|
||||
scenarios: [
|
||||
{
|
||||
id: "sc-kyc-1",
|
||||
name: "KYC question",
|
||||
expectation: "Answer from the verified document set, never invent facts",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-kyc-2",
|
||||
name: "Expired identity document",
|
||||
expectation: "Mark verification as failed and request a fresh document",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-kyc-3",
|
||||
name: "Compliance escalation",
|
||||
expectation: "Escalate sanctions-list hits to the compliance officer",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
evalCases: [
|
||||
{ id: "ec-kyc-1", name: "Valid passport", passing: true, latencyMs: 2200 },
|
||||
{ id: "ec-kyc-2", name: "Expired licence", passing: true, latencyMs: 2050 },
|
||||
{
|
||||
id: "ec-kyc-3",
|
||||
name: "Sanctions-list match",
|
||||
passing: false,
|
||||
latencyMs: 2600,
|
||||
},
|
||||
{
|
||||
id: "ec-kyc-4",
|
||||
name: "Mismatched name fields",
|
||||
passing: false,
|
||||
latencyMs: 2310,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
version: "v2-draft",
|
||||
status: "draft",
|
||||
createdAt: "2026-06-12T13:40:00Z",
|
||||
author: "risk@acme.com",
|
||||
note: "Sanctions-list escalation path (in review)",
|
||||
},
|
||||
{
|
||||
version: "v1",
|
||||
status: "published",
|
||||
createdAt: "2026-03-22T10:15:00Z",
|
||||
author: "risk@acme.com",
|
||||
note: "Initial publish",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Enterprise-only agent — governance-heavy and exercised against a larger
|
||||
* golden set than the base tenants get, so it only appears for that tier.
|
||||
*/
|
||||
const COMPLIANCE_SWEEP: Agent = {
|
||||
id: "agent-compliance-sweep",
|
||||
name: "Compliance Sweep",
|
||||
role: "Audits processed documents against retention and PII policy",
|
||||
status: "published",
|
||||
version: "v4",
|
||||
model: "claude-opus-4.1",
|
||||
toolMode: "restricted",
|
||||
deniedTools: ["send.email", "route.pipeline", "invoke.webhook"],
|
||||
evalsPassing: 58,
|
||||
evalsTotal: 60,
|
||||
scenarios: [
|
||||
{
|
||||
id: "sc-cs-1",
|
||||
name: "Over-retention detection",
|
||||
expectation: "Flag documents held past their retention window",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "sc-cs-2",
|
||||
name: "Unredacted PII",
|
||||
expectation: "Quarantine and write an audit entry, never auto-delete",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
evalCases: [
|
||||
{
|
||||
id: "ec-cs-1",
|
||||
name: "Expired retention",
|
||||
passing: true,
|
||||
latencyMs: 3100,
|
||||
},
|
||||
{ id: "ec-cs-2", name: "SSN in free text", passing: true, latencyMs: 2900 },
|
||||
{
|
||||
id: "ec-cs-3",
|
||||
name: "Redacted re-share",
|
||||
passing: true,
|
||||
latencyMs: 2700,
|
||||
},
|
||||
{
|
||||
id: "ec-cs-4",
|
||||
name: "Cross-region copy",
|
||||
passing: false,
|
||||
latencyMs: 3300,
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
version: "v4",
|
||||
status: "published",
|
||||
createdAt: "2026-06-03T15:05:00Z",
|
||||
author: "compliance@acme.com",
|
||||
note: "Cross-region copy detection",
|
||||
},
|
||||
{
|
||||
version: "v3",
|
||||
status: "published",
|
||||
createdAt: "2026-05-01T12:00:00Z",
|
||||
author: "compliance@acme.com",
|
||||
note: "Retention-window policy refresh",
|
||||
},
|
||||
{
|
||||
version: "v2",
|
||||
status: "published",
|
||||
createdAt: "2026-03-14T09:00:00Z",
|
||||
author: "compliance@acme.com",
|
||||
note: "PII quarantine workflow",
|
||||
},
|
||||
{
|
||||
version: "v1",
|
||||
status: "published",
|
||||
createdAt: "2026-01-20T08:00:00Z",
|
||||
author: "you@acme.com",
|
||||
note: "Initial publish",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* On the free tier a single starter agent ships as a draft so the surface has
|
||||
* something to render, but with an empty golden set and no version history —
|
||||
* evals and version governance are paid capabilities.
|
||||
*/
|
||||
const STARTER_AGENT: Agent = {
|
||||
id: "agent-starter",
|
||||
name: "Starter Classifier",
|
||||
role: "Sorts uploads into a handful of document types",
|
||||
status: "draft",
|
||||
version: "v1-draft",
|
||||
model: "claude-haiku-4.5",
|
||||
toolMode: "broad",
|
||||
deniedTools: [],
|
||||
evalsPassing: 0,
|
||||
evalsTotal: 0,
|
||||
scenarios: [
|
||||
{
|
||||
id: "sc-st-1",
|
||||
name: "Unknown document type",
|
||||
expectation: "Fall back to the inbox queue instead of guessing",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
evalCases: [],
|
||||
versions: [
|
||||
{
|
||||
version: "v1-draft",
|
||||
status: "draft",
|
||||
createdAt: "2026-06-14T17:30:00Z",
|
||||
author: "you@acme.com",
|
||||
note: "Draft — not yet published",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const PRO_AGENTS: Agent[] = [CONTRACT_ROUTER, INVOICE_AGENT, KYC_AGENT];
|
||||
|
||||
/**
|
||||
* Trim an agent to the free-tier shape: drop the eval golden set and collapse
|
||||
* version history to the current version, mirroring the capability gating the
|
||||
* KPI strip advertises.
|
||||
*/
|
||||
function toFreeShape(agent: Agent): Agent {
|
||||
return {
|
||||
...agent,
|
||||
status: "draft",
|
||||
evalsPassing: 0,
|
||||
evalsTotal: 0,
|
||||
evalCases: [],
|
||||
versions: agent.versions.slice(0, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Agents for a given tier. */
|
||||
export function agentsFor(tier: Tier): Agent[] {
|
||||
if (tier === "free") return [toFreeShape(STARTER_AGENT)];
|
||||
if (tier === "enterprise") return [...PRO_AGENTS, COMPLIANCE_SWEEP];
|
||||
return PRO_AGENTS;
|
||||
}
|
||||
|
||||
function summaryFor(agents: Agent[]): AgentsSummary {
|
||||
const published = agents.filter((a) => a.status === "published");
|
||||
const totalScenarios = agents.reduce((n, a) => n + a.scenarios.length, 0);
|
||||
|
||||
// Pass-rate is meaningless without a golden set, so average only over agents
|
||||
// that actually have eval cases — free tenants land at 0.
|
||||
const withEvals = agents.filter((a) => a.evalsTotal > 0);
|
||||
const avgPassRate =
|
||||
withEvals.length === 0
|
||||
? 0
|
||||
: withEvals.reduce((sum, a) => sum + a.evalsPassing / a.evalsTotal, 0) /
|
||||
withEvals.length;
|
||||
|
||||
const latestPublished = published.length > 0 ? published[0].version : "—";
|
||||
|
||||
return {
|
||||
activeAgents: published.length,
|
||||
totalAgents: agents.length,
|
||||
avgPassRate,
|
||||
totalScenarios,
|
||||
latestPublished,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentsResponse(tier: Tier): AgentsResponse {
|
||||
const agents = agentsFor(tier);
|
||||
return { summary: summaryFor(agents), agents };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { buildAgentsResponse } from "@portal/mocks/agents";
|
||||
|
||||
export const agentsHandlers = [
|
||||
http.get("/v1/agents", async ({ request }) => {
|
||||
await delay(120);
|
||||
const url = new URL(request.url);
|
||||
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
|
||||
return HttpResponse.json(buildAgentsResponse(tier));
|
||||
}),
|
||||
];
|
||||
@@ -10,7 +10,6 @@ import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"
|
||||
import { docsHandlers } from "@portal/mocks/handlers/docs";
|
||||
import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
|
||||
import { agentsHandlers } from "@portal/mocks/handlers/agents";
|
||||
import { policiesHandlers } from "@portal/mocks/handlers/policies";
|
||||
import { classificationHandlers } from "@portal/mocks/handlers/classification";
|
||||
import { documentsHandlers } from "@portal/mocks/handlers/documents";
|
||||
@@ -30,7 +29,6 @@ export const handlers = [
|
||||
...procurementSaasHandlers,
|
||||
...usersHandlers,
|
||||
...teamSaasHandlers,
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
...classificationHandlers,
|
||||
...documentsHandlers,
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
.portal-agents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 84rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.portal-agents__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-agents__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 650;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-agents__sub {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* Two-column layout: selector rail + builder */
|
||||
.portal-agents__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 17rem 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 56rem) {
|
||||
.portal-agents__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Selector rail */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__selector-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-agents__selector-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-agents__selector-item.is-selected {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
box-shadow: 0 0 0 1px var(--color-blue) inset;
|
||||
}
|
||||
|
||||
.portal-agents__selector-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-agents__selector-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-agents__selector-role {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-agents__selector-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__selector-version,
|
||||
.portal-agents__builder-version,
|
||||
.portal-agents__builder-model,
|
||||
.portal-agents__version-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Builder panel */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__builder {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
}
|
||||
|
||||
.portal-agents__builder-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-agents__builder-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-agents__builder-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-agents__builder-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portal-agents__builder-model {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
|
||||
/* Shared panel body */
|
||||
.portal-agents__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
}
|
||||
|
||||
.portal-agents__detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__detail-heading {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-agents__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.portal-agents__hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-agents__muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-agents__mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-agents__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.portal-agents__stat-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 14rem));
|
||||
}
|
||||
|
||||
/* Bars (eval pass-rate) */
|
||||
.portal-agents__bar-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
|
||||
.portal-agents__bar-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-agents__bar-head strong {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Scenarios */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__scenarios {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__scenario {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-agents__scenario-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-agents__scenario-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__scenario-head strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-agents__scenario-expect {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-agents__scenario-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed var(--color-border-light);
|
||||
}
|
||||
|
||||
.portal-agents__scenario-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr auto;
|
||||
align-items: end;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.portal-agents__scenario-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Tools */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__tool-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Evals */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__eval-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Versions */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__versions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.portal-agents__version-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-agents__version-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-agents__version-tag {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-agents__version-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-agents__version-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-agents__version-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Bootstrap dialog */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.portal-agents__bootstrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.portal-agents__bootstrap-lead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.portal-agents__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.75rem 1rem;
|
||||
text-align: center;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-agents__dropzone:hover {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-agents__dropzone-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.portal-agents__dropzone-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-agents__dropzone-text {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-agents__dialog-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
|
||||
import { Button, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchAgents, type AgentsResponse } from "@portal/api/agents";
|
||||
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
|
||||
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
|
||||
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
|
||||
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
|
||||
import "@portal/views/AgentBuilder.css";
|
||||
|
||||
export function AgentBuilder() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const state = useAsync<AgentsResponse>(() => fetchAgents(tier), [tier]);
|
||||
const { data, loading } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [bootstrapOpen, setBootstrapOpen] = useState(false);
|
||||
|
||||
const agents = data?.agents ?? [];
|
||||
// Default the selection to the first agent once data lands, but honour an
|
||||
// explicit pick. Recomputed each render so a tier change re-points cleanly.
|
||||
const selected = agents.find((a) => a.id === selectedId) ?? agents[0] ?? null;
|
||||
|
||||
// Enterprise unlocks restricted-tools governance and deep version history.
|
||||
const governanceUnlocked = tier === "enterprise";
|
||||
|
||||
return (
|
||||
<div className="portal-agents">
|
||||
<header className="portal-agents__head">
|
||||
<div>
|
||||
<h1 className="portal-agents__title">
|
||||
{t("portal.agentBuilder.title")}
|
||||
</h1>
|
||||
<p className="portal-agents__sub">
|
||||
{t("portal.agentBuilder.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setBootstrapOpen(true)}
|
||||
leftSection={<FileUploadRounded style={{ fontSize: "1.2rem" }} />}
|
||||
>
|
||||
{t("portal.agentBuilder.bootstrapFromDocument")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<AgentKpiStrip summary={data?.summary ?? null} loading={loading} />
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-agents__layout">
|
||||
<div className="portal-agents__selector" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} height="4rem" />
|
||||
))}
|
||||
</div>
|
||||
<div className="portal-agents__builder" aria-hidden>
|
||||
<Skeleton height="18rem" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
title={t("portal.agentBuilder.empty.title")}
|
||||
description={t("portal.agentBuilder.empty.description")}
|
||||
actions={
|
||||
<Button onClick={() => setBootstrapOpen(true)}>
|
||||
{t("portal.agentBuilder.bootstrapFromDocument")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && selected && (
|
||||
<div className="portal-agents__layout">
|
||||
<AgentSelector
|
||||
agents={agents}
|
||||
selectedId={selected.id}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
<AgentBuilderPanel
|
||||
// Remount on selection change so each panel re-seeds its local
|
||||
// edit state from the freshly selected agent.
|
||||
key={selected.id}
|
||||
agent={selected}
|
||||
governanceUnlocked={governanceUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BootstrapDialog
|
||||
open={bootstrapOpen}
|
||||
onClose={() => setBootstrapOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,6 @@ vi.mock("@portal/api/integrations", () => ({
|
||||
deleteIntegration: vi.fn(),
|
||||
}));
|
||||
|
||||
// The Agent Builder header action is a flavor seam; stub it to keep the test focused.
|
||||
vi.mock("@portal/components/sources/AgentBuilderAction", () => ({
|
||||
AgentBuilderAction: () => null,
|
||||
}));
|
||||
|
||||
const RESPONSE: SourcesResponse = {
|
||||
kpis: [
|
||||
{ value: 1, description: "" },
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type SourceView,
|
||||
} from "@portal/api/sources";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
|
||||
import { KpiStrip } from "@portal/components/sources/KpiStrip";
|
||||
import { SourcesTable } from "@portal/components/sources/SourcesTable";
|
||||
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
|
||||
@@ -55,7 +54,6 @@ export function Sources() {
|
||||
</div>
|
||||
{activeTab === "sources" && (
|
||||
<div className="portal-sources__actions">
|
||||
<AgentBuilderAction />
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
|
||||
|
||||
Reference in New Issue
Block a user